# Resizable

> Resizable panel groups: nested, collapsible, keyboard-driven, saved to localStorage. Grips light up as tubes.

Category: Layout. Docs: https://neonkit.heyaadi.com/components/resizable

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/resizable.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/resizable`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"

<ResizablePanelGroup direction="horizontal" autoSaveId="editor">
  <ResizablePanel defaultSize={25} minSize={15} collapsible>Files</ResizablePanel>
  <ResizableHandle />
  <ResizablePanel defaultSize={75}>Editor</ResizablePanel>
</ResizablePanelGroup>
```

## Examples

### Editor layout

A file list beside a nested vertical group. Drag a grip, or drag a panel past half its minimum to fold it. The layout is saved.

```tsx
"use client";

import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";

const files = ["page.tsx", "layout.tsx", "billing.tsx", "route.ts", "schema.prisma"];

export default function ResizableDemo() {
  return (
    // Below 600px the editor scrolls sideways rather than squeezing file names to an ellipsis.
    <div className="w-full max-w-4xl overflow-x-auto rounded-xl border border-white/10 bg-white/[0.02]">
      <div className="h-[420px] min-w-[600px]">
        <ResizablePanelGroup direction="horizontal" autoSaveId="resizable-demo">
          <ResizablePanel defaultSize={24} minSize={16} maxSize={40} collapsible>
            <div className="grid h-full content-start gap-0.5 p-3">
              <p className="px-2 pb-2 text-[13px] text-white/50">acme-web</p>
              {files.map((file, i) => (
                <p
                  key={file}
                  className={
                    i === 2
                      ? "truncate rounded-md bg-white/[0.06] px-2 py-1 font-mono text-[12.5px] text-white/90"
                      : "truncate px-2 py-1 font-mono text-[12.5px] text-white/55"
                  }
                >
                  {file}
                </p>
              ))}
            </div>
          </ResizablePanel>
          <ResizableHandle aria-label="Resize file list" />
          <ResizablePanel defaultSize={76} minSize={40}>
            <ResizablePanelGroup direction="vertical">
              <ResizablePanel defaultSize={68} minSize={30}>
                <div className="h-full overflow-hidden whitespace-pre p-5 font-mono text-[12.5px] leading-6 text-white/55">
                  <p><span className="text-white/25">12</span>  export async function updatePlan(id: string, plan: Plan) {"{"}</p>
                  <p><span className="text-white/25">13</span>    const seat = await db.seat.count({"{"} where: {"{"} orgId: id {"}"} {"}"});</p>
                  <p><span className="text-white/25">14</span>    if (seat &gt; limits[plan]) throw new SeatLimitError(seat);</p>
                  <p><span className="text-white/25">15</span>    return stripe.subscriptions.update(id, {"{"} plan {"}"});</p>
                  <p><span className="text-white/25">16</span>  {"}"}</p>
                </div>
              </ResizablePanel>
              <ResizableHandle aria-label="Resize terminal" />
              <ResizablePanel defaultSize={32} minSize={12} collapsible>
                <div className="h-full overflow-hidden bg-black/30 p-4 font-mono text-[12.5px] leading-6 text-white/50">
                  <p><span className="text-white/25">$</span> pnpm test billing</p>
                  <p className="text-[#34d399]">✓ 14 passed (1.82s)</p>
                </div>
              </ResizablePanel>
            </ResizablePanelGroup>
          </ResizablePanel>
        </ResizablePanelGroup>
      </div>
    </div>
  );
}
```

### Collapse to a rail

collapsedSize leaves the folder pane as an icon rail rather than hiding it.

```tsx
"use client";

import * as React from "react";
import { Archive, Inbox, Send, Star } from "@/components/ui/icons";
import { cn } from "@/lib/utils";
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable";

const folders = [
  { name: "Inbox", icon: Inbox, count: 12 },
  { name: "Starred", icon: Star, count: 3 },
  { name: "Sent", icon: Send },
  { name: "Archive", icon: Archive },
];

const threads = [
  {
    from: "Priya Raman",
    subject: "Q4 pricing review",
    preview: "Pulled the churn numbers for the Team plan…",
  },
  {
    from: "Linear",
    subject: "ENG-2201 moved to Done",
    preview: "Cache edge config between deploys",
  },
  {
    from: "Marco Bellini",
    subject: "Re: SOC 2 evidence",
    preview: "Uploaded the access reviews for September.",
  },
];

// The folder pane collapses to a 56px-ish icon rail instead of disappearing.
export default function ResizableRail() {
  const [collapsed, setCollapsed] = React.useState(false);

  return (
    <div className="w-full max-w-4xl overflow-x-auto rounded-xl border border-white/10 bg-white/[0.02]">
      <div className="h-[340px] min-w-[600px]">
        <ResizablePanelGroup direction="horizontal">
          <ResizablePanel
            defaultSize={22}
            minSize={16}
            maxSize={30}
            collapsible
            collapsedSize={7}
            onCollapse={() => setCollapsed(true)}
            onExpand={() => setCollapsed(false)}
          >
            <nav aria-label="Folders" className="grid gap-0.5 p-2">
              {folders.map(({ name, icon: Icon, count }, i) => (
                <a
                  key={name}
                  href="#"
                  title={collapsed ? name : undefined}
                  className={cn(
                    "flex h-9 items-center gap-3 rounded-md px-2.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon-accent,34_211_238)/0.6)]",
                    i === 0
                      ? "bg-white/[0.06] text-white/90"
                      : "text-white/55 hover:bg-white/[0.04] hover:text-white/85",
                    collapsed && "justify-center px-0",
                  )}
                >
                  <Icon aria-hidden className="size-4 shrink-0" />
                  {!collapsed && (
                    <span className="flex-1 truncate">{name}</span>
                  )}
                  {!collapsed && count && (
                    <span className="text-xs tabular-nums text-white/40">
                      {count}
                    </span>
                  )}
                </a>
              ))}
            </nav>
          </ResizablePanel>
          <ResizableHandle aria-label="Resize folders" />
          <ResizablePanel defaultSize={36} minSize={24}>
            <ul className="divide-y divide-white/[0.06]">
              {threads.map((t, i) => (
                <li
                  key={t.subject}
                  className={cn(
                    "grid gap-0.5 px-4 py-3",
                    i === 0 && "bg-white/[0.03]",
                  )}
                >
                  <p className="truncate text-sm font-medium text-white/90">
                    {t.from}
                  </p>
                  <p className="truncate text-[13px] text-white/70">
                    {t.subject}
                  </p>
                  <p className="truncate text-xs text-white/40">{t.preview}</p>
                </li>
              ))}
            </ul>
          </ResizablePanel>
          <ResizableHandle aria-label="Resize thread list" />
          <ResizablePanel defaultSize={42} minSize={30}>
            <article className="grid gap-3 p-6">
              <h3 className="text-lg font-medium text-white/90">
                Q4 pricing review
              </h3>
              <p className="max-w-prose text-sm leading-6 text-white/60">
                Pulled the churn numbers for the Team plan. Annual seats churn
                at a third of monthly ones, so I&apos;d like to test a 20%
                annual discount before we touch list prices. Draft deck by
                Thursday.
              </p>
            </article>
          </ResizablePanel>
        </ResizablePanelGroup>
      </div>
    </div>
  );
}
```

### Imperative and onLayout

A button folds the outline through apiRef; onLayout reports every size.

```tsx
"use client";

import * as React from "react";
import { PanelLeft } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
  type ResizablePanelApi,
} from "@/components/ui/resizable";

export default function ResizableControlled() {
  const sidebar = React.useRef<ResizablePanelApi>(null);
  const [sizes, setSizes] = React.useState<number[]>([30, 70]);
  const open = sizes[0] > 0.5;

  return (
    <div className="grid w-full max-w-2xl gap-3">
      <div className="flex items-center justify-between gap-3">
        <Button
          variant="ghost"
          size="sm"
          className="border border-white/10"
          aria-pressed={open}
          onClick={() => (open ? sidebar.current?.collapse() : sidebar.current?.expand())}
        >
          <PanelLeft aria-hidden />
          {open ? "Hide outline" : "Show outline"}
        </Button>
        <p className="font-mono text-xs tabular-nums text-white/50">
          {sizes.map((s) => `${s.toFixed(1)}%`).join(" / ")}
        </p>
      </div>
      <div className="h-56 overflow-hidden rounded-xl border border-white/10 bg-white/[0.02]">
        <ResizablePanelGroup direction="horizontal" onLayout={setSizes}>
          <ResizablePanel apiRef={sidebar} defaultSize={30} minSize={20} maxSize={45} collapsible>
            <ol className="grid gap-2 p-4 text-[13px] text-white/55">
              <li className="text-white/90">1. Why annual</li>
              <li>2. Churn by plan</li>
              <li>3. The 20% test</li>
              <li>4. Risks</li>
            </ol>
          </ResizablePanel>
          <ResizableHandle withHandle={false} aria-label="Resize outline" />
          <ResizablePanel defaultSize={70} minSize={40}>
            <div className="grid gap-2 p-5">
              <h3 className="text-base font-medium text-white/90">Why annual</h3>
              <p className="text-sm leading-6 text-white/55">
                Annual Team seats churn at 0.9% a month against 2.7% for monthly ones.
              </p>
            </div>
          </ResizablePanel>
        </ResizablePanelGroup>
      </div>
      <p className="text-xs leading-5 text-white/50">
        Focus the divider and use the arrow keys, or press Enter to fold the outline away.
      </p>
    </div>
  );
}
```

## API reference

### ResizablePanelGroup

A flex container. Panels and handles must be its direct children. Accepts every native div attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `direction` | `"horizontal" \| "vertical"` | `"horizontal"` | Which way the panels sit. |
| `onLayout` | `(sizes: number[]) => void` | — | Every panel's size in percent, after each change. |
| `autoSaveId` | `string` | — | Saves the layout in localStorage and restores it on the next visit. Blocked or full storage is ignored. |
| `keyboardStep` | `number` | `5` | Percent per arrow key. Shift moves 1%. |
| `color` | `NeonColor` | `inherited` | Palette name or hex for the handles. Falls back to --neon-accent, then cyan. |

### ResizablePanel

Accepts every native div attribute. Sizes are percentages of the group.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultSize` | `number` | — | Starting size. Panels without one share what's left. Set it on every panel to avoid a jump after hydration. |
| `minSize` | `number` | `0` | Smallest size while open. |
| `maxSize` | `number` | `100` | Largest size. |
| `collapsible` | `boolean` | `false` | Can fold to collapsedSize: drag past half the minimum, double-click the handle or press Enter on it. |
| `collapsedSize` | `number` | `0` | Size when collapsed, e.g. room for an icon rail. |
| `onResize` | `(size: number) => void` | — | Called when this panel's size changes. |
| `onCollapse / onExpand` | `() => void` | — | Called when the panel folds or opens. |
| `apiRef` | `Ref<ResizablePanelApi>` | — | collapse(), expand(), resize(size), getSize() and isCollapsed(). |

### ResizableHandle

A focusable separator. Give it an aria-label that names what it resizes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `withHandle` | `boolean` | `true` | Shows the grip. Without it the handle is a hairline that lights up. |
| `disabled` | `boolean` | `false` | Can't be dragged or focused. |

## Accessibility

- Handles follow the APG window splitter: role separator, aria-valuenow is the size of the panel before it, and aria-controls points at that panel.
- Arrow keys resize along the group's direction, Shift for 1%. Home and End go to the minimum and maximum.
- Enter folds the panel before the handle, or the one after it if only that one is collapsible, and Enter again restores its last size.
- A shrinking panel that reaches its minimum passes the rest of the drag to the next one along.
- The grip lights as a tube while hovered, focused or dragged; at rest it's an unlit glass capsule.
- Folding with the keyboard or a double-click animates; dragging never does, and reduced motion turns the animation off.

## Source

`components/ui/resizable.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

type Direction = "horizontal" | "vertical";

const EPSILON = 0.01;
const ANIMATION_MS = 240;
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;

interface PanelConfig {
  minSize: number;
  maxSize: number;
  defaultSize?: number;
  collapsible: boolean;
  collapsedSize: number;
}

interface PanelCallbacks {
  onResize?: (size: number) => void;
  onCollapse?: () => void;
  onExpand?: () => void;
}

interface Structure {
  /** Panel ids in DOM order. */
  order: string[];
  /** Handle id → index of the panel before it. */
  handles: Record<string, number>;
}

interface GroupContextValue {
  groupId: string;
  direction: Direction;
  layout: number[] | null;
  structure: Structure;
  animating: boolean;
  register: (id: string, config: React.RefObject<PanelConfig & PanelCallbacks>) => () => void;
  registerHandle: () => () => void;
  startDrag: (handleId: string, event: React.PointerEvent<HTMLElement>) => void;
  resizeBy: (before: number, delta: number, animate?: boolean) => void;
  toggleCollapse: (before: number) => void;
  setPanelSize: (id: string, size: number) => void;
  getConfig: (index: number) => PanelConfig | undefined;
  draggingHandle: string | null;
  keyboardStep: number;
}

const GroupContext = React.createContext<GroupContextValue | null>(null);

function useGroup(component: string) {
  const context = React.useContext(GroupContext);
  if (!context) throw new Error(`<${component}> must be rendered inside <ResizablePanelGroup>.`);
  return context;
}

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const isCollapsed = (config: PanelConfig, size: number) => config.collapsible && size <= config.collapsedSize + EPSILON;

/**
 * Moves the boundary after panel `before` by `delta` percent. The panel next to the
 * boundary grows; the other side shrinks, nearest panel first, so a panel at its minimum
 * passes the rest on. A collapsible panel snaps shut once pushed past half its minimum,
 * and a collapsed one only opens once pulled past half of it.
 */
function adjustLayout(start: number[], panels: PanelConfig[], before: number, delta: number): number[] {
  if (Math.abs(delta) < EPSILON || before < 0 || before >= start.length - 1) return start;
  const next = [...start];
  const grow = delta > 0 ? before : before + 1;
  const step = delta > 0 ? 1 : -1;
  const want = Math.abs(delta);

  const growing = panels[grow];
  const growFrom = start[grow];
  let target = growFrom + want;
  if (isCollapsed(growing, growFrom)) {
    if (want < (growing.minSize - growing.collapsedSize) / 2) return start;
    target = Math.max(target, growing.minSize);
  }
  target = Math.min(target, growing.maxSize);
  const need = target - growFrom;
  if (need <= EPSILON) return start;

  let taken = 0;
  for (let i = grow === before ? before + 1 : before; i >= 0 && i < start.length && need - taken > EPSILON; i += step) {
    const panel = panels[i];
    const size = start[i];
    if (isCollapsed(panel, size)) continue;
    let to = size - (need - taken);
    if (to < panel.minSize) {
      to = panel.collapsible && to < (panel.minSize + panel.collapsedSize) / 2 ? panel.collapsedSize : Math.min(size, panel.minSize);
    }
    next[i] = to;
    taken += size - to;
  }

  const grown = growFrom + taken;
  if (taken <= EPSILON || grown > growing.maxSize + EPSILON) return start;
  if (isCollapsed(growing, growFrom) && grown < growing.minSize - EPSILON) return start;
  next[grow] = grown;
  return next;
}

function initialLayout(configs: PanelConfig[]): number[] {
  const fixed = configs.reduce((sum, c) => sum + (c.defaultSize ?? 0), 0);
  const open = configs.filter((c) => c.defaultSize === undefined).length;
  const share = open ? Math.max(0, 100 - fixed) / open : 0;
  const sizes = configs.map((c) => clamp(c.defaultSize ?? share, c.collapsible ? c.collapsedSize : c.minSize, c.maxSize));
  const total = sizes.reduce((a, b) => a + b, 0) || 1;
  return sizes.map((size) => (size / total) * 100);
}

function readSaved(key: string, count: number): number[] | null {
  try {
    const raw = window.localStorage.getItem(key);
    if (!raw) return null;
    const parsed: unknown = JSON.parse(raw);
    if (!Array.isArray(parsed) || parsed.length !== count || !parsed.every((n) => typeof n === "number" && n >= 0)) return null;
    const total = parsed.reduce((a: number, b: number) => a + b, 0);
    return Math.abs(total - 100) < 1 ? (parsed as number[]) : null;
  } catch {
    return null;
  }
}

function prefersReducedMotion() {
  return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
}

export interface ResizablePanelGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color" | "dir"> {
  /** Palette name or any hex color for the handles. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  direction?: Direction;
  /** Called with every panel's size, in percent, whenever the layout changes. */
  onLayout?: (sizes: number[]) => void;
  /** Saves the layout in localStorage under this key and restores it on the next visit. */
  autoSaveId?: string;
  /** Percent moved per arrow key press. Shift moves 1%. */
  keyboardStep?: number;
}

const ResizablePanelGroup = React.forwardRef<HTMLDivElement, ResizablePanelGroupProps>(
  (
    { className, style, color, direction = "horizontal", onLayout, autoSaveId, keyboardStep = 5, children, ...props },
    ref,
  ) => {
    const groupId = React.useId();
    const rootRef = React.useRef<HTMLDivElement>(null);
    React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);

    const panels = React.useRef(new Map<string, React.RefObject<PanelConfig & PanelCallbacks>>());
    const [version, setVersion] = React.useState(0);
    const [structure, setStructure] = React.useState<Structure>({ order: [], handles: {} });
    const [layout, setLayoutState] = React.useState<number[] | null>(null);
    const [draggingHandle, setDraggingHandle] = React.useState<string | null>(null);
    const [animating, setAnimating] = React.useState(false);
    const layoutRef = React.useRef<number[] | null>(null);
    const restore = React.useRef(new Map<string, number>());
    const animationTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

    const configs = React.useCallback(
      (order: string[]) => order.map((id) => panels.current.get(id)?.current as PanelConfig),
      [],
    );

    const commit = React.useCallback(
      (next: number[], from?: number[]) => {
        const previous = layoutRef.current;
        if (previous && next.every((size, i) => Math.abs(size - previous[i]) < EPSILON)) return;
        // Remember what a panel was before it collapsed, so expanding brings it back there.
        if (previous) {
          structure.order.forEach((id, i) => {
            const config = panels.current.get(id)?.current;
            if (config && isCollapsed(config, next[i]) && !isCollapsed(config, previous[i])) {
              restore.current.set(id, Math.max((from ?? previous)[i], config.minSize));
            }
          });
        }
        layoutRef.current = next;
        setLayoutState(next);
      },
      [structure.order],
    );

    // Panels and handles register on mount; read their order from the DOM so conditional
    // children land in the right place.
    useIsomorphicLayoutEffect(() => {
      const root = rootRef.current;
      if (!root) return;
      const order: string[] = [];
      const handles: Record<string, number> = {};
      for (const child of Array.from(root.children) as HTMLElement[]) {
        if (child.dataset.panelGroupId !== groupId) continue;
        if (child.dataset.panelId) order.push(child.dataset.panelId);
        else if (child.dataset.handleId) handles[child.dataset.handleId] = order.length - 1;
      }
      const sameOrder = order.join() === structure.order.join();
      if (!sameOrder || JSON.stringify(handles) !== JSON.stringify(structure.handles)) {
        setStructure({ order, handles });
      }
      if (sameOrder && layoutRef.current) return;
      const list = configs(order);
      if (list.some((c) => !c)) return;
      const saved = autoSaveId ? readSaved(`nk-resizable:${autoSaveId}`, order.length) : null;
      const next = saved ?? initialLayout(list);
      layoutRef.current = next;
      setLayoutState(next);
    }, [version, groupId, autoSaveId, configs, structure]);

    const onLayoutRef = React.useRef(onLayout);
    useIsomorphicLayoutEffect(() => {
      onLayoutRef.current = onLayout;
    });

    // Report the layout and per-panel changes.
    const reported = React.useRef<number[] | null>(null);
    React.useEffect(() => {
      if (!layout) return;
      const previous = reported.current;
      reported.current = layout;
      onLayoutRef.current?.(layout);
      structure.order.forEach((id, i) => {
        const panel = panels.current.get(id)?.current;
        if (!panel || (previous && Math.abs(previous[i] - layout[i]) < EPSILON)) return;
        panel.onResize?.(layout[i]);
        if (!previous) return;
        const was = isCollapsed(panel, previous[i]);
        const now = isCollapsed(panel, layout[i]);
        if (now && !was) panel.onCollapse?.();
        if (!now && was) panel.onExpand?.();
      });
    }, [layout, structure.order]);

    // Save once the pointer lets go, not on every frame of a drag.
    React.useEffect(() => {
      if (!autoSaveId || !layout || draggingHandle) return;
      try {
        window.localStorage.setItem(`nk-resizable:${autoSaveId}`, JSON.stringify(layout.map((n) => Math.round(n * 100) / 100)));
      } catch {
        // Storage can be full or blocked; the layout still works for this visit.
      }
    }, [autoSaveId, layout, draggingHandle]);

    React.useEffect(() => () => clearTimeout(animationTimer.current), []);

    const animate = React.useCallback(() => {
      if (prefersReducedMotion()) return;
      setAnimating(true);
      clearTimeout(animationTimer.current);
      animationTimer.current = setTimeout(() => setAnimating(false), ANIMATION_MS);
    }, []);

    const register = React.useCallback((id: string, config: React.RefObject<PanelConfig & PanelCallbacks>) => {
      panels.current.set(id, config);
      setVersion((v) => v + 1);
      return () => {
        panels.current.delete(id);
        setVersion((v) => v + 1);
      };
    }, []);

    const registerHandle = React.useCallback(() => {
      setVersion((v) => v + 1);
      return () => setVersion((v) => v + 1);
    }, []);

    const resizeBy = React.useCallback(
      (before: number, delta: number, withAnimation = false) => {
        const current = layoutRef.current;
        if (!current) return;
        const next = adjustLayout(current, configs(structure.order), before, delta);
        if (next === current) return;
        if (withAnimation) animate();
        commit(next, current);
      },
      [configs, structure.order, commit, animate],
    );

    const toggleCollapse = React.useCallback(
      (before: number) => {
        const current = layoutRef.current;
        if (!current) return;
        const list = configs(structure.order);
        // The pane before the handle is the primary one; fall back to the one after it.
        const index = list[before]?.collapsible ? before : list[before + 1]?.collapsible ? before + 1 : -1;
        if (index < 0) return;
        const config = list[index];
        const id = structure.order[index];
        const size = current[index];
        const target = isCollapsed(config, size)
          ? clamp(restore.current.get(id) ?? config.defaultSize ?? config.minSize, config.minSize, config.maxSize)
          : config.collapsedSize;
        const change = target - size;
        resizeBy(before, index === before ? change : -change, true);
      },
      [configs, structure.order, resizeBy],
    );

    const setPanelSize = React.useCallback(
      (id: string, size: number) => {
        const current = layoutRef.current;
        const index = structure.order.indexOf(id);
        if (!current || index < 0) return;
        const change = size - current[index];
        if (index < current.length - 1) resizeBy(index, change, true);
        else resizeBy(index - 1, -change, true);
      },
      [structure.order, resizeBy],
    );

    const startDrag = React.useCallback(
      (handleId: string, event: React.PointerEvent<HTMLElement>) => {
        const root = rootRef.current;
        const before = structure.handles[handleId];
        const start = layoutRef.current;
        if (!root || before === undefined || before < 0 || !start) return;
        const horizontal = direction === "horizontal";
        const rect = root.getBoundingClientRect();
        let handleSpace = 0;
        for (const child of Array.from(root.children) as HTMLElement[]) {
          if (child.dataset.panelGroupId === groupId && child.dataset.handleId) {
            const r = child.getBoundingClientRect();
            handleSpace += horizontal ? r.width : r.height;
          }
        }
        const total = (horizontal ? rect.width : rect.height) - handleSpace;
        if (total <= 0) return;

        const handle = event.currentTarget;
        const origin = horizontal ? event.clientX : event.clientY;
        const pointerId = event.pointerId;
        const list = configs(structure.order);
        handle.setPointerCapture?.(pointerId);
        setDraggingHandle(handleId);

        const body = document.body.style;
        const saved = { cursor: body.cursor, userSelect: body.userSelect };
        body.cursor = horizontal ? "col-resize" : "row-resize";
        body.userSelect = "none";

        const move = (e: PointerEvent) => {
          if (e.pointerId !== pointerId) return;
          const delta = (((horizontal ? e.clientX : e.clientY) - origin) / total) * 100;
          commit(adjustLayout(start, list, before, delta), start);
        };
        const end = (e: PointerEvent) => {
          if (e.pointerId !== pointerId) return;
          handle.removeEventListener("pointermove", move);
          handle.removeEventListener("pointerup", end);
          handle.removeEventListener("pointercancel", end);
          body.cursor = saved.cursor;
          body.userSelect = saved.userSelect;
          setDraggingHandle(null);
        };
        handle.addEventListener("pointermove", move);
        handle.addEventListener("pointerup", end);
        handle.addEventListener("pointercancel", end);
      },
      [structure.handles, structure.order, direction, groupId, configs, commit],
    );

    const getConfig = React.useCallback(
      (index: number) => panels.current.get(structure.order[index] ?? "")?.current ?? undefined,
      [structure.order],
    );

    const context = React.useMemo<GroupContextValue>(
      () => ({
        groupId,
        direction,
        layout,
        structure,
        animating,
        register,
        registerHandle,
        startDrag,
        resizeBy,
        toggleCollapse,
        setPanelSize,
        getConfig,
        draggingHandle,
        keyboardStep,
      }),
      [groupId, direction, layout, structure, animating, register, registerHandle, startDrag, resizeBy, toggleCollapse, setPanelSize, getConfig, draggingHandle, keyboardStep],
    );

    return (
      <GroupContext.Provider value={context}>
        <div
          ref={rootRef}
          data-panel-group=""
          data-direction={direction}
          style={neonVars(color, style)}
          className={cn(
            "[--neon:var(--neon-accent,34_211_238)]",
            "flex size-full overflow-hidden",
            direction === "vertical" && "flex-col",
            className,
          )}
          {...props}
        >
          {children}
        </div>
      </GroupContext.Provider>
    );
  },
);
ResizablePanelGroup.displayName = "ResizablePanelGroup";

export interface ResizablePanelApi {
  collapse: () => void;
  expand: () => void;
  /** Sets the size in percent, taking the space from or giving it to the neighbour. */
  resize: (size: number) => void;
  getSize: () => number;
  isCollapsed: () => boolean;
}

export interface ResizablePanelProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Starting size in percent. Panels without one share what's left. */
  defaultSize?: number;
  /** Percent. */
  minSize?: number;
  /** Percent. */
  maxSize?: number;
  /** Lets the panel shut: drag it past half its minimum, double-click the handle or press Enter on it. */
  collapsible?: boolean;
  /** Percent left showing when collapsed, e.g. for an icon rail. */
  collapsedSize?: number;
  onResize?: (size: number) => void;
  onCollapse?: () => void;
  onExpand?: () => void;
  /** Imperative control: collapse, expand, resize. */
  apiRef?: React.Ref<ResizablePanelApi>;
}

const ResizablePanel = React.forwardRef<HTMLDivElement, ResizablePanelProps>(
  (
    {
      id: idProp,
      className,
      style,
      defaultSize,
      minSize = 0,
      maxSize = 100,
      collapsible = false,
      collapsedSize = 0,
      onResize,
      onCollapse,
      onExpand,
      apiRef,
      ...props
    },
    ref,
  ) => {
    const group = useGroup("ResizablePanel");
    const autoId = React.useId();
    const id = idProp ?? autoId;
    const config = React.useRef<PanelConfig & PanelCallbacks>({ minSize, maxSize, defaultSize, collapsible, collapsedSize });
    useIsomorphicLayoutEffect(() => {
      config.current = { minSize, maxSize, defaultSize, collapsible, collapsedSize, onResize, onCollapse, onExpand };
    });

    const { register } = group;
    useIsomorphicLayoutEffect(() => register(id, config), [register, id]);

    const index = group.structure.order.indexOf(id);
    const size = group.layout && index >= 0 ? group.layout[index] : defaultSize;
    const collapsed = size !== undefined && collapsible && size <= collapsedSize + EPSILON;

    const layoutRef = React.useRef({ size, setPanelSize: group.setPanelSize });
    useIsomorphicLayoutEffect(() => {
      layoutRef.current = { size, setPanelSize: group.setPanelSize };
    });

    React.useImperativeHandle(
      apiRef,
      () => ({
        collapse: () => {
          if (config.current.collapsible) layoutRef.current.setPanelSize(id, config.current.collapsedSize);
        },
        expand: () => {
          const c = config.current;
          const current = layoutRef.current.size ?? 0;
          if (c.collapsible && current <= c.collapsedSize + EPSILON) {
            layoutRef.current.setPanelSize(id, clamp(c.defaultSize ?? c.minSize, c.minSize, c.maxSize));
          }
        },
        resize: (next: number) => layoutRef.current.setPanelSize(id, next),
        getSize: () => layoutRef.current.size ?? 0,
        isCollapsed: () => {
          const c = config.current;
          return c.collapsible && (layoutRef.current.size ?? 0) <= c.collapsedSize + EPSILON;
        },
      }),
      [id],
    );

    return (
      <div
        ref={ref}
        id={id}
        data-panel-id={id}
        data-panel-group-id={group.groupId}
        data-state={collapsible ? (collapsed ? "collapsed" : "expanded") : undefined}
        style={{
          flexGrow: size ?? 1,
          flexShrink: 1,
          flexBasis: 0,
          transition: group.animating ? `flex ${ANIMATION_MS - 20}ms cubic-bezier(0.23, 1, 0.32, 1)` : undefined,
          ...style,
        }}
        className={cn("relative min-h-0 min-w-0 overflow-hidden", className)}
        {...props}
      />
    );
  },
);
ResizablePanel.displayName = "ResizablePanel";

export interface ResizableHandleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Shows the grip. Without it the handle is a bare hairline that lights up. */
  withHandle?: boolean;
  disabled?: boolean;
}

const ResizableHandle = React.forwardRef<HTMLDivElement, ResizableHandleProps>(
  (
    { className, withHandle = true, disabled = false, onPointerDown, onKeyDown, onDoubleClick, "aria-label": ariaLabel, ...props },
    ref,
  ) => {
    const group = useGroup("ResizableHandle");
    const handleId = React.useId();
    const { registerHandle } = group;
    useIsomorphicLayoutEffect(() => registerHandle(), [registerHandle]);

    const horizontal = group.direction === "horizontal";
    const before = group.structure.handles[handleId] ?? -1;
    const config = group.getConfig(before);
    const size = group.layout?.[before];
    const dragging = group.draggingHandle === handleId;
    // A neighbour collapsed to nothing puts the handle on the group's edge; keep the grip inside it.
    const after = group.layout?.[before + 1];
    const edge = size !== undefined && size < 0.5 ? "start" : after !== undefined && after < 0.5 ? "end" : null;
    const panelId = group.structure.order[before];

    function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
      onKeyDown?.(event);
      if (event.defaultPrevented || disabled || before < 0 || size === undefined || !config) return;
      const step = event.shiftKey ? 1 : group.keyboardStep;
      const back = horizontal ? "ArrowLeft" : "ArrowUp";
      const forward = horizontal ? "ArrowRight" : "ArrowDown";
      let delta: number | null = null;
      if (event.key === back) delta = -step;
      else if (event.key === forward) delta = step;
      else if (event.key === "Home") delta = (config.collapsible ? config.collapsedSize : config.minSize) - size;
      else if (event.key === "End") delta = config.maxSize - size;
      else if (event.key === "Enter") {
        event.preventDefault();
        group.toggleCollapse(before);
        return;
      }
      if (delta === null) return;
      event.preventDefault();
      // Arrows step over the snap zone: from the minimum, one more step back collapses.
      if (config.collapsible && delta < 0 && size - config.minSize < EPSILON && !(event.key === "Home")) {
        delta = config.collapsedSize - size;
      }
      group.resizeBy(before, delta, event.key === "Home" || event.key === "End");
    }

    return (
      <div
        ref={ref}
        role="separator"
        tabIndex={disabled ? undefined : 0}
        aria-orientation={horizontal ? "vertical" : "horizontal"}
        aria-label={ariaLabel ?? "Resize"}
        aria-controls={panelId}
        aria-valuenow={size === undefined ? undefined : Math.round(size)}
        aria-valuemin={config ? Math.round(config.collapsible ? config.collapsedSize : config.minSize) : undefined}
        aria-valuemax={config ? Math.round(config.maxSize) : undefined}
        aria-disabled={disabled || undefined}
        data-handle-id={handleId}
        data-panel-group-id={group.groupId}
        data-dragging={dragging ? "" : undefined}
        onPointerDown={(event) => {
          onPointerDown?.(event);
          if (event.defaultPrevented || disabled || event.button !== 0) return;
          event.preventDefault();
          event.currentTarget.focus({ preventScroll: true });
          group.startDrag(handleId, event);
        }}
        onDoubleClick={(event) => {
          onDoubleClick?.(event);
          if (event.defaultPrevented || disabled || before < 0) return;
          group.toggleCollapse(before);
        }}
        onKeyDown={handleKeyDown}
        className={cn(
          "group/handle relative z-10 flex shrink-0 touch-none select-none items-center justify-center bg-white/10 outline-none",
          // An invisible strip either side makes the 1px line easy to grab.
          "before:absolute before:content-['']",
          horizontal
            ? "w-px cursor-col-resize before:inset-y-0 before:-left-[5px] before:-right-[5px]"
            : "h-px cursor-row-resize before:inset-x-0 before:-top-[5px] before:-bottom-[5px]",
          disabled && "pointer-events-none",
          className,
        )}
        {...props}
      >
        {/* The line lights from the grip outwards while the handle is in use. */}
        <span
          aria-hidden
          className={cn(
            "pointer-events-none absolute opacity-0 transition-opacity duration-200 ease-out",
            "group-hover/handle:opacity-60 group-focus-visible/handle:opacity-100 group-data-[dragging]/handle:opacity-100",
            horizontal
              ? "inset-y-0 left-0 w-px bg-[linear-gradient(to_bottom,transparent,rgb(var(--neon)/0.7)_35%,rgb(var(--neon)/0.7)_65%,transparent)]"
              : "inset-x-0 top-0 h-px bg-[linear-gradient(to_right,transparent,rgb(var(--neon)/0.7)_35%,rgb(var(--neon)/0.7)_65%,transparent)]",
          )}
        />
        {withHandle && (
          <span
            aria-hidden
            className={cn(
              "relative shrink-0 rounded-full border border-white/20 bg-[#0c0e15]",
              "transition-[background-color,border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]",
              horizontal ? "h-8 w-[7px]" : "h-[7px] w-8",
              edge === "start" && (horizontal ? "translate-x-[4px]" : "translate-y-[4px]"),
              edge === "end" && (horizontal ? "-translate-x-[4px]" : "-translate-y-[4px]"),
              "group-hover/handle:border-[rgb(var(--neon)/0.7)] group-hover/handle:bg-[rgb(var(--neon)/0.35)] group-hover/handle:shadow-[0_0_10px_rgb(var(--neon)/CALC(0.35*var(--neon-glow,1)))]",
              "group-focus-visible/handle:border-[rgb(var(--neon))] group-focus-visible/handle:bg-[rgb(var(--neon))] group-focus-visible/handle:shadow-[0_0_2px_rgb(var(--neon)/var(--neon-glow,1)),0_0_10px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1))),0_0_24px_rgb(var(--neon)/CALC(0.45*var(--neon-glow,1)))]",
              "group-data-[dragging]/handle:border-[rgb(var(--neon))] group-data-[dragging]/handle:bg-[rgb(var(--neon))] group-data-[dragging]/handle:shadow-[0_0_2px_rgb(var(--neon)/var(--neon-glow,1)),0_0_10px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1))),0_0_24px_rgb(var(--neon)/CALC(0.45*var(--neon-glow,1)))]",
            )}
          />
        )}
      </div>
    );
  },
);
ResizableHandle.displayName = "ResizableHandle";

export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
```
