Swatches plus more

Featured swatches with a +N more panel grid grouped by family. A pick from the long tail is promoted into view.

componentswatchbeautyapparelvariants
$npx shadcn@latest add @storekit/pdp-swatch-more
pdp-swatch-more

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

Source

components/storekit/pdp/pdp-swatch-more.tsx
257 lines
"use client"

import { useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { X } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Featured swatches plus "+N more". The first few shades stay on the page;
   the long tail opens in a panel grid, grouped by family, directly under
   the row (in flow, so no parent overflow can clip it). Picking a shade
   from the tail promotes it into the visible row so the choice stays in view.
   Escape closes, Tab is trapped inside the panel, focus returns to the
   trigger. `color` is any CSS color string carried on inline style. */

type Shade = { id: string; name: string; color: string; family?: string; soldOut?: boolean }

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 EASE = [0.16, 1, 0.3, 1] as const

const DEFAULT_SHADES: Shade[] = [
  ["Porcelain", "248 230 216", "Fair"], ["Ivory", "242 222 200", "Fair"], ["Shell", "238 214 192", "Fair"], ["Linen", "232 206 180", "Fair"],
  ["Sand", "226 196 166", "Light"], ["Beige", "218 186 152", "Light"], ["Wheat", "212 178 140", "Light"], ["Buff", "204 168 130", "Light"],
  ["Honey", "196 154 112", "Medium"], ["Caramel", "184 140 98", "Medium"], ["Amber", "172 126 86", "Medium"], ["Toffee", "160 114 78", "Medium"],
  ["Chestnut", "138 96 66", "Deep"], ["Cocoa", "118 80 56", "Deep"], ["Mocha", "98 66 46", "Deep"], ["Espresso", "76 50 36", "Deep"],
  ["Ebony", "58 38 28", "Deep"], ["Onyx", "44 30 24", "Deep"],
].map(([name, rgb, family], i) => ({ id: name.toLowerCase(), name, color: `rgb(${rgb})`, family, soldOut: i === 10 }))

function Dot({ s, selected, onPick, size = "md", refCb, tabIndex, onKeyDown, layoutGroup }: {
  s: Shade
  selected: boolean
  onPick: () => void
  size?: "md" | "sm"
  refCb?: (el: HTMLButtonElement | null) => void
  tabIndex?: number
  onKeyDown?: (e: React.KeyboardEvent) => void
  layoutGroup?: string
}) {
  const reduce = useReducedMotion()
  return (
    <button
      ref={refCb}
      type="button"
      role="radio"
      aria-checked={selected}
      aria-label={s.soldOut ? `${s.name}, sold out` : s.name}
      title={s.name}
      tabIndex={tabIndex}
      onClick={onPick}
      onKeyDown={onKeyDown}
      className={cn("group/sw relative grid h-11 w-11 shrink-0 place-items-center rounded-sk-chip transition-transform duration-200 ease-sk-out active:scale-[0.97]", RING)}
    >
      {selected ? (
        layoutGroup ? (
          <motion.span layoutId={layoutGroup} aria-hidden transition={{ duration: reduce ? 0 : 0.35, ease: EASE }} className="absolute inset-0 rounded-sk-chip border-2 border-sk-ink" />
        ) : (
          <span aria-hidden className="absolute inset-0 rounded-sk-chip border-2 border-sk-ink" />
        )
      ) : (
        <span aria-hidden className="absolute inset-0 rounded-sk-chip border border-transparent transition-colors duration-200 group-hover/sw:border-sk-line" />
      )}
      <span aria-hidden className={cn("relative overflow-hidden rounded-sk-chip ring-1 ring-inset ring-sk-ink/15", size === "md" ? "h-8 w-8" : "h-7 w-7")} style={{ background: s.color }}>
        {s.soldOut ? <span className="absolute left-1/2 top-1/2 h-[1.5px] w-[140%] -translate-x-1/2 -translate-y-1/2 -rotate-45 bg-sk-paper shadow-[0_0_0_0.5px_rgb(var(--sk-ink)/0.5)]" /> : null}
      </span>
    </button>
  )
}

export function PdpSwatchMore({
  shades = DEFAULT_SHADES,
  featured = 6,
  label = "Shade",
  defaultValue,
  onChange,
  className,
}: {
  shades?: Shade[]
  /** How many shades stay visible before the "+N more" trigger. */
  featured?: number
  label?: string
  defaultValue?: string
  onChange?: (id: string) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const uid = useId()
  const sk = useSkBlock("pdp", "pdp-swatch-more")
  const [value, setValue] = useState(defaultValue ?? shades.find((s) => !s.soldOut)?.id ?? shades[0]?.id)
  const [open, setOpen] = useState(false)
  const triggerRef = useRef<HTMLButtonElement>(null)
  const panelRef = useRef<HTMLDivElement>(null)
  const rowRefs = useRef<(HTMLButtonElement | null)[]>([])

  const selected = shades.find((s) => s.id === value) ?? shades[0]
  const head = shades.slice(0, featured)
  const visible = head.some((s) => s.id === value) || !selected ? head : [...head.slice(0, featured - 1), selected]
  const rest = shades.length - visible.length
  const vi = Math.max(0, visible.findIndex((s) => s.id === value))

  const pick = (id: string) => {
    if (id !== value) sk.emit("interact", "swatch", undefined)
    setValue(id)
    onChange?.(id)
  }

  const close = () => {
    setOpen(false)
    triggerRef.current?.focus()
  }

  useEffect(() => {
    if (!open) return
    const panel = panelRef.current
    const first = panel?.querySelector<HTMLElement>('[aria-checked="true"]') ?? panel?.querySelector<HTMLElement>("button")
    first?.focus()
    const onDown = (e: MouseEvent) => {
      if (panel && !panel.contains(e.target as Node) && !triggerRef.current?.contains(e.target as Node)) setOpen(false)
    }
    document.addEventListener("mousedown", onDown)
    return () => document.removeEventListener("mousedown", onDown)
  }, [open])

  const onPanelKeys = (e: React.KeyboardEvent) => {
    if (e.key === "Escape") {
      e.preventDefault()
      return close()
    }
    if (e.key !== "Tab" || !panelRef.current) return
    const items = Array.from(panelRef.current.querySelectorAll<HTMLElement>("button"))
    const i = items.indexOf(document.activeElement as HTMLElement)
    if (e.shiftKey && i <= 0) {
      e.preventDefault()
      items[items.length - 1]?.focus()
    } else if (!e.shiftKey && i === items.length - 1) {
      e.preventDefault()
      items[0]?.focus()
    }
  }

  const onRowKeys = (e: React.KeyboardEvent) => {
    const n = visible.length
    let next = -1
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (vi + 1) % n
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (vi - 1 + n) % n
    if (next < 0) return
    e.preventDefault()
    pick(visible[next].id)
    rowRefs.current[next]?.focus()
  }

  const families = Array.from(new Set(shades.map((s) => s.family ?? ""))).map((f) => ({ family: f, items: shades.filter((s) => (s.family ?? "") === f) }))

  return (
    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("relative flex w-full flex-col gap-3", className)}>
      <div className="flex items-baseline gap-2 text-sm">
        <span id={`${uid}-label`} className="font-medium text-sk-ink">
          {label}
        </span>
        <span className="truncate text-sk-muted" aria-live="polite">
          {selected?.name}
          {selected?.soldOut ? ", sold out" : ""}
        </span>
        <span className="ml-auto shrink-0 font-sk-mono text-[11px] tabular-nums text-sk-muted">{shades.length} shades</span>
      </div>
      <div className="flex flex-wrap items-center gap-1.5">
        <div role="radiogroup" aria-labelledby={`${uid}-label`} className="flex flex-wrap gap-1.5">
          {visible.map((s, i) => (
            <Dot
              key={s.id}
              s={s}
              selected={s.id === value}
              onPick={() => pick(s.id)}
              tabIndex={i === vi ? 0 : -1}
              onKeyDown={onRowKeys}
              refCb={(el) => {
                rowRefs.current[i] = el
              }}
              layoutGroup={`${uid}-ring`}
            />
          ))}
        </div>
        {rest > 0 ? (
          <button
            ref={triggerRef}
            type="button"
            aria-haspopup="dialog"
            aria-expanded={open}
            aria-controls={`${uid}-panel`}
            onClick={() => {
              if (!open) sk.emit("interact", "more_open", undefined)
              setOpen((o) => !o)
            }}
            className={cn(
              "h-11 rounded-sk-chip border border-sk-line px-3.5 text-[13px] font-medium tabular-nums text-sk-ink transition-[border-color,background-color,transform] duration-200 ease-sk-out hover:border-sk-ink/40 active:scale-[0.97]",
              open && "border-sk-ink/40 bg-sk-ink/[0.04]",
              RING
            )}
          >
            +{rest} more
          </button>
        ) : null}
      </div>

      <AnimatePresence initial={false}>
        {open ? (
          <motion.div
            ref={panelRef}
            id={`${uid}-panel`}
            role="dialog"
            aria-modal="false"
            aria-label={`All ${label.toLowerCase()}s`}
            onKeyDown={onPanelKeys}
            initial={{ opacity: 0, y: reduce ? 0 : 8, scale: reduce ? 1 : 0.98 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: reduce ? 0 : 4, scale: reduce ? 1 : 0.98 }}
            transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}
            className="origin-top rounded-sk-card border border-sk-line bg-sk-card p-4 shadow-[0_24px_60px_-24px_rgb(var(--sk-ink)/0.25)] sm:p-5"
          >
            <div className="mb-3 flex items-center justify-between gap-3">
              <span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">All {shades.length} shades</span>
              <button
                type="button"
                aria-label="Close"
                onClick={close}
                className={cn("-mr-2 grid h-11 w-11 place-items-center rounded-sk-btn text-sk-muted transition-[color,transform] duration-200 hover:text-sk-ink active:scale-[0.97]", RING)}
              >
                <X size={16} />
              </button>
            </div>
            <div className="flex max-h-[260px] flex-col gap-3 overflow-y-auto">
              {families.map((g) => (
                <div key={g.family} role="radiogroup" aria-label={g.family || label} className="flex flex-col gap-1.5">
                  {g.family ? <span className="text-[13px] text-sk-muted">{g.family}</span> : null}
                  <div className="flex flex-wrap gap-1">
                    {g.items.map((s) => (
                      <Dot
                        key={s.id}
                        s={s}
                        size="sm"
                        selected={s.id === value}
                        onPick={() => {
                          pick(s.id)
                          close()
                        }}
                      />
                    ))}
                  </div>
                </div>
              ))}
            </div>
          </motion.div>
        ) : null}
      </AnimatePresence>
    </div>
  )
}

Usage

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

Swatches plus more, StoreKit