Spec tabs
Overview, details, ingredients and shipping tabs with a sliding underline and a mono spec ledger.
componenttabssupplementswellness
$
npx shadcn@latest add @storekit/pdp-specspdp-specs
Live and interactive. Switch the brand in the frame header to see the same source under another token file.
Installation
$
npx shadcn@latest add @storekit/pdp-specs$
pnpm dlx shadcn@latest add @storekit/pdp-specs$
yarn dlx shadcn@latest add @storekit/pdp-specs$
bunx --bun shadcn@latest add @storekit/pdp-specsSource
components/storekit/pdp/pdp-specs.tsx
122 lines
"use client"
import { useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/components/storekit/lib/cn"
/* Tabbed product information. The underline glides between tabs with a
shared layoutId; Specifications renders as a two column mono ledger. */
type Spec = { label: string; value: string }
const EASE = [0.23, 1, 0.32, 1] as const
const RING = "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
const DEFAULT_SPECS: Spec[] = [
{ label: "Servings", value: "30 per pouch" },
{ label: "Serving size", value: "1 scoop (10g)" },
{ label: "Per serving", value: "$1.63" },
{ label: "Form", value: "Powder" },
{ label: "Diet", value: "Vegan, gluten free" },
{ label: "Made in", value: "USA, third-party tested" },
]
export function PdpSpecs({
overview = "One scoop of greens, adaptogens and probiotics to start the day. Mixes clean in cold water and tastes like a bright, lightly sweet green juice.",
specs = DEFAULT_SPECS,
ingredients = "Organic spirulina, wheatgrass and spinach, ashwagandha, a 10 billion CFU probiotic blend and monk fruit. No fillers, no artificial sweeteners, nothing we would not take ourselves.",
shipping = "Ships same day before 2pm ET. Free shipping on subscriptions, and tracking is emailed as soon as the label prints.",
className,
}: {
overview?: React.ReactNode
specs?: Spec[]
ingredients?: React.ReactNode
shipping?: React.ReactNode
className?: string
}) {
const reduce = useReducedMotion()
const uid = useId()
const tabs = ["Overview", "Details", "Ingredients", "Shipping"]
const [active, setActive] = useState(1)
const refs = useRef<(HTMLButtonElement | null)[]>([])
const onKeyDown = (e: React.KeyboardEvent) => {
let next = -1
if (e.key === "ArrowRight") next = (active + 1) % tabs.length
else if (e.key === "ArrowLeft") next = (active - 1 + tabs.length) % tabs.length
else if (e.key === "Home") next = 0
else if (e.key === "End") next = tabs.length - 1
if (next < 0) return
e.preventDefault()
setActive(next)
refs.current[next]?.focus()
}
const panel = [
<p key="o" className="max-w-prose text-[15px] leading-[1.5] text-sk-muted">{overview}</p>,
<dl key="s" className="grid gap-x-8 sm:grid-cols-2">
{specs.map((s) => (
<div key={s.label} className="flex items-baseline justify-between gap-4 border-b border-sk-line py-3">
<dt className="shrink-0 text-sm text-sk-muted">{s.label}</dt>
<dd className="min-w-0 break-all text-right font-sk-mono text-[13px] text-sk-ink">{s.value}</dd>
</div>
))}
</dl>,
<p key="r" className="max-w-prose text-[15px] leading-[1.5] text-sk-muted">{ingredients}</p>,
<p key="sh" className="max-w-prose text-[15px] leading-[1.5] text-sk-muted">{shipping}</p>,
]
return (
<div data-sk-section="pdp" data-sk-variant="pdp-specs" className={cn("w-full", className)}>
<div role="tablist" aria-label="Product information" className="flex overflow-x-auto border-b border-sk-line [scrollbar-width:none] sm:gap-1">
{tabs.map((t, i) => {
const selected = i === active
return (
<button
key={t}
ref={(el) => {
refs.current[i] = el
}}
id={`${uid}-tab-${i}`}
role="tab"
type="button"
aria-selected={selected}
aria-controls={`${uid}-panel`}
tabIndex={selected ? 0 : -1}
onClick={() => setActive(i)}
onKeyDown={onKeyDown}
className={cn(
"relative h-11 shrink-0 rounded-t-sk-btn px-2.5 text-[13px] font-medium transition-colors duration-200 sm:px-3 sm:text-sm",
selected ? "text-sk-ink" : "text-sk-muted hover:text-sk-ink",
RING
)}
>
{t}
{selected ? (
<motion.span
layoutId={`${uid}-underline`}
transition={{ duration: reduce ? 0 : 0.4, ease: EASE }}
className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-sk-accent"
/>
) : null}
</button>
)
})}
</div>
<div id={`${uid}-panel`} role="tabpanel" aria-labelledby={`${uid}-tab-${active}`} tabIndex={0} className={cn("pt-6", "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent")}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={active}
initial={{ opacity: 0, y: reduce ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -4 }}
transition={{ duration: reduce ? 0 : 0.3, ease: EASE }}
>
{panel[active]}
</motion.div>
</AnimatePresence>
</div>
</div>
)
}
Usage
Import from "@/components/storekit/pdp/pdp-specs" and pass your product data. The demo above exercises the full prop surface with sample values.