Dropdown stack

Numbered native selects with label and value always visible, sequential unlock, price deltas and a running total.

componentdropdownanyvariants
$npx shadcn@latest add @storekit/pdp-dropdown-stack
pdp-dropdown-stack

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-dropdown-stack
$pnpm dlx shadcn@latest add @storekit/pdp-dropdown-stack
$yarn dlx shadcn@latest add @storekit/pdp-dropdown-stack
$bunx --bun shadcn@latest add @storekit/pdp-dropdown-stack

Source

components/storekit/pdp/pdp-dropdown-stack.tsx
156 lines
"use client"

import { useId, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, ChevronDown } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Structured dropdown stack for long option sets. Each step is a numbered
   field whose label and current value are always visible; the native select
   sits on top, invisible, so every platform gets its own picker and full
   keyboard support. Later steps unlock once the earlier ones have a value.
   Price deltas are integer cents and roll into the running total. */

type Choice = { value: string; label: string; delta?: number; disabled?: boolean }
type Field = { id: string; label: string; options: Choice[] }

const EASE = [0.16, 1, 0.3, 1] as const

const DEFAULT_FIELDS: Field[] = [
  {
    id: "flavor",
    label: "Flavor",
    options: [
      { value: "original", label: "Original" },
      { value: "pineapple", label: "Pineapple mint" },
      { value: "berry", label: "Wild berry", delta: 300 },
    ],
  },
  {
    id: "count",
    label: "Size",
    options: [
      { value: "1", label: "30 servings" },
      { value: "2", label: "60 servings", delta: 4000 },
      { value: "3", label: "3-pouch bundle", delta: 8000 },
    ],
  },
  {
    id: "shaker",
    label: "Add a shaker",
    options: [
      { value: "none", label: "None" },
      { value: "glass", label: "Glass shaker", delta: 1200 },
      { value: "steel", label: "Steel shaker", delta: 1800, disabled: true },
    ],
  },
]

export function PdpDropdownStack({
  fields = DEFAULT_FIELDS,
  basePrice = 5900,
  defaultValues = {},
  onChange,
  className,
}: {
  fields?: Field[]
  /** Price before any option deltas, integer cents. */
  basePrice?: number
  defaultValues?: Record<string, string>
  onChange?: (values: Record<string, string>, total: number) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const uid = useId()
  const sk = useSkBlock("pdp", "pdp-dropdown-stack")
  const [values, setValues] = useState<Record<string, string>>(defaultValues)

  const total = fields.reduce((sum, f) => sum + (f.options.find((o) => o.value === values[f.id])?.delta ?? 0), basePrice)
  const done = fields.every((f) => values[f.id])

  const set = (id: string, v: string) => {
    const next = { ...values, [id]: v }
    setValues(next)
    sk.emit("interact", id, undefined)
    onChange?.(next, fields.reduce((s, f) => s + (f.options.find((o) => o.value === next[f.id])?.delta ?? 0), basePrice))
  }

  return (
    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-3", className)}>
      <ol className="flex flex-col gap-2.5">
        {fields.map((f, i) => {
          const locked = fields.slice(0, i).some((p) => !values[p.id])
          const chosen = f.options.find((o) => o.value === values[f.id])
          const id = `${uid}-${f.id}`
          return (
            <li key={f.id}>
              <label
                htmlFor={id}
                className={cn(
                  "group/dd relative flex min-h-[60px] items-center gap-3 rounded-sk-card border bg-sk-card px-4 py-2.5 transition-[border-color,background-color,opacity] duration-200 ease-sk-out",
                  "focus-within:border-sk-accent focus-within:ring-[3px] focus-within:ring-sk-accent/15",
                  locked ? "border-sk-line opacity-50" : chosen ? "border-sk-line hover:border-sk-ink/30" : "border-sk-ink/30 hover:border-sk-ink/50"
                )}
              >
                <span
                  aria-hidden
                  className={cn(
                    "grid h-6 w-6 shrink-0 place-items-center rounded-full border font-sk-mono text-[11px] tabular-nums transition-colors duration-200",
                    chosen ? "border-sk-accent bg-sk-accent text-sk-accent-ink" : "border-sk-line text-sk-muted"
                  )}
                >
                  {chosen ? <Check size={12} strokeWidth={3} /> : i + 1}
                </span>
                <span className="flex w-0 min-w-0 flex-1 flex-col">
                  <span className="truncate font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{f.label}</span>
                  <span className={cn("truncate text-sm", chosen ? "font-medium text-sk-ink" : "text-sk-muted")}>
                    {chosen ? chosen.label : locked ? "Choose the step above first" : `Select ${f.label.toLowerCase()}`}
                  </span>
                </span>
                {chosen?.delta ? <span className="shrink-0 text-[13px] tabular-nums text-sk-muted">+{money(chosen.delta)}</span> : null}
                <ChevronDown size={16} aria-hidden className="shrink-0 text-sk-muted transition-transform duration-200 group-hover/dd:translate-y-0.5" />
                <select
                  id={id}
                  value={values[f.id] ?? ""}
                  disabled={locked}
                  onChange={(e) => set(f.id, e.target.value)}
                  className="absolute inset-0 h-full w-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed"
                >
                  <option value="" disabled>
                    {`Select ${f.label.toLowerCase()}`}
                  </option>
                  {f.options.map((o) => (
                    <option key={o.value} value={o.value} disabled={o.disabled}>
                      {o.label}
                      {o.delta ? ` (+${money(o.delta)})` : ""}
                      {o.disabled ? " (sold out)" : ""}
                    </option>
                  ))}
                </select>
              </label>
            </li>
          )
        })}
      </ol>
      <div className="flex items-center justify-between gap-4 border-t border-sk-line pt-3 text-sm">
        <span className="text-sk-muted">{done ? "Your configuration" : `${fields.filter((f) => values[f.id]).length} of ${fields.length} chosen`}</span>
        <span className="relative inline-flex h-6 overflow-hidden" aria-live="polite">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={total}
              initial={{ opacity: 0, y: reduce ? 0 : 8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: reduce ? 0 : -8 }}
              transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}
              className="font-semibold tabular-nums text-sk-price"
            >
              {money(total)}
            </motion.span>
          </AnimatePresence>
        </span>
      </div>
    </div>
  )
}

Usage

Import from "@/components/storekit/pdp/pdp-dropdown-stack" and pass your product data. The demo above exercises the full prop surface with sample values.

Dropdown stack, StoreKit