← All components

Resizable

Panels split by a hairline that grows an ink grip when you reach for it.

FilesDrag the line, or focus it and use the arrows.
EditorHome and End jump to the limits.
Terminal

The idea

A handle moves only the boundary it sits on — the panel before it grows by exactly what the one after gives up, each held to its own limits — and sizes are percentages, so the split survives the window resizing. At rest the boundary is a hairline, because a split is structure, not a control asking to be used; point at it, focus it or drag it and a short ink grip grows out of the middle. Every handle is a focusable separator: arrows nudge it, Home and End take it to its limits.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Resizable — usage17 lines
import { Resizable, ResizableHandle, ResizablePanel } from "mihirmodi-ui";

// Sizes are percent. A handle moves only the boundary it sits on.
<Resizable direction="horizontal" onLayout={(sizes) => save("split", sizes)}>
  <ResizablePanel defaultSize={30} minSize={18} maxSize={45}>
    <Files />
  </ResizablePanel>
  <ResizableHandle label="Resize files" />
  <ResizablePanel minSize={40}>
    {/* Groups nest the other way. */}
    <Resizable direction="vertical">
      <ResizablePanel defaultSize={65}><Editor /></ResizablePanel>
      <ResizableHandle label="Resize terminal" />
      <ResizablePanel><Terminal /></ResizablePanel>
    </Resizable>
  </ResizablePanel>
</Resizable>

Props

PropNotes
direction"horizontal" | "vertical"Groups nest: a panel can hold a group the other way.
ResizablePanel defaultSizenumberPercent. Panels without one share what is left.
ResizablePanel minSize / maxSizenumberPercent limits the handle respects, by drag and by key.
ResizableHandle labelstringThe separator's accessible name.
onLayout(sizes: number[]) => voidAfter each drag or key press — persist the reader's split.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/layout/Resizable.tsx271 lines
"use client";

import {
  Children,
  createContext,
  isValidElement,
  useCallback,
  useContext,
  useId,
  useRef,
  useState,
  type KeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactElement,
  type ReactNode,
} from "react";

type Direction = "horizontal" | "vertical";

type Limits = { min: number; max: number };

type GroupState = {
  direction: Direction;
  sizes: number[];
  limits: Limits[];
  ids: string[];
  /** Move the boundary after panel `i` to make panel `i` this size (percent). */
  resize: (i: number, size: number) => void;
  begin: (i: number, e: ReactPointerEvent<HTMLElement>) => void;
  dragging: number | null;
};

const GroupContext = createContext<GroupState | null>(null);
const SlotContext = createContext<number>(-1);

export type ResizablePanelProps = {
  children?: ReactNode;
  /** Its starting share of the group, in percent. Panels without one split what is left. */
  defaultSize?: number;
  /** The smallest it may be dragged to, in percent. */
  minSize?: number;
  /** The largest, in percent. */
  maxSize?: number;
  /** A name for the panel; the handle before it is announced as resizing it. */
  label?: string;
  className?: string;
};

/** One pane of a `Resizable`. Its size is the group's to set. */
export function ResizablePanel({ children, className = "" }: ResizablePanelProps) {
  const g = useContext(GroupContext);
  const i = useContext(SlotContext);
  if (!g || i < 0) return null;
  return (
    <div
      id={g.ids[i]}
      data-panel=""
      className={`min-h-0 min-w-0 overflow-hidden ${className}`}
      style={{ flex: `${g.sizes[i]} 1 0px` }}
    >
      {children}
    </div>
  );
}

export type ResizableHandleProps = {
  /** The accessible name. Default "Resize". */
  label?: string;
  className?: string;
};

/**
 * The boundary between two panels. A hairline at rest; a short ink grip on
 * hover, on focus and while dragged, so it shows itself only when asked.
 */
export function ResizableHandle({ label, className = "" }: ResizableHandleProps) {
  const g = useContext(GroupContext);
  const i = useContext(SlotContext);
  if (!g || i < 0) return null;
  const horizontal = g.direction === "horizontal";
  const size = g.sizes[i];
  const { min, max } = g.limits[i];
  const active = g.dragging === i;

  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
    const step = e.shiftKey ? 10 : 2;
    const back = horizontal ? "ArrowLeft" : "ArrowUp";
    const fwd = horizontal ? "ArrowRight" : "ArrowDown";
    let next: number | null = null;
    if (e.key === back) next = size - step;
    else if (e.key === fwd) next = size + step;
    else if (e.key === "Home") next = min;
    else if (e.key === "End") next = max;
    if (next == null) return;
    e.preventDefault();
    g.resize(i, next);
  };

  return (
    <div
      role="separator"
      tabIndex={0}
      // The separator's orientation is the line's own: a horizontal group is
      // split by a vertical line.
      aria-orientation={horizontal ? "vertical" : "horizontal"}
      aria-valuenow={Math.round(size)}
      aria-valuemin={Math.round(min)}
      aria-valuemax={Math.round(max)}
      aria-controls={g.ids[i]}
      aria-label={label ?? "Resize"}
      onPointerDown={(e) => g.begin(i, e)}
      onKeyDown={onKey}
      data-active={active || undefined}
      className={`group relative z-10 flex shrink-0 touch-none select-none items-center justify-center ${
        horizontal ? "w-px cursor-col-resize" : "h-px cursor-row-resize"
      } ${className}`}
    >
      {/* The line itself, and a hit area twelve pixels wide around it. */}
      <span
        aria-hidden
        className={`absolute bg-gray-alpha-400 ${horizontal ? "inset-y-0 left-0 w-px" : "inset-x-0 top-0 h-px"}`}
      />
      <span aria-hidden className={`absolute ${horizontal ? "inset-y-0 -left-1.5 -right-1.5" : "inset-x-0 -bottom-1.5 -top-1.5"}`} />
      <span
        aria-hidden
        className={`relative rounded-full bg-ink opacity-0 transition-[opacity,transform] duration-150 ease-geist group-hover:opacity-100 group-focus-visible:opacity-100 group-data-[active]:opacity-100 motion-reduce:transition-opacity ${
          horizontal
            ? "h-8 w-[3px] scale-y-50 group-hover:scale-y-100 group-focus-visible:scale-y-100 group-data-[active]:scale-y-100"
            : "h-[3px] w-8 scale-x-50 group-hover:scale-x-100 group-focus-visible:scale-x-100 group-data-[active]:scale-x-100"
        }`}
      />
    </div>
  );
}

export type ResizableProps = {
  /** `ResizablePanel`s with a `ResizableHandle` between each pair. */
  children: ReactNode;
  direction?: Direction;
  /** Every settled layout, in percent — persist it to restore the reader's split. */
  onLayout?: (sizes: number[]) => void;
  className?: string;
};

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

/**
 * Two or more panels split by handles you can drag.
 *
 * A handle moves only the boundary it sits on: the panel before it grows by
 * exactly what the panel after it gives up, each held to its own minimum and
 * maximum, and nothing further along the row shifts. Sizes are percentages
 * and the panels are flex items weighted by them, so the split survives the
 * window being resized and nothing has to be measured until a drag begins.
 *
 * At rest the boundary is a hairline — the split is structure, not a control
 * shouting to be used. Point at it, focus it or drag it and a short ink grip
 * grows out of the line's middle, which says "this moves" exactly when that is
 * the question. Every handle is a focusable `separator` carrying the size of
 * the panel before it, so the arrows nudge it (Shift for bigger steps), Home
 * and End take it to its limits, and a screen reader hears the percentage.
 *
 *     <Resizable direction="horizontal">
 *       <ResizablePanel defaultSize={30} minSize={20}>Files</ResizablePanel>
 *       <ResizableHandle />
 *       <ResizablePanel>Editor</ResizablePanel>
 *     </Resizable>
 */
export function Resizable({ children, direction = "horizontal", onLayout, className = "" }: ResizableProps) {
  const base = useId();
  const root = useRef<HTMLDivElement>(null);
  const items = Children.toArray(children).filter(isValidElement) as ReactElement[];
  const panels = items.filter((c) => c.type === ResizablePanel) as ReactElement<ResizablePanelProps>[];
  const ids = panels.map((_, i) => `${base}-panel-${i}`);

  const limits: Limits[] = panels.map((p) => ({ min: p.props.minSize ?? 0, max: p.props.maxSize ?? 100 }));

  const [sizes, setSizes] = useState<number[]>(() => {
    const given = panels.map((p) => p.props.defaultSize);
    const taken = given.reduce<number>((a, s) => a + (s ?? 0), 0);
    const open = given.filter((s) => s == null).length;
    const share = open ? Math.max(0, 100 - taken) / open : 0;
    return given.map((s) => s ?? share);
  });
  const [dragging, setDragging] = useState<number | null>(null);
  const latest = useRef(sizes);
  latest.current = sizes;

  // Limits are rebuilt from props every render; the callback reads the latest.
  const bounds = useRef(limits);
  bounds.current = limits;

  const resize = useCallback((i: number, want: number) => {
    setSizes((prev) => {
      const a = prev[i];
      const b = prev[i + 1];
      if (a == null || b == null) return prev;
      const pair = a + b;
      const la = bounds.current[i];
      const lb = bounds.current[i + 1];
      // Both panels' limits bound the one boundary: A in its own range, and
      // B — which is whatever A leaves — in its.
      const lo = Math.max(la.min, pair - lb.max);
      const hi = Math.min(la.max, pair - lb.min);
      const nextA = clamp(want, lo, hi);
      if (Math.abs(nextA - a) < 0.01) return prev;
      const next = [...prev];
      next[i] = nextA;
      next[i + 1] = pair - nextA;
      return next;
    });
  }, []);

  const begin = (i: number, e: ReactPointerEvent<HTMLElement>) => {
    const el = root.current;
    if (!el || e.button !== 0) return;
    e.preventDefault();
    const handle = e.currentTarget;
    handle.focus({ preventScroll: true });
    handle.setPointerCapture(e.pointerId);
    const box = el.getBoundingClientRect();
    const span = direction === "horizontal" ? box.width : box.height;
    const from = direction === "horizontal" ? e.clientX : e.clientY;
    const start = latest.current[i];
    setDragging(i);
    const prevCursor = document.body.style.cursor;
    document.body.style.cursor = direction === "horizontal" ? "col-resize" : "row-resize";
    const move = (ev: PointerEvent) => {
      const d = ((direction === "horizontal" ? ev.clientX : ev.clientY) - from) / Math.max(1, span);
      resize(i, start + d * 100);
    };
    const up = () => {
      setDragging(null);
      document.body.style.cursor = prevCursor;
      handle.removeEventListener("pointermove", move);
      handle.removeEventListener("pointerup", up);
      handle.removeEventListener("pointercancel", up);
      onLayout?.(latest.current);
    };
    handle.addEventListener("pointermove", move);
    handle.addEventListener("pointerup", up);
    handle.addEventListener("pointercancel", up);
  };

  // Keyboard resizes settle on each press, so report them as they land.
  const resizeAndReport = (i: number, want: number) => {
    resize(i, want);
    if (onLayout) requestAnimationFrame(() => onLayout(latest.current));
  };

  let panel = -1;
  return (
    <GroupContext.Provider value={{ direction, sizes, limits, ids, resize: resizeAndReport, begin, dragging }}>
      <div
        ref={root}
        data-direction={direction}
        className={`flex h-full w-full ${direction === "horizontal" ? "flex-row" : "flex-col"} ${className}`}
      >
        {items.map((c, n) => {
          if (c.type === ResizablePanel) panel += 1;
          // A handle belongs to the panel before it.
          return (
            <SlotContext.Provider key={c.key ?? n} value={panel}>
              {c}
            </SlotContext.Provider>
          );
        })}
      </div>
    </GroupContext.Provider>
  );
}