Spec tabs

Overview, details, ingredients and shipping tabs with a sliding underline and a mono spec ledger.

componenttabssupplementswellness
$npx shadcn@latest add @storekit/pdp-specs
pdp-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-specs

Source

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.

Spec tabs, StoreKit