{
  "name": "stack-panel",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Stack Panel\n * @version: 1.0.0\n * @date: 2026-10-16\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { Check, ChevronLeft, ChevronRight } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  MotionConfig,\n  motion,\n  type PanInfo,\n  useAnimate,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type ReactNode,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { flushSync } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface StackRow {\n  id: string;\n  label: string;\n  /** Grey text on the right, like the current value of a setting. */\n  detail?: string;\n  /** Id of the page this row opens. */\n  to?: string;\n  /** Anything else on the right: a switch, a badge. */\n  trailing?: ReactNode;\n  onSelect?: () => void;\n  /** Shows a check, for rows that pick one option of a section. */\n  selected?: boolean;\n  /** Shows a switch in this state, for rows that turn something on. */\n  checked?: boolean;\n  tone?: \"default\" | \"danger\";\n}\n\nexport interface StackSection {\n  title?: string;\n  rows: StackRow[];\n  /** Small print under the section. */\n  footer?: string;\n}\n\nexport interface StackPage {\n  title: string;\n  sections: StackSection[];\n}\n\nexport interface StackPanelProps {\n  pages: Record<string, StackPage>;\n  /** The page the panel starts on. */\n  root: string;\n  className?: string;\n}\n\n// iOS pushes with a curve close to this; the page settles without bounce.\nconst PUSH = { type: \"spring\", duration: 0.42, bounce: 0 } as const;\nconst HEIGHT = { type: \"spring\", duration: 0.38, bounce: 0 } as const;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// The page underneath moves a third as far as the one on top.\nconst PARALLAX = 0.3;\n// Let go past this share of the width, or flick faster, and the page pops.\nconst POP_DISTANCE = 0.35;\nconst POP_VELOCITY = 500;\n\ninterface Origin {\n  /** The tapped row's label, relative to the panel. */\n  left: number;\n  top: number;\n  height: number;\n  fontSize: number;\n}\n\n/**\n * The header title. Pushed in from a row, it flies from that row's label\n * up into the header; otherwise it slides a little and fades.\n */\nfunction Title({\n  text,\n  origin,\n  direction,\n  panel,\n}: {\n  text: string;\n  origin: Origin | null;\n  direction: 1 | -1;\n  panel: HTMLElement | null;\n}) {\n  const [scope, run] = useAnimate<HTMLSpanElement>();\n  const reduced = useReducedMotion();\n\n  useLayoutEffect(() => {\n    const el = scope.current;\n    if (!(el && panel)) {\n      return;\n    }\n    if (!origin || reduced) {\n      run(\n        el,\n        { opacity: [0, 1], x: [direction * 24, 0] },\n        { duration: reduced ? 0 : 0.28, ease: EASE_OUT }\n      );\n      return;\n    }\n    const box = panel.getBoundingClientRect();\n    const own = el.getBoundingClientRect();\n    const fontSize = Number.parseFloat(getComputedStyle(el).fontSize);\n    const scale = origin.fontSize / fontSize;\n    const dx = origin.left - (own.left - box.left);\n    const dy =\n      origin.top + origin.height / 2 - (own.top - box.top + own.height / 2);\n    // Placed at the row before the first frame, so it never flashes in the\n    // header first.\n    el.style.transform = `translateX(${dx}px) translateY(${dy}px) scale(${scale})`;\n    run(\n      el,\n      { x: [dx, 0], y: [dy, 0], scale: [scale, 1], opacity: [1, 1] },\n      PUSH\n    );\n  }, [scope, run, origin, direction, panel, reduced]);\n\n  return (\n    <motion.span\n      className=\"absolute inset-x-0 top-1/2 mx-auto w-fit max-w-[55%] -translate-y-1/2 truncate font-semibold text-[15px] text-zinc-900 dark:text-zinc-100\"\n      exit={{\n        opacity: 0,\n        x: direction * -40,\n        transition: { duration: 0.18, ease: EASE_OUT },\n      }}\n      ref={scope}\n      style={{ transformOrigin: \"left center\" }}\n    >\n      {text}\n    </motion.span>\n  );\n}\n\nfunction Page({\n  page,\n  onOpen,\n  measureRef,\n}: {\n  page: StackPage;\n  onOpen: (row: StackRow, label: HTMLElement) => void;\n  measureRef?: (el: HTMLDivElement | null) => void;\n}) {\n  return (\n    <div className=\"flex flex-col gap-5 px-3 pt-2 pb-4\" ref={measureRef}>\n      {page.sections.map((section, index) => (\n        // biome-ignore lint/suspicious/noArrayIndexKey: sections are a fixed, ordered list\n        <section key={index}>\n          {section.title && (\n            <h3 className=\"mb-1.5 px-3 font-medium text-xs text-zinc-500 dark:text-zinc-400\">\n              {section.title}\n            </h3>\n          )}\n          <ul className=\"divide-y divide-zinc-100 overflow-hidden rounded-xl bg-zinc-50 dark:divide-zinc-800/70 dark:bg-zinc-900\">\n            {section.rows.map((row) => (\n              <li key={row.id}>\n                <RowView onOpen={onOpen} row={row} />\n              </li>\n            ))}\n          </ul>\n          {section.footer && (\n            <p className=\"mt-1.5 px-3 text-xs text-zinc-400 leading-5 dark:text-zinc-500\">\n              {section.footer}\n            </p>\n          )}\n        </section>\n      ))}\n    </div>\n  );\n}\n\nfunction RowView({\n  row,\n  onOpen,\n}: {\n  row: StackRow;\n  onOpen: (row: StackRow, label: HTMLElement) => void;\n}) {\n  const labelRef = useRef<HTMLSpanElement>(null);\n  const interactive = Boolean(row.to || row.onSelect);\n  const content = (\n    <>\n      <span\n        className={cn(\n          \"min-w-0 flex-1 truncate text-left text-[15px]\",\n          row.tone === \"danger\"\n            ? \"text-red-600 dark:text-red-400\"\n            : \"text-zinc-900 dark:text-zinc-100\"\n        )}\n        ref={labelRef}\n      >\n        {row.label}\n      </span>\n      {row.detail && (\n        <span className=\"max-w-[45%] shrink-0 truncate text-sm text-zinc-500 dark:text-zinc-400\">\n          {row.detail}\n        </span>\n      )}\n      {row.trailing}\n      {row.checked !== undefined && <Toggle on={row.checked} />}\n      {row.selected !== undefined && (\n        <Check\n          aria-hidden=\"true\"\n          className={cn(\n            \"size-4 shrink-0 text-zinc-900 dark:text-zinc-100\",\n            !row.selected && \"invisible\"\n          )}\n        />\n      )}\n      {row.to && (\n        <ChevronRight\n          aria-hidden=\"true\"\n          className=\"-mr-1 size-4 shrink-0 text-zinc-400 dark:text-zinc-500\"\n        />\n      )}\n    </>\n  );\n  const base = \"flex min-h-11 w-full items-center gap-2 px-3 py-2\";\n  if (!interactive) {\n    return <div className={base}>{content}</div>;\n  }\n  const className = cn(\n    base,\n    \"outline-none transition-colors duration-150 hover:bg-zinc-100 focus-visible:bg-zinc-100 active:bg-zinc-100 dark:active:bg-zinc-800 dark:focus-visible:bg-zinc-800 dark:hover:bg-zinc-800/60\"\n  );\n  const onClick = () => {\n    if (labelRef.current) {\n      onOpen(row, labelRef.current);\n    }\n  };\n  if (row.checked !== undefined) {\n    return (\n      <button\n        aria-checked={row.checked}\n        className={className}\n        onClick={onClick}\n        role=\"switch\"\n        type=\"button\"\n      >\n        {content}\n      </button>\n    );\n  }\n  return (\n    <button\n      aria-pressed={row.selected}\n      className={className}\n      onClick={onClick}\n      type=\"button\"\n    >\n      {content}\n    </button>\n  );\n}\n\n/**\n * A panel that drills into pages, like settings on a phone. A row opens its\n * page from the right while its label flies up to become the title; swipe\n * the page right, or press Back or Escape, to return.\n */\nexport function StackPanel({ pages, root, className }: StackPanelProps) {\n  const [stack, setStack] = useState<string[]>([root]);\n  const [origin, setOrigin] = useState<Origin | null>(null);\n  const [direction, setDirection] = useState<1 | -1>(1);\n  // \"auto\" until the first page is measured, so the server render has a\n  // height too.\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  const [busy, setBusy] = useState(false);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const bodyRef = useRef<HTMLDivElement>(null);\n  const backRef = useRef<HTMLButtonElement>(null);\n  const observer = useRef<ResizeObserver | null>(null);\n  // The top page's offset; the page underneath is derived from it, so a\n  // push, a pop and a swipe all move both pages together.\n  const x = useMotionValue(0);\n  const [width, setWidth] = useState(360);\n  const underX = useTransform(x, (v) => -PARALLAX * (width - v));\n  const shade = useTransform(x, [0, width], [0.06, 0]);\n\n  const top = stack.at(-1) ?? root;\n  const under = stack.length > 1 ? stack.at(-2) : undefined;\n  const parent = under ? pages[under] : undefined;\n\n  useEffect(() => {\n    const body = bodyRef.current;\n    if (!body) {\n      return;\n    }\n    const watch = new ResizeObserver(() => setWidth(body.clientWidth));\n    watch.observe(body);\n    return () => {\n      watch.disconnect();\n      observer.current?.disconnect();\n    };\n  }, []);\n\n  // The panel's height follows the page on top.\n  const measure = (el: HTMLDivElement | null) => {\n    observer.current?.disconnect();\n    if (!el) {\n      return;\n    }\n    observer.current = new ResizeObserver(() => setHeight(el.offsetHeight));\n    observer.current.observe(el);\n  };\n\n  const push = (row: StackRow, label: HTMLElement) => {\n    row.onSelect?.();\n    if (!(row.to && pages[row.to]) || busy) {\n      return;\n    }\n    const box = panelRef.current?.getBoundingClientRect();\n    const rect = label.getBoundingClientRect();\n    setOrigin(\n      box\n        ? {\n            left: rect.left - box.left,\n            top: rect.top - box.top,\n            height: rect.height,\n            fontSize: Number.parseFloat(getComputedStyle(label).fontSize),\n          }\n        : null\n    );\n    setDirection(1);\n    setBusy(true);\n    x.set(width);\n    const to = row.to;\n    flushSync(() => setStack((s) => [...s, to]));\n    animate(x, 0, PUSH).then(() => setBusy(false));\n    backRef.current?.focus({ preventScroll: true });\n  };\n\n  const pop = () => {\n    if (stack.length < 2) {\n      return;\n    }\n    setOrigin(null);\n    setDirection(-1);\n    setBusy(true);\n    animate(x, width, { ...PUSH, duration: 0.34 }).then(() => {\n      // Both in one frame: the old top unmounts as the offset resets.\n      flushSync(() => setStack((s) => s.slice(0, -1)));\n      x.set(0);\n      setBusy(false);\n    });\n  };\n\n  const onDragEnd = (_: unknown, info: PanInfo) => {\n    if (x.get() > width * POP_DISTANCE || info.velocity.x > POP_VELOCITY) {\n      pop();\n    } else {\n      animate(x, 0, PUSH);\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\" && stack.length > 1) {\n      event.preventDefault();\n      pop();\n      backRef.current?.focus({ preventScroll: true });\n    }\n  };\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/noNoninteractiveElementInteractions: Escape is a shortcut for the Back button */}\n      <div\n        className={cn(\n          \"relative w-full max-w-sm overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950\",\n          className\n        )}\n        onKeyDown={onKeyDown}\n        ref={panelRef}\n      >\n        <div className=\"relative z-10 flex h-12 items-center border-zinc-100 border-b bg-white px-2 dark:border-zinc-900 dark:bg-zinc-950\">\n          <AnimatePresence initial={false}>\n            {parent && (\n              <motion.button\n                animate={{ opacity: 1 }}\n                className=\"relative z-10 flex max-w-[30%] items-center rounded-lg py-1.5 pr-2 pl-1 text-sm text-zinc-600 outline-none transition-colors duration-150 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100\"\n                exit={{ opacity: 0 }}\n                initial={{ opacity: 0 }}\n                key=\"back\"\n                onClick={pop}\n                ref={backRef}\n                transition={{ duration: 0.18 }}\n                type=\"button\"\n              >\n                <ChevronLeft aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n                <span className=\"truncate\">{parent.title}</span>\n              </motion.button>\n            )}\n          </AnimatePresence>\n          <AnimatePresence initial={false}>\n            <Title\n              direction={direction}\n              key={`${top}-${stack.length}`}\n              origin={origin}\n              panel={panelRef.current}\n              text={pages[top]?.title ?? \"\"}\n            />\n          </AnimatePresence>\n        </div>\n\n        <motion.div\n          animate={{ height }}\n          className=\"relative\"\n          ref={bodyRef}\n          transition={HEIGHT}\n        >\n          {under && pages[under] && (\n            <motion.div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-x-0 top-0\"\n              inert\n              key={`${under}-${stack.length - 1}`}\n              style={{ x: underX }}\n            >\n              <Page onOpen={() => undefined} page={pages[under]} />\n              <motion.div\n                className=\"absolute inset-0 bg-black\"\n                style={{ opacity: shade }}\n              />\n            </motion.div>\n          )}\n          {pages[top] && (\n            <motion.div\n              className=\"relative bg-white dark:bg-zinc-950\"\n              drag={stack.length > 1 && !busy ? \"x\" : false}\n              dragConstraints={{ left: 0, right: 0 }}\n              dragDirectionLock\n              dragElastic={{ left: 0, right: 1 }}\n              dragMomentum={false}\n              key={`${top}-${stack.length}`}\n              onDragEnd={onDragEnd}\n              style={{\n                x,\n                boxShadow:\n                  stack.length > 1 ? \"-8px 0 24px -12px rgb(0 0 0 / 0.18)\" : \"\",\n              }}\n            >\n              <Page measureRef={measure} onOpen={push} page={pages[top]} />\n            </motion.div>\n          )}\n        </motion.div>\n      </div>\n    </MotionConfig>\n  );\n}\n\nfunction Toggle({ on }: { on: boolean }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"relative h-[22px] w-9 shrink-0 rounded-full transition-colors duration-200\",\n        on ? \"bg-zinc-900 dark:bg-zinc-100\" : \"bg-zinc-200 dark:bg-zinc-700\"\n      )}\n    >\n      <span\n        className={cn(\n          \"absolute top-0.5 left-0.5 size-[18px] rounded-full bg-white shadow-sm transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] dark:bg-zinc-950\",\n          on && \"translate-x-3.5\"\n        )}\n      />\n    </span>\n  );\n}\n\n/** Workspace settings, three levels deep. */\nexport default function StackPanelDemo({ className }: { className?: string }) {\n  const [theme, setTheme] = useState(\"System\");\n  const [density, setDensity] = useState(\"Comfortable\");\n  const [alerts, setAlerts] = useState({\n    mentions: true,\n    digest: false,\n    deploys: true,\n  });\n  const [quiet, setQuiet] = useState(\"22:00 – 07:00\");\n\n  const flip = (key: keyof typeof alerts) => () =>\n    setAlerts((a) => ({ ...a, [key]: !a[key] }));\n  const toggleRow = (id: keyof typeof alerts, label: string): StackRow => ({\n    id,\n    label,\n    onSelect: flip(id),\n    checked: alerts[id],\n  });\n  const choice = (\n    value: string,\n    current: string,\n    set: (v: string) => void\n  ): StackRow => ({\n    id: value,\n    label: value,\n    selected: value === current,\n    onSelect: () => set(value),\n  });\n\n  const pages: Record<string, StackPage> = {\n    settings: {\n      title: \"Settings\",\n      sections: [\n        {\n          rows: [\n            {\n              id: \"account\",\n              label: \"Account\",\n              detail: \"Maya Chen\",\n              to: \"account\",\n            },\n            {\n              id: \"appearance\",\n              label: \"Appearance\",\n              detail: theme,\n              to: \"appearance\",\n            },\n            {\n              id: \"notifications\",\n              label: \"Notifications\",\n              to: \"notifications\",\n            },\n          ],\n        },\n        {\n          rows: [\n            { id: \"billing\", label: \"Billing\", detail: \"Pro\", to: \"billing\" },\n            { id: \"shortcuts\", label: \"Keyboard shortcuts\", detail: \"34\" },\n          ],\n        },\n      ],\n    },\n    account: {\n      title: \"Account\",\n      sections: [\n        {\n          rows: [\n            { id: \"name\", label: \"Name\", detail: \"Maya Chen\" },\n            { id: \"email\", label: \"Email\", detail: \"maya@acme.com\" },\n            { id: \"security\", label: \"Password & security\", to: \"security\" },\n          ],\n        },\n        {\n          rows: [\n            {\n              id: \"sign-out\",\n              label: \"Sign out\",\n              tone: \"danger\",\n              onSelect: () => undefined,\n            },\n          ],\n        },\n      ],\n    },\n    security: {\n      title: \"Password & security\",\n      sections: [\n        {\n          rows: [\n            {\n              id: \"password\",\n              label: \"Password\",\n              detail: \"Changed 3 months ago\",\n            },\n            { id: \"2fa\", label: \"Two-factor\", detail: \"Authenticator app\" },\n            { id: \"sessions\", label: \"Active sessions\", detail: \"3\" },\n          ],\n          footer: \"Signing out of a session takes effect on its next request.\",\n        },\n      ],\n    },\n    appearance: {\n      title: \"Appearance\",\n      sections: [\n        {\n          title: \"Theme\",\n          rows: [\"Light\", \"Dark\", \"System\"].map((v) =>\n            choice(v, theme, setTheme)\n          ),\n        },\n        {\n          title: \"Density\",\n          rows: [\"Comfortable\", \"Compact\"].map((v) =>\n            choice(v, density, setDensity)\n          ),\n        },\n      ],\n    },\n    notifications: {\n      title: \"Notifications\",\n      sections: [\n        {\n          rows: [\n            toggleRow(\"mentions\", \"Mentions\"),\n            toggleRow(\"deploys\", \"Failed deploys\"),\n            toggleRow(\"digest\", \"Weekly digest\"),\n          ],\n        },\n        {\n          rows: [\n            { id: \"quiet\", label: \"Quiet hours\", detail: quiet, to: \"quiet\" },\n          ],\n          footer: \"Nothing but failed deploys gets through in quiet hours.\",\n        },\n      ],\n    },\n    quiet: {\n      title: \"Quiet hours\",\n      sections: [\n        {\n          rows: [\"Off\", \"22:00 – 07:00\", \"20:00 – 09:00\", \"Weekends\"].map((v) =>\n            choice(v, quiet, setQuiet)\n          ),\n        },\n      ],\n    },\n    billing: {\n      title: \"Billing\",\n      sections: [\n        {\n          rows: [\n            { id: \"plan\", label: \"Plan\", detail: \"Pro, yearly\" },\n            { id: \"next\", label: \"Next invoice\", detail: \"Nov 1 · $96\" },\n            { id: \"card\", label: \"Card\", detail: \"Visa ·· 4242\" },\n          ],\n        },\n      ],\n    },\n  };\n\n  return <StackPanel className={className} pages={pages} root=\"settings\" />;\n}\n",
      "path": "/components/kokonutui/stack-panel.tsx",
      "target": "components/kokonutui/stack-panel.tsx"
    }
  ]
}