{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "resizable-panels",
  "type": "registry:ui",
  "title": "Resizable Panels",
  "description": "Split panes that drag, resist at their limits and spring shut on double-click.",
  "categories": [
    "app-nav"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/resizable-panels.tsx",
      "content": "\"use client\";\nimport { animate, motion, motionValue, useReducedMotion, useTransform, type MotionValue } from \"motion/react\";\nimport { Children, createContext, isValidElement, useContext, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring } from \"@/lib/motion\";\n\n/* -------------------------------------------------------------------------------------------------\n * Types and context\n * -----------------------------------------------------------------------------------------------*/\n\ntype Direction = \"horizontal\" | \"vertical\";\n\ntype PanelDef = {\n  id: string;\n  min: number;\n  max: number;\n  collapsible: boolean;\n  collapsedSize: number;\n  collapsed?: boolean;\n  onCollapsedChange?: (collapsed: boolean) => void;\n};\n\ntype GroupContext = {\n  direction: Direction;\n  sizes: number[];\n  mvs: MotionValue<number>[];\n  defs: PanelDef[];\n  groupPx: number;\n  dragging: number | null;\n  startDrag: (handle: number, e: React.PointerEvent) => void;\n  nudge: (handle: number, delta: number) => void;\n  jumpTo: (handle: number, edge: \"min\" | \"max\") => void;\n  toggle: (handle: number, instant?: boolean, only?: number) => void;\n};\n\nconst Group = createContext<GroupContext | null>(null);\nconst PanelIndex = createContext(-1);\nconst HandleIndex = createContext(-1);\nconst useGroup = () => {\n  const ctx = useContext(Group);\n  if (!ctx) throw new Error(\"Panel and PanelHandle must be used inside PanelGroup\");\n  return ctx;\n};\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n// iOS-style resistance: the further past the edge, the less it gives, never more than `limit`.\nconst rubber = (over: number, limit: number) => (limit * over * 0.55) / (limit + over * 0.55);\nconst round = (v: number) => Math.round(v * 100) / 100;\n\n/* -------------------------------------------------------------------------------------------------\n * PanelGroup\n * -----------------------------------------------------------------------------------------------*/\n\nexport type PanelGroupProps = Omit<React.ComponentProps<\"div\">, \"dir\"> & {\n  direction?: Direction;\n  /** Called with every panel's size, in percent, whenever a resize settles. Persist it to restore the layout. */\n  onLayout?: (sizes: number[]) => void;\n};\n\n/**\n * Split panes. Sizes are percentages of the group, so the layout survives a window resize;\n * handles between panels drag, step with arrow keys, and double-click to collapse.\n */\nexport function PanelGroup({ direction = \"horizontal\", onLayout, className, children, ...rest }: PanelGroupProps) {\n  const reduce = !!useReducedMotion();\n  const uid = useId();\n  const kids = Children.toArray(children).filter(isValidElement);\n  const panelProps = kids.filter((k) => k.type === Panel).map((k) => k.props as PanelProps);\n\n  // Panels without a default share whatever the others leave.\n  const [initial] = useState(() => {\n    const given = panelProps.reduce((sum, p) => sum + (p.defaultSize ?? 0), 0);\n    const free = panelProps.filter((p) => p.defaultSize === undefined).length;\n    return panelProps.map((p) => (p.collapsed ? (p.collapsedSize ?? 0) : (p.defaultSize ?? (free ? Math.max(0, 100 - given) / free : 0))));\n  });\n  const [mvs] = useState(() => initial.map((s) => motionValue(s)));\n  const [sizes, setSizes] = useState(initial);\n  const [dragging, setDragging] = useState<number | null>(null);\n  const [groupPx, setGroupPx] = useState(0);\n  const root = useRef<HTMLDivElement>(null);\n  const restore = useRef<number[]>(initial.map((s) => s));\n  const last = useRef<number[]>(initial);\n  const pointer = useRef<{ handle: number; start: number; a: number; b: number; px: number; id: number; moved: boolean } | null>(null);\n\n  const defs: PanelDef[] = panelProps.map((p, i) => ({\n    id: p.id ?? `${uid}-p${i}`,\n    min: p.minSize ?? 10,\n    max: p.maxSize ?? 100,\n    collapsible: !!p.collapsible,\n    collapsedSize: p.collapsedSize ?? 0,\n    collapsed: p.collapsed,\n    onCollapsedChange: p.onCollapsedChange,\n  }));\n  const defsRef = useRef(defs);\n  const onLayoutRef = useRef(onLayout);\n  useEffect(() => {\n    defsRef.current = defs;\n    onLayoutRef.current = onLayout;\n  });\n\n  useEffect(() => {\n    const el = root.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => {\n      const handles = [...el.children].filter((c) => c.getAttribute(\"role\") === \"separator\");\n      const handlePx = handles.reduce((sum, h) => sum + (direction === \"horizontal\" ? (h as HTMLElement).offsetWidth : (h as HTMLElement).offsetHeight), 0);\n      setGroupPx((direction === \"horizontal\" ? el.clientWidth : el.clientHeight) - handlePx);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [direction]);\n\n  const isCollapsed = (i: number, v = mvs[i].get()) => defsRef.current[i].collapsible && v <= defsRef.current[i].collapsedSize + 0.01;\n\n  // Writes the settled sizes to React (for ARIA and data attributes) and tells the app.\n  const commit = () => {\n    const next = mvs.map((m) => round(m.get()));\n    const prev = last.current;\n    last.current = next;\n    setSizes(next);\n    next.forEach((v, i) => {\n      const was = isCollapsed(i, prev[i]);\n      const now = isCollapsed(i, v);\n      if (!now && v > 0) restore.current[i] = v;\n      if (was !== now) defsRef.current[i].onCollapsedChange?.(now);\n    });\n    if (next.some((v, i) => v !== prev[i])) onLayoutRef.current?.(next);\n  };\n\n  // The range the panel before handle `h` may take, given both neighbors' limits.\n  const range = (h: number, total: number) => {\n    const A = defsRef.current[h];\n    const B = defsRef.current[h + 1];\n    return { lo: Math.max(A.min, total - B.max), hi: Math.min(A.max, total - B.min) };\n  };\n\n  const set = (h: number, a: number, total: number, how: \"jump\" | \"spring\" | \"soft\") => {\n    const [ma, mb] = [mvs[h], mvs[h + 1]];\n    if (how === \"jump\" || reduce) {\n      ma.jump(a);\n      mb.jump(total - a);\n      return Promise.resolve();\n    }\n    const t = how === \"spring\" ? spring.snappy : spring.soft;\n    return Promise.all([animate(ma, a, t), animate(mb, total - a, t)]).then(() => undefined);\n  };\n\n  const startDrag = (h: number, e: React.PointerEvent) => {\n    if (e.button !== 0) return;\n    e.preventDefault();\n    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\n    const coord = direction === \"horizontal\" ? e.clientX : e.clientY;\n    pointer.current = { handle: h, start: coord, a: mvs[h].get(), b: mvs[h + 1].get(), px: Math.max(groupPx, 1), id: e.pointerId, moved: false };\n    setDragging(h);\n    document.documentElement.style.cursor = direction === \"horizontal\" ? \"col-resize\" : \"row-resize\";\n    document.documentElement.style.userSelect = \"none\";\n  };\n\n  useEffect(() => {\n    if (dragging === null) return;\n    let snapped: \"a\" | \"b\" | null = null;\n    const onMove = (e: PointerEvent) => {\n      const p = pointer.current;\n      if (!p || e.pointerId !== p.id) return;\n      const total = p.a + p.b;\n      const A = defsRef.current[p.handle];\n      const B = defsRef.current[p.handle + 1];\n      const deltaPx = (direction === \"horizontal\" ? e.clientX : e.clientY) - p.start;\n      // A press that hasn't traveled 3px is still a click (or half of a double-click), not a resize.\n      if (!p.moved && Math.abs(deltaPx) < 3) return;\n      p.moved = true;\n      const deltaPct = (deltaPx / p.px) * 100;\n      const raw = p.a + deltaPct;\n      const { lo, hi } = range(p.handle, total);\n      const edge = (24 / p.px) * 100;\n      let a = raw;\n      let snap: typeof snapped = null;\n      if (raw < lo) {\n        // Past the minimum: a collapsible panel snaps shut beyond half its minimum; anything else resists.\n        if (A.collapsible && raw < A.min / 2) snap = \"a\";\n        else a = lo - rubber(lo - raw, edge);\n      } else if (raw > hi) {\n        if (B.collapsible && total - raw < B.min / 2) snap = \"b\";\n        else a = hi + rubber(raw - hi, edge);\n      }\n      if (snap !== snapped) {\n        const target = snap === \"a\" ? A.collapsedSize : snap === \"b\" ? total - B.collapsedSize : clamp(raw, lo, hi);\n        snapped = snap;\n        void set(p.handle, target, total, \"spring\");\n        return;\n      }\n      if (snap) return;\n      set(p.handle, a, total, \"jump\");\n      document.documentElement.style.cursor =\n        a <= lo ? (direction === \"horizontal\" ? \"e-resize\" : \"s-resize\") : a >= hi ? (direction === \"horizontal\" ? \"w-resize\" : \"n-resize\") : direction === \"horizontal\" ? \"col-resize\" : \"row-resize\";\n    };\n    const onUp = (e: PointerEvent) => {\n      const p = pointer.current;\n      if (!p || e.pointerId !== p.id) return;\n      pointer.current = null;\n      setDragging(null);\n      document.documentElement.style.cursor = \"\";\n      document.documentElement.style.userSelect = \"\";\n      if (!p.moved) return;\n      const total = p.a + p.b;\n      const { lo, hi } = range(p.handle, total);\n      const a = mvs[p.handle].get();\n      // Anything left outside the limits by the rubber band springs back.\n      if (!snapped && (a < lo || a > hi)) void set(p.handle, clamp(a, lo, hi), total, \"soft\").then(commit);\n      else window.setTimeout(commit, snapped ? 260 : 0);\n    };\n    window.addEventListener(\"pointermove\", onMove);\n    window.addEventListener(\"pointerup\", onUp);\n    window.addEventListener(\"pointercancel\", onUp);\n    return () => {\n      window.removeEventListener(\"pointermove\", onMove);\n      window.removeEventListener(\"pointerup\", onUp);\n      window.removeEventListener(\"pointercancel\", onUp);\n      document.documentElement.style.cursor = \"\";\n      document.documentElement.style.userSelect = \"\";\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- handlers read live values through refs and motion values\n  }, [dragging, direction]);\n\n  const nudge = (h: number, delta: number) => {\n    const total = mvs[h].get() + mvs[h + 1].get();\n    const { lo, hi } = range(h, total);\n    const A = defsRef.current[h];\n    const current = mvs[h].get();\n    let a = clamp(current + delta, lo, hi);\n    // Stepping down out of a collapsed panel opens it to its minimum; stepping below the minimum collapses it.\n    if (A.collapsible && isCollapsed(h) && delta > 0) a = lo;\n    else if (A.collapsible && delta < 0 && current <= lo + 0.01) a = A.collapsedSize;\n    void set(h, a, total, \"jump\");\n    commit();\n  };\n\n  const jumpTo = (h: number, edge: \"min\" | \"max\") => {\n    const total = mvs[h].get() + mvs[h + 1].get();\n    const { lo, hi } = range(h, total);\n    void set(h, edge === \"min\" ? lo : hi, total, \"jump\");\n    commit();\n  };\n\n  // Double-click or Enter: collapse whichever neighbor can, or bring it back to where it was.\n  const toggle = (h: number, instant = false, only?: number) => {\n    const total = mvs[h].get() + mvs[h + 1].get();\n    const A = defsRef.current[h];\n    const B = defsRef.current[h + 1];\n    const target = only ?? (A.collapsible ? h : B.collapsible ? h + 1 : null);\n    if (target === null || !defsRef.current[target].collapsible) return;\n    const T = defsRef.current[target];\n    const shut = isCollapsed(target);\n    // Remember where it was, so opening it again puts it back exactly there.\n    if (!shut) restore.current[target] = mvs[target].get();\n    const want = shut ? clamp(restore.current[target] ?? T.min, T.min, T.max) : T.collapsedSize;\n    const a = target === h ? want : total - want;\n    void set(h, clamp(a, 0, total), total, instant ? \"jump\" : \"soft\").then(commit);\n    if (instant) commit();\n  };\n\n  // A controlled `collapsed` prop animates the panel open or shut.\n  const controlled = defs.map((d) => d.collapsed);\n  useEffect(() => {\n    controlled.forEach((want, i) => {\n      if (want === undefined || want === isCollapsed(i)) return;\n      const h = i < mvs.length - 1 ? i : i - 1;\n      if (h < 0) return;\n      toggle(h, false, i);\n    });\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- only react to the prop values changing\n  }, [controlled.join(\",\")]);\n\n  const countBefore = (i: number, type: unknown) => kids.slice(0, i).filter((k) => k.type === type).length;\n  return (\n    <Group.Provider value={{ direction, sizes, mvs, defs, groupPx, dragging, startDrag, nudge, jumpTo, toggle }}>\n      <div\n        ref={root}\n        data-direction={direction}\n        data-dragging={dragging !== null ? \"\" : undefined}\n        className={cn(\"flex size-full min-h-0 min-w-0 overflow-hidden\", direction === \"vertical\" && \"flex-col\", className)}\n        {...rest}\n      >\n        {kids.map((k, i) => {\n          if (k.type === Panel) {\n            return (\n              <PanelIndex.Provider key={k.key ?? i} value={countBefore(i, Panel)}>\n                {k}\n              </PanelIndex.Provider>\n            );\n          }\n          if (k.type === PanelHandle) {\n            return (\n              <HandleIndex.Provider key={k.key ?? i} value={countBefore(i, PanelHandle)}>\n                {k}\n              </HandleIndex.Provider>\n            );\n          }\n          return k;\n        })}\n      </div>\n    </Group.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Panel\n * -----------------------------------------------------------------------------------------------*/\n\nexport type PanelProps = Omit<React.ComponentProps<\"div\">, \"id\"> & {\n  id?: string;\n  /** Starting size in percent. Panels without one share what is left. */\n  defaultSize?: number;\n  /** Percent. Default 10. */\n  minSize?: number;\n  /** Percent. Default 100. */\n  maxSize?: number;\n  /** Can be dragged or double-clicked shut. */\n  collapsible?: boolean;\n  /** Size when collapsed, in percent: 0 hides it, a few percent leaves a rail. */\n  collapsedSize?: number;\n  /** Controlled collapsed state: changing it animates the panel. */\n  collapsed?: boolean;\n  onCollapsedChange?: (collapsed: boolean) => void;\n};\n\n// Props the group reads; everything else lands on the panel element.\nconst groupKeys = [\"id\", \"defaultSize\", \"minSize\", \"maxSize\", \"collapsible\", \"collapsedSize\", \"collapsed\", \"onCollapsedChange\"] as const;\n\nexport function Panel({ className, children, style, ...props }: PanelProps) {\n  const rest: Record<string, unknown> = { ...props };\n  for (const k of groupKeys) delete rest[k];\n  const { direction, sizes, mvs, defs, groupPx } = useGroup();\n  const i = useContext(PanelIndex);\n  const def = defs[i];\n  const mv = mvs[i];\n  const flex = useTransform(mv, (v) => `${v} 1 0px`);\n  // Content fades as the panel closes and keeps its minimum size, so it is clipped rather than squeezed.\n  const opacity = useTransform(mv, [def.collapsedSize, Math.max(def.collapsedSize + 0.01, def.min * 0.8)], def.collapsible ? [0, 1] : [1, 1]);\n  const collapsed = def.collapsible && sizes[i] <= def.collapsedSize + 0.01;\n  const minPx = def.collapsible && groupPx ? (groupPx * def.min) / 100 : undefined;\n\n  return (\n    <motion.div\n      id={def.id}\n      data-panel=\"\"\n      data-collapsed={collapsed ? \"\" : undefined}\n      style={{ ...style, flex }}\n      className={cn(\"relative min-h-0 min-w-0 overflow-hidden\", className)}\n      {...(rest as Omit<React.ComponentProps<typeof motion.div>, \"style\">)}\n    >\n      <motion.div\n        inert={collapsed}\n        style={{ opacity, ...(minPx ? (direction === \"horizontal\" ? { minWidth: minPx } : { minHeight: minPx }) : null) }}\n        className=\"size-full\"\n      >\n        {children}\n      </motion.div>\n    </motion.div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * PanelHandle\n * -----------------------------------------------------------------------------------------------*/\n\nexport type PanelHandleProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Always show the grip, not only on hover and focus. Touch screens always show it. */\n  grip?: boolean;\n  /** Accessible name. Defaults to \"Resize {panel id}\". */\n  \"aria-label\"?: string;\n};\n\nexport function PanelHandle({ grip = false, className, \"aria-label\": label, ...rest }: PanelHandleProps) {\n  const { direction, sizes, defs, dragging, startDrag, nudge, jumpTo, toggle } = useGroup();\n  const h = useContext(HandleIndex);\n  const A = defs[h];\n  const horizontal = direction === \"horizontal\";\n  const total = (sizes[h] ?? 0) + (sizes[h + 1] ?? 0);\n  const lo = Math.max(A.min, total - defs[h + 1].max);\n  const hi = Math.min(A.max, total - defs[h + 1].min);\n  const active = dragging === h;\n  const collapsedSide = [h, h + 1].find((i) => defs[i].collapsible && sizes[i] <= defs[i].collapsedSize + 0.01);\n\n  return (\n    <div\n      role=\"separator\"\n      tabIndex={0}\n      aria-orientation={horizontal ? \"vertical\" : \"horizontal\"}\n      aria-controls={A.id}\n      aria-valuenow={Math.round(sizes[h] ?? 0)}\n      aria-valuemin={Math.round(A.collapsible ? A.collapsedSize : lo)}\n      aria-valuemax={Math.round(hi)}\n      aria-label={label ?? \"Resize panels\"}\n      data-dragging={active ? \"\" : undefined}\n      data-collapsed={collapsedSide !== undefined ? \"\" : undefined}\n      onPointerDown={(e) => startDrag(h, e)}\n      onDoubleClick={() => toggle(h)}\n      onKeyDown={(e) => {\n        const back = horizontal ? \"ArrowLeft\" : \"ArrowUp\";\n        const fwd = horizontal ? \"ArrowRight\" : \"ArrowDown\";\n        const step = e.shiftKey ? 10 : 2;\n        if (e.key === back) nudge(h, -step);\n        else if (e.key === fwd) nudge(h, step);\n        else if (e.key === \"Home\") jumpTo(h, \"min\");\n        else if (e.key === \"End\") jumpTo(h, \"max\");\n        else if (e.key === \"Enter\") toggle(h, true);\n        else return;\n        e.preventDefault();\n      }}\n      className={cn(\n        \"group/handle relative z-[1] flex-none touch-none select-none bg-line outline-none\",\n        \"transition-[background-color] duration-150 hover:bg-fg-4 hover:delay-150 data-dragging:bg-fg-3 data-dragging:delay-0 focus-visible:bg-fg-3\",\n        // The line is 1px; the target is 9px (21px on touch), centered on it.\n        \"before:absolute before:content-['']\",\n        horizontal\n          ? \"w-px cursor-col-resize before:inset-y-0 before:-inset-x-1 pointer-coarse:before:-inset-x-2.5\"\n          : \"h-px cursor-row-resize before:inset-x-0 before:-inset-y-1 pointer-coarse:before:-inset-y-2.5\",\n        className,\n      )}\n      {...rest}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-fg-3 ring-2 ring-frame\",\n          \"opacity-0 transition-[opacity,scale,background-color] duration-150 ease-out-expo motion-reduce:transition-opacity\",\n          horizontal ? \"h-6 w-[5px] scale-y-50\" : \"h-[5px] w-6 scale-x-50\",\n          \"group-hover/handle:opacity-100 group-hover/handle:delay-150 group-focus-visible/handle:opacity-100 group-data-dragging/handle:bg-fg group-data-dragging/handle:opacity-100 group-data-dragging/handle:delay-0\",\n          horizontal\n            ? \"group-hover/handle:scale-y-100 group-focus-visible/handle:scale-y-100 group-data-dragging/handle:scale-y-110\"\n            : \"group-hover/handle:scale-x-100 group-focus-visible/handle:scale-x-100 group-data-dragging/handle:scale-x-110\",\n          (grip || collapsedSide !== undefined) && \"opacity-100\",\n          grip && (horizontal ? \"scale-y-100\" : \"scale-x-100\"),\n          \"pointer-coarse:opacity-100\",\n        )}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/resizable-panels"
}
