{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"focus-stack","type":"registry:component","title":"Focus Stack","description":"Overlapping items where the selection comes forward — context stays behind.","dependencies":[],"categories":["media","stack","queue","keyboard","cards"],"files":[{"path":"components/lab/focus-stack/focus-stack.tsx","target":"components/lab/focus-stack/focus-stack.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport \"./focus-stack.css\";\n\nexport type StackItem = {\n  id: string;\n  title: string;\n  meta?: string;\n  content: ReactNode;\n};\n\nexport type FocusStackProps = {\n  items: StackItem[];\n  /** Controlled selection; omit to let the stack select itself. */\n  index?: number;\n  onIndexChange?: (index: number) => void;\n  /** Initial selection in uncontrolled use. */\n  defaultIndex?: number;\n  /** Depth offset in pixels between stacked items. */\n  spread?: number;\n  /** A slight fan rotation for physical stacks. */\n  rotate?: boolean;\n  className?: string;\n};\n\nconst SCALE_STEP = 0.045;\nconst MAX_ROTATION = 1.2;\nconst MIN_OPACITY = 0.55;\n\n/**\n * FOCUS STACK — a queue that keeps its context visible.\n *\n * The active card owns the flow, so the container height is the card the\n * reader is looking at. The other cards sit behind it, fanned by\n * distance: items before the active one lift upward, items after it drop\n * downward, both scaling and fading with depth. Only the active panel is\n * rendered visibly — the rest are `hidden`, so assistive technology sees\n * exactly one tabpanel. Arrow keys, Home and End move selection; the tab\n * row is a single tab stop.\n */\nexport function FocusStack({\n  items,\n  index,\n  onIndexChange,\n  defaultIndex = 0,\n  spread = 34,\n  rotate = false,\n  className,\n}: FocusStackProps) {\n  const isControlled = index !== undefined;\n  const [internal, setInternal] = useState(defaultIndex);\n  const count = items.length;\n  const raw = isControlled ? index : internal;\n  const active = count === 0 ? 0 : Math.max(0, Math.min(raw, count - 1));\n\n  const uid = useId();\n  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);\n\n  /* Uncontrolled selection follows the items when the host slices them. */\n  if (!isControlled && count > 0 && internal > count - 1) {\n    setInternal(count - 1);\n  }\n\n  const select = (next: number) => {\n    if (count === 0) return;\n    const clamped = Math.max(0, Math.min(next, count - 1));\n    if (clamped === active) return;\n    if (!isControlled) setInternal(clamped);\n    onIndexChange?.(clamped);\n  };\n\n  const focusAndSelect = (next: number) => {\n    if (count === 0) return;\n    const wrapped = ((next % count) + count) % count;\n    select(wrapped);\n    tabRefs.current[wrapped]?.focus();\n  };\n\n  const onTablistKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    switch (event.key) {\n      case \"ArrowLeft\":\n      case \"ArrowUp\":\n        event.preventDefault();\n        focusAndSelect(active - 1);\n        break;\n      case \"ArrowRight\":\n      case \"ArrowDown\":\n        event.preventDefault();\n        focusAndSelect(active + 1);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        focusAndSelect(0);\n        break;\n      case \"End\":\n        event.preventDefault();\n        focusAndSelect(count - 1);\n        break;\n      default:\n        break;\n    }\n  };\n\n  if (count === 0) return null;\n\n  return (\n    <div\n      className={`fs-root${className ? ` ${className}` : \"\"}`}\n      data-stacked={count > 1 ? \"true\" : \"false\"}\n      style={{ \"--fs-spread\": `${spread}px` } as CSSProperties}\n    >\n      <div\n        className=\"fs-tabs\"\n        role=\"tablist\"\n        aria-orientation=\"horizontal\"\n        aria-label=\"Stack items\"\n        onKeyDown={onTablistKeyDown}\n      >\n        {items.map((item, i) => (\n          <button\n            key={item.id}\n            ref={(node) => {\n              tabRefs.current[i] = node;\n            }}\n            type=\"button\"\n            role=\"tab\"\n            id={`${uid}-tab-${i}`}\n            className=\"fs-tab\"\n            aria-selected={i === active}\n            aria-controls={`${uid}-panel-${i}`}\n            tabIndex={i === active ? 0 : -1}\n            onClick={() => select(i)}\n          >\n            <span className=\"fs-tab-title\">{item.title}</span>\n            {item.meta ? <span className=\"fs-tab-meta\">{item.meta}</span> : null}\n          </button>\n        ))}\n      </div>\n\n      <div className=\"fs-pile\">\n        {items.map((item, i) => {\n          const depth = i - active;\n          const isActive = depth === 0;\n          const distance = Math.abs(depth);\n          const scale = 1 - SCALE_STEP * distance;\n          const backStyle: CSSProperties = {\n            transform: `translateY(${spread * depth}px) scale(${scale}) rotate(${\n              rotate ? (depth < 0 ? -MAX_ROTATION : MAX_ROTATION) : 0\n            }deg)`,\n            opacity: Math.max(MIN_OPACITY, 1 - 0.1 * distance),\n            zIndex: count + 1 - distance,\n          };\n          return (\n            <div\n              key={item.id}\n              className={`fs-card${isActive ? \" fs-card--active\" : \"\"}`}\n              style={isActive ? { zIndex: count + 2 } : backStyle}\n              aria-hidden={isActive ? undefined : true}\n              inert={!isActive}\n            >\n              {!isActive ? (\n                <div\n                  className={`fs-peek ${\n                    depth > 0 ? \"fs-peek--bottom\" : \"fs-peek--top\"\n                  }`}\n                >\n                  <span className=\"fs-peek-title\">{item.title}</span>\n                  {item.meta ? (\n                    <span className=\"fs-peek-meta\">{item.meta}</span>\n                  ) : null}\n                </div>\n              ) : null}\n              <div\n                role=\"tabpanel\"\n                id={`${uid}-panel-${i}`}\n                aria-labelledby={`${uid}-tab-${i}`}\n                className=\"fs-panel\"\n                tabIndex={0}\n                hidden={!isActive}\n              >\n                {item.content}\n              </div>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/lab/focus-stack/focus-stack.css","target":"components/lab/focus-stack/focus-stack.css","type":"registry:file","content":"/* ============================================================\n   FOCUS STACK — self-contained styles.\n   Prefix .fs-  ·  tokens are scoped on .fs-root with fallbacks.\n   ============================================================ */\n\n.fs-root {\n  --fs-ink: #141418;\n  --fs-muted: #5f5f68;\n  --fs-faint: #8c8c94;\n  --fs-line: rgba(20, 20, 24, 0.12);\n  --fs-line-soft: rgba(20, 20, 24, 0.07);\n  --fs-surface: #ffffff;\n  --fs-sunken: #f4f4f2;\n  --fs-accent: #4f27e0;\n  --fs-accent-soft: rgba(79, 39, 224, 0.09);\n  --fs-ease: cubic-bezier(0.22, 1, 0.36, 1);\n  --fs-duration: 320ms;\n  --fs-edge: var(--fs-spread, 34px);\n\n  display: block;\n  color: var(--fs-ink, #141418);\n  font-size: 0.8125rem;\n  line-height: 1.5;\n}\n\n/*\n * When several cards are fanned, the pile reserves breathing room for\n * the nearest neighbours; the rest may travel beyond it.\n */\n.fs-root[data-stacked=\"true\"] {\n  --fs-edge: calc(var(--fs-spread, 34px) + 8px);\n}\n\n/* ---------- tablist: title + meta, one tab stop ---------- */\n\n.fs-tabs {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n  padding: 0;\n  margin: 0 0 14px;\n}\n\n.fs-tab {\n  display: flex;\n  flex-direction: column;\n  align-items: flex-start;\n  gap: 1px;\n  max-width: 100%;\n  padding: 6px 10px;\n  border: 1px solid var(--fs-line-soft, rgba(20, 20, 24, 0.07));\n  border-radius: 9px;\n  background: var(--fs-surface, #ffffff);\n  color: var(--fs-muted, #5f5f68);\n  font: inherit;\n  text-align: left;\n  cursor: pointer;\n  transition:\n    background-color 150ms ease,\n    border-color 150ms ease;\n}\n\n.fs-tab-title {\n  font-size: 0.75rem;\n  font-weight: 500;\n  color: var(--fs-ink, #141418);\n  overflow-wrap: anywhere;\n}\n\n.fs-tab-meta {\n  font-family: ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, monospace;\n  font-size: 0.59375rem;\n  letter-spacing: 0.06em;\n  text-transform: uppercase;\n  color: var(--fs-faint, #8c8c94);\n  overflow-wrap: anywhere;\n}\n\n.fs-tab[aria-selected=\"true\"] {\n  border-color: rgba(79, 39, 224, 0.45);\n  background: var(--fs-accent-soft, rgba(79, 39, 224, 0.09));\n}\n\n.fs-tab[aria-selected=\"false\"]:hover {\n  background: var(--fs-sunken, #f4f4f2);\n}\n\n.fs-tab:focus-visible,\n.fs-panel:focus-visible {\n  outline: 2px solid var(--fs-accent, #4f27e0);\n  outline-offset: 2px;\n}\n\n/* ---------- the pile ---------- */\n\n.fs-pile {\n  position: relative;\n  padding: var(--fs-edge, 0px) 0;\n}\n\n.fs-card {\n  position: absolute;\n  inset: var(--fs-edge, 0px) 0;\n  background: var(--fs-surface, #ffffff);\n  border: 1px solid var(--fs-line, rgba(20, 20, 24, 0.12));\n  border-radius: 14px;\n  box-shadow: 0 1px 2px rgba(20, 20, 24, 0.05);\n  transition:\n    transform var(--fs-duration, 320ms) var(--fs-ease, cubic-bezier(0.22, 1, 0.36, 1)),\n    opacity var(--fs-duration, 320ms) var(--fs-ease, cubic-bezier(0.22, 1, 0.36, 1));\n}\n\n.fs-card--active {\n  position: relative;\n  inset: auto;\n}\n\n/* Cards behind the selection keep a legible edge: the strip shows the\n   item they hold, so the pile reads as records in a queue rather than\n   blank paper. A card shifted down is seen from below, a card shifted\n   up from above — the strip always sits on the exposed band, and its\n   height tracks the spread so the label is never covered. */\n.fs-peek {\n  position: absolute;\n  left: 0;\n  right: 0;\n  height: var(--fs-spread, 34px);\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  padding-inline: 16px;\n  overflow: hidden;\n  white-space: nowrap;\n}\n\n.fs-peek--top {\n  top: 0;\n  border-bottom: 1px solid var(--fs-line-soft, rgba(20, 20, 24, 0.06));\n}\n\n.fs-peek--bottom {\n  bottom: 0;\n  border-top: 1px solid var(--fs-line-soft, rgba(20, 20, 24, 0.06));\n}\n\n.fs-peek-title {\n  font-size: 0.8125rem;\n  font-weight: 600;\n  letter-spacing: -0.01em;\n  color: var(--fs-ink, #141418);\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.fs-peek-meta {\n  font-family: ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, monospace;\n  font-size: 0.5625rem;\n  letter-spacing: 0.09em;\n  text-transform: uppercase;\n  color: var(--fs-faint, #8c8c94);\n  white-space: nowrap;\n  flex: none;\n}\n\n.fs-panel {\n  padding: 14px 16px;\n}\n\n.fs-panel:focus-visible {\n  outline-offset: -2px;\n  border-radius: 12px;\n}\n\n@media (max-width: 30rem) {\n  .fs-panel {\n    padding: 12px 14px;\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .fs-card {\n    transition: none;\n  }\n}\n"}]}