Resizable panels whose divider leans toward its snap points instead of clamping to them. It parks while your pointer keeps moving, breaks free when you pull past, and can be caught mid-settle.
With and without
Two identical groups, one given `snapPoints` and one not. Drag both dividers. The magnetic one bends toward a tick as you approach, holds there while the cursor keeps going, then breaks free and races to catch up.
Retries carry a per-request budget instead of a fixed count, so a slow call cannot spend the whole window.
Retries carry a per-request budget instead of a fixed count, so a slow call cannot spend the whole window.
Basic
A sidebar snapping at 280 and 380, collapsible at 0. Each snap captures within 24px, and the 200px band below the minimum is crossed under your hand rather than jumped. The readout is written straight to the DOM from `onLayoutChanging`, which is the only correct way to use a callback that fires every frame.
IDE layout
Nested groups on both axes: a file tree beside a main pane, which is itself split into an editor and a terminal snapping at 30% and 60%. Each group writes its own custom properties, so the inner one never sizes itself from the outer one's numbers.
Collapsible sidebar
A header button collapsing and restoring the panel through the same spring the drag settles with, driven by the group's imperative handle. `onCollapse` and `onExpand` keep the button's own label honest however the panel got there.
Collapse it with the button, then drag the divider back out. Both cross the dead band below the minimum width under your hand rather than jumping across it.
Install
Install the packages this component imports.
bun add motionAlso requires lib/utils.ts, lib/springs.ts (see Installation).
Source
Copy and paste the following code into your project.
"use client";
/**
* SnapPanels — resizable panels whose divider leans toward its snap points
* instead of clamping to them. This is furniture: IDE layouts, dashboards,
* editors, mail clients, anything with a sidebar.
*
* Drag the divider and it bends toward the nearest snap as you get close,
* parks there while your pointer keeps moving, then breaks free when you pull
* past it. Let go in between and it settles toward wherever the release was
* headed, carrying the speed you released at, and you can catch it mid-settle.
*
* ## The magnetic map
*
* The divider's rendered position is not the pointer position. For a raw
* offset `d` from the nearest snap, inside that snap's capture radius `R`:
*
* rendered(d) = d * (1 - s * w(|d| / R)) w(t) = (1 - t^2)^2, s = 0.85
*
* `w` is 1 at the snap and 0 at the radius edge, with zero slope at the edge.
* That second property is the one that matters: it makes the boundary of the
* field invisible, so entering and leaving it has no felt step. Without it the
* field has walls and reads as a dead zone rather than an attraction.
*
* Differentiating gives gain `g'(u) = 1 - s(1 - u^2)(1 - 5u^2)` for `u = d/R`,
* and every property this component sells falls out of that one line:
*
* - `(1-u^2)(1-5u^2)` is largest at `u = 0`, so the *minimum* gain is `1 - s`
* = 0.15x and it sits exactly on the snap. Parked, not frozen. Tracking
* never stops, which is the difference between a magnet and a dead zone,
* and it is why `SNAP_STRENGTH` must stay below 1.
* - Minimum gain above zero also means the map is monotonic: the divider
* never moves backwards while the pointer moves forwards. The obvious
* alternative, subtracting a gaussian, is not monotonic for useful
* amplitudes and reverses mid-drag. That reads as a bug.
* - It is smallest at `u^2 = 3/5`, so gain peaks at `1 + 0.8s` = 1.68x, 77.5%
* of the way out. The break-free acceleration is the same curve. No second
* animation, no threshold, no extra state, and `s` tunes the parked
* stiffness without moving where the peak is.
*
* ## Fields never overlap
*
* Two snaps closer together than their radii would produce a midpoint where
* the nearest-snap target flips, and the divider would jump the gap between
* the two fields. So adjacent radii that would overlap are shrunk
* proportionally to their own size. For two ordinary snaps that is exactly
* half the gap each; it is stated as a proportion because the collapse band
* below is a field with a very different radius and deserves its share.
*
* Snaps are deduplicated first. `collapsible` contributes an implicit snap at
* `collapsedSize`, so a panel that is *also* handed `0` in `snapPoints` would
* otherwise have two snaps in one position, a neighbour gap of zero, and a
* capture radius of zero — the magnet silently missing from the one snap the
* component is named for.
*
* ## The collapse band
*
* A collapsible panel with `minSize` 200 and `collapsedSize` 0 has 200px
* between the two that no release may rest in. Every other implementation
* crosses that band by jumping, which is the move this component exists to
* refuse, so it does not get an exemption for its own hardest case. The band
* is a one-sided capture field with radius `minSize - collapsedSize`. Gain is
* 1 at `minSize`, so entry is continuous with free tracking; it peaks 77.5% of
* the way down and falls to 0.15x at the collapse, so the panel dives shut and
* then holds under a pointer that is still moving. The unusable widths turn
* into a range the divider crosses under your hand rather than one it
* teleports across.
*
* It is the only field whose far edge is not free space, so both of its ends
* are settle targets.
*
* ## Release
*
* The settle target comes from where the release is going, not from where it
* is: project the position forward by the release velocity and take the snap
* nearest the projection. Nearest-to-current is wrong at the one moment that
* matters — break free at 1.68x gain, let go while still inside the field, and
* nearest-to-current hauls the divider back onto the snap you spent the whole
* gesture escaping.
*
* The velocity is the *rendered* one, sampled from the divider's own positions
* rather than the pointer's. Near a snap the divider is moving at 0.15x the
* pointer, and seeding the spring with raw pointer velocity would fling it
* through the point it was parked on.
*
* Sampling is over an 80ms window, and reads zero after 100ms of a stationary
* pointer. Both numbers come from `list-detail-morph`, which paid for them
* first: one `pointermove` delta is not a velocity, since at 240Hz consecutive
* moves are ~4ms apart and three pixels of lift-off jitter reads as hundreds
* of px/s. A divider parked on a snap while someone reads the panel it sizes
* is the stale case exactly.
*
* Grabbing mid-settle stops the animation, anchors the raw position to the
* current rendered one — the map is monotonic, so no numerical inversion is
* needed — and seeds the sampler with the animation's velocity, so a catch and
* an immediate re-release keeps the throw instead of dropping it.
*
* ## State, keyboard, screen readers
*
* Sizes are px internally and reported back in whatever unit the panel was
* declared in, because sidebars are sized in pixels and content panes in
* percentages and forcing either unit on both makes half of real layouts
* wrong. There is no loading, error, or empty state: the panels are containers
* and whatever is inside them owns those.
*
* The handle is a `separator` with `tabIndex=0`, `aria-orientation` describing
* the separator's own geometry (a horizontal group is divided by a vertical
* line), `aria-controls` pointing at the panel it sizes, and
* `aria-valuenow` / `aria-valuemin` / `aria-valuemax` as percentages of the
* group — ARIA takes a number and a `Size` may be a pixel string. Arrows nudge
* 8px and `Shift`-arrows 1px, `PageUp` / `PageDown` jump snap to snap,
* `Home` / `End` go to the ends, `Enter` toggles collapse.
*
* Magnetism is pointer-only. Keyboard input is already discrete and precise,
* so bending it toward snaps would fight the user rather than help them.
* Departure from the component's own signature behaviour, named here because
* it is one.
*
* ## Motion
*
* One focal movement: the divider. The panels are consequence, the ticks are
* support. Every curve here is bespoke under the gesture-and-physics exemption
* in `@/lib/springs`.
*
* - `SETTLE` is `{ duration: 0.3, bounce: 0.12 }`. Far less bounce than
* `elastic-slider`'s 0.35, and the gap between them is the point: a 4px
* overshoot on a 24px band is elastic, and the same 4px along the full
* height of a panel is sloppy.
* - Ticks are invisible at rest, faded in on grab and out on release with
* `spring.quick`, whose exit is already the faster half. A divider is seen
* constantly, and the chrome around a signature detail is not exempt from
* the constant tier, so the guides exist only while you are doing the thing
* they guide.
* - The mark holding the divider goes to `--foreground` and to full height
* from half. Scale, not height, so nothing reflows and the change is one
* composited property.
* - The marks sit in a band 12px clear of the divider's midpoint rather than
* on it. Centred, the mark the divider is captured by ends up underneath
* the grip at exactly the moment it matters, which is the moment it has to
* be visible. Offset, the grip reads as the thumb and the marks as the
* scale it travels along.
* - The held mark also draws a hairline back to the divider, spanning the
* distance the magnet is currently eating. This is the one moment the
* component looks broken to someone who does not know what it does: the
* pointer has moved 12px and the divider has moved 6, and without the line
* the missing 6 read as dropped input. With it they read as tension, and
* the line closes to nothing exactly when the divider arrives. Measured, so
* it is feedback rather than decoration, and it stays under reduced motion.
* - The handle carries a grip at rest: a 24px pill at 15% foreground,
* brightening on hover and again while held. A 1px rule is not an
* affordance, and the constant tier's almost-invisible rule is waived for a
* component's own signature detail, which for this one is the divider
* itself. The rest of the chrome still obeys it.
* - The handle's hover is colour only. Two pixels of width does not get
* clearer by lasting longer, and it cannot be made bigger without the
* handle becoming furniture in its own right.
* - `animateLayout` springs an incoming `layout` prop instead of setting it,
* on `spring.moderate` rather than a bespoke curve: a controlled change is
* not a gesture, so the exemption above does not cover it. Opt-in, because
* a consumer already driving `layout` from its own loop would otherwise get
* a spring chasing a spring. Panels are matched to their previous size by
* `id`, not by index, so a panel that was not there starts at zero and the
* others make room for it — the one case where nothing else in this file
* can produce the motion, since the panel count changes with it.
*
* Reduced motion keeps the magnetism, because it is feedback about where the
* snap is rather than decoration, and the component would lose its point
* without it. Releasing near a snap still lands on it. The settle becomes an
* instant set, since it animates size and every size animation is gated. Tick
* fades are opacity and survive untouched.
*
* ## Departures, and things measured rather than assumed
*
* - No push-through cascade. A handle resizes its two adjacent panels only.
* VS Code cascades; it roughly doubles the constraint solver and is reached
* for rarely enough that the complexity is not obviously bought.
* - The drag writes a CSS custom property per panel and sizes panels with
* `flex-basis: var(--tcn-panel-N)`, so React renders once on commit rather
* than once per frame. Driving `flex-basis` through React state reflows the
* whole subtree every frame: fine at two panels, janky at four, which is
* late enough to be missed in a demo and early enough to be hit in real use.
* - Custom properties inherit, so a group writes *all* of its indices on
* mount rather than only the one being dragged. A nested group that wrote
* lazily would leave its panels reading the outer group's `--tcn-panel-0`
* until the first drag and size itself from a number belonging to a
* different container.
* - Panels and handles must be direct children of the group. The group reads
* their props to build the constraint model before the first paint, which
* a registration effect cannot do without a visible reflow on mount.
* - `getBoundingClientRect()` on an unmounted or zero-width group returns 0,
* and a zero reaching the geometry divides the container width. The measure
* keeps the last non-zero value as its fallback.
* - `setPointerCapture` on the handle, `touch-action: none` or touch drags
* scroll the page, and `user-select: none` plus the resize cursor on
* `document.body` so both survive the pointer leaving the handle.
* - `dir="rtl"` mirrors a horizontal group: the drag axis inverts and a handle
* grows the opposite neighbour. The direction is read off the group at grab
* time rather than assumed.
*/
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
type ComponentPropsWithoutRef,
type CSSProperties,
type ReactElement,
type Ref,
} from "react";
import { animate, motion, useMotionValue, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { spring } from "@/lib/springs";
/** A percentage when it is a number, an exact pixel count when it is a string.
* The template literal type is what makes `"280px"` check and `"280"` not. */
type Size = number | `${number}px`;
type Direction = "horizontal" | "vertical";
/** The painted divider. The hit area is an absolutely positioned overlay, so
* widening the target never changes the layout arithmetic. */
const HANDLE_SIZE = 1;
/** Extra grab distance either side of the paint. */
const HANDLE_HIT = 5;
const DEFAULT_CAPTURE_RADIUS = 24;
/** Parked gain is `1 - SNAP_STRENGTH`. Exactly 1 would pin the divider and
* break the house rule that input is tracked while it is happening. */
const SNAP_STRENGTH = 0.85;
/** Bespoke, under the gesture-and-physics exemption. See the motion note. */
const SETTLE = { type: "spring", duration: 0.3, bounce: 0.12 } as const;
/** Velocity is averaged over this window, in ms, and discarded after this long
* without movement. Both from `list-detail-morph`. */
const VELOCITY_WINDOW = 80;
const VELOCITY_STALE = 100;
/** How far ahead a release is projected, in seconds — the first half of
* `SETTLE`'s response. Longer and a slow drift picks the snap after next. */
const RELEASE_PROJECTION = 0.12;
/** Keyboard nudge, and its fine-grained variant under `Shift`. */
const ARROW_STEP = 8;
const ARROW_FINE_STEP = 1;
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function panelVar(index: number) {
return `--tcn-panel-${index}`;
}
/** Set per mark, read by both that mark and its tension line. Holds the
* signed distance from the divider's current edge to the mark, so the two
* stay in step without a second per-frame write. */
const TICK_OFFSET = "--tcn-tick-offset";
/** `available` is the group minus its handles, so a percentage always means
* a share of the space panels can actually occupy. */
function resolveSize(size: Size | undefined, available: number, fallback: number): number {
if (size === undefined) return fallback;
if (typeof size === "number") return (size / 100) * available;
const parsed = Number.parseFloat(size);
return Number.isFinite(parsed) ? parsed : fallback;
}
function isPixelSize(size: Size | undefined) {
return typeof size === "string";
}
function toSize(px: number, available: number, pixelTyped: boolean): Size {
if (pixelTyped) return `${Math.round(px)}px` as Size;
return available > 0 ? Math.round((px / available) * 1e4) / 1e2 : 0;
}
/** `w(t) = (1 - t^2)^2`. 1 at the snap, 0 at the radius edge, and flat at the
* edge so the boundary of the field cannot be felt. */
function fieldWeight(t: number) {
const inverted = 1 - t * t;
return inverted * inverted;
}
interface SnapField {
/** Where the divider is drawn to, in the left/top panel's size space. */
pos: number;
radiusBelow: number;
radiusAbove: number;
/** Set only on a collapse band: the far end, which is a settle target too. */
edge?: number;
}
function fieldHolds(field: SnapField, value: number) {
return value >= field.pos - field.radiusBelow && value <= field.pos + field.radiusAbove;
}
/** The whole component, in four lines. Fields never overlap, so the first one
* holding the raw position is the only one that can. */
function magnetise(raw: number, fields: SnapField[]) {
for (const field of fields) {
const offset = raw - field.pos;
const radius = offset < 0 ? field.radiusBelow : field.radiusAbove;
if (radius <= 0 || Math.abs(offset) >= radius) continue;
return field.pos + offset * (1 - SNAP_STRENGTH * fieldWeight(Math.abs(offset) / radius));
}
return raw;
}
interface Band {
collapsed: number;
min: number;
}
function buildFields(input: {
lower: number;
upper: number;
captureRadius: number;
/** Snap positions already mapped into the left/top panel's size space. */
positions: number[];
bandStart?: Band;
bandEnd?: Band;
}): SnapField[] {
const { lower, upper, captureRadius } = input;
const fields: SnapField[] = [];
if (input.bandStart && input.bandStart.min > input.bandStart.collapsed) {
fields.push({
pos: input.bandStart.collapsed,
radiusBelow: 0,
radiusAbove: input.bandStart.min - input.bandStart.collapsed,
edge: input.bandStart.min,
});
}
if (input.bandEnd && input.bandEnd.min > input.bandEnd.collapsed) {
fields.push({
pos: input.bandEnd.collapsed,
radiusBelow: input.bandEnd.collapsed - input.bandEnd.min,
radiusAbove: 0,
edge: input.bandEnd.min,
});
}
for (const position of input.positions) {
if (position < lower - 0.5 || position > upper + 0.5) continue;
const inBand = fields.some(
(field) =>
field.edge !== undefined &&
position > Math.min(field.pos, field.edge) - 0.5 &&
position < Math.max(field.pos, field.edge) + 0.5,
);
if (inBand) continue;
if (fields.some((field) => Math.abs(field.pos - position) < 0.5)) continue;
fields.push({ pos: position, radiusBelow: captureRadius, radiusAbove: captureRadius });
}
fields.sort((a, b) => a.pos - b.pos);
for (const field of fields) {
field.radiusBelow = Math.min(field.radiusBelow, Math.max(0, field.pos - lower));
field.radiusAbove = Math.min(field.radiusAbove, Math.max(0, upper - field.pos));
}
// Shrink only what would actually overlap, and shrink each side in
// proportion to what it asked for. Two ordinary snaps end up with half the
// gap each; a collapse band keeps its share against a 24px neighbour.
for (let i = 0; i < fields.length - 1; i += 1) {
const a = fields[i]!;
const b = fields[i + 1]!;
const gap = b.pos - a.pos;
const wanted = a.radiusAbove + b.radiusBelow;
if (wanted > gap && wanted > 0) {
const scale = gap / wanted;
a.radiusAbove *= scale;
b.radiusBelow *= scale;
}
}
return fields;
}
function resolveRelease(input: {
rendered: number;
velocity: number;
fields: SnapField[];
lower: number;
upper: number;
}) {
const { rendered, velocity, fields, lower, upper } = input;
const projected = clamp(rendered + velocity * RELEASE_PROJECTION, lower, upper);
const nearerEnd = (field: SnapField) =>
Math.abs(projected - field.pos) <= Math.abs(projected - field.edge!) ? field.pos : field.edge!;
// A band is checked against where the divider *is*, because its interior is
// not a position anything is allowed to rest at.
const band = fields.find((field) => field.edge !== undefined && fieldHolds(field, rendered));
if (band) return nearerEnd(band);
const target = fields.find((field) => fieldHolds(field, projected));
// The projection chooses *among* snaps. It never becomes the resting place
// itself: a divider released between two snaps stays where it was let go,
// because a snap point is a preferred position and not a clamp, and a throw
// that carries on past the hand is the incumbent's behaviour, not ours.
if (!target) return clamp(rendered, lower, upper);
return target.edge !== undefined ? nearerEnd(target) : target.pos;
}
/** A mark on the divider, tied to the field that owns it. */
interface Tick {
pos: number;
field: number;
}
interface VelocitySample {
time: number;
position: number;
}
function createSampler() {
const samples: VelocitySample[] = [];
return {
/** Used when a gesture starts mid-flight, so catching a settle and letting
* go again keeps the throw rather than dropping it. */
seed(position: number, velocity: number) {
const now = performance.now();
samples.length = 0;
samples.push({ time: now - 16, position: position - velocity * 0.016 });
samples.push({ time: now, position });
},
push(position: number) {
const now = performance.now();
samples.push({ time: now, position });
while (samples.length > 2 && now - samples[0]!.time > VELOCITY_WINDOW) samples.shift();
},
read() {
const last = samples[samples.length - 1];
if (!last || samples.length < 2) return 0;
if (performance.now() - last.time > VELOCITY_STALE) return 0;
const first = samples.find((sample) => last.time - sample.time <= VELOCITY_WINDOW);
if (!first) return 0;
const elapsed = (last.time - first.time) / 1000;
if (elapsed <= 0) return 0;
return (last.position - first.position) / elapsed;
},
clear() {
samples.length = 0;
},
};
}
interface PanelSpec {
id: string;
defaultSize?: Size;
minSize?: Size;
maxSize?: Size;
snapPoints?: Size[];
collapsible?: boolean;
collapsedSize?: Size;
onCollapse?: () => void;
onExpand?: () => void;
}
interface ResolvedSpec {
id: string;
domId: string;
defaultSize: number | null;
min: number;
max: number;
snaps: number[];
collapsible: boolean;
collapsed: number;
pixelTyped: boolean;
onCollapse?: () => void;
onExpand?: () => void;
}
function resolveSpecs(specs: PanelSpec[], available: number, groupId: string): ResolvedSpec[] {
return specs.map((spec, index) => {
const collapsed = spec.collapsible ? resolveSize(spec.collapsedSize, available, 0) : 0;
const min = Math.max(0, resolveSize(spec.minSize, available, 0));
return {
id: spec.id,
domId: `${groupId}-panel-${spec.id || index}`,
defaultSize:
spec.defaultSize === undefined ? null : resolveSize(spec.defaultSize, available, 0),
min,
max: resolveSize(spec.maxSize, available, available),
snaps: (spec.snapPoints ?? []).map((snap) => resolveSize(snap, available, 0)),
collapsible: Boolean(spec.collapsible),
collapsed,
pixelTyped:
isPixelSize(spec.defaultSize) ||
isPixelSize(spec.minSize) ||
isPixelSize(spec.maxSize) ||
(spec.snapPoints ?? []).some(isPixelSize),
onCollapse: spec.onCollapse,
onExpand: spec.onExpand,
};
});
}
/** Hand every panel a size that sums to exactly the space available. Panels
* that declared nothing share what is left. */
function initialLayout(specs: ResolvedSpec[], available: number, declared?: Size[]): number[] {
const resolved = specs.map((spec, index) => {
const override = declared?.[index];
if (override !== undefined) return resolveSize(override, available, 0);
return spec.defaultSize;
});
const fixed = resolved.filter((size): size is number => size !== null);
const remainder = Math.max(0, available - fixed.reduce((total, size) => total + size, 0));
const flexibleCount = resolved.length - fixed.length;
const share = flexibleCount > 0 ? remainder / flexibleCount : 0;
const sizes = resolved.map((size, index) =>
clamp(size ?? share, specs[index]!.min, specs[index]!.max),
);
return normalise(sizes, specs, available);
}
/** Absorb a container resize. Percentage-typed panels take the change,
* because a sidebar declared at 280px meant 280px. */
function refitLayout(previous: number[], specs: ResolvedSpec[], available: number): number[] {
const total = previous.reduce((sum, size) => sum + size, 0);
const delta = available - total;
if (Math.abs(delta) < 0.5) return normalise(previous, specs, available);
const flexible = specs
.map((spec, index) => ({ index, weight: spec.pixelTyped ? 0 : previous[index]! }))
.filter((entry) => entry.weight > 0);
const pool =
flexible.length > 0 ? flexible : previous.map((size, index) => ({ index, weight: size }));
const weight = pool.reduce((sum, entry) => sum + entry.weight, 0);
const next = [...previous];
for (const entry of pool) {
const share = weight > 0 ? entry.weight / weight : 1 / pool.length;
next[entry.index] = next[entry.index]! + delta * share;
}
return normalise(next, specs, available);
}
/** Clamp to every panel's own limits, then push the rounding error into
* whichever panel still has room for it. */
function normalise(sizes: number[], specs: ResolvedSpec[], available: number): number[] {
const next = sizes.map((size, index) => {
const spec = specs[index]!;
const lower = spec.collapsible ? Math.min(spec.collapsed, spec.min) : spec.min;
return clamp(size, lower, spec.max);
});
let drift = available - next.reduce((sum, size) => sum + size, 0);
for (let pass = 0; pass < 2 && Math.abs(drift) > 0.01; pass += 1) {
for (let index = next.length - 1; index >= 0 && Math.abs(drift) > 0.01; index -= 1) {
const spec = specs[index]!;
const lower = spec.collapsible ? Math.min(spec.collapsed, spec.min) : spec.min;
const room = drift > 0 ? spec.max - next[index]! : lower - next[index]!;
const applied =
drift > 0 ? Math.min(drift, Math.max(0, room)) : Math.max(drift, Math.min(0, room));
next[index] = next[index]! + applied;
drift -= applied;
}
}
return next;
}
interface GroupContextValue {
direction: Direction;
groupId: string;
groupRef: React.RefObject<HTMLDivElement | null>;
specsRef: React.RefObject<ResolvedSpec[]>;
sizesRef: React.RefObject<number[]>;
availableRef: React.RefObject<number>;
captureRadius: number;
reduceMotion: boolean;
measured: boolean;
interactingRef: React.RefObject<boolean>;
write: (sizes: number[], changing: boolean) => void;
commit: (sizes: number[]) => void;
}
const GroupContext = createContext<GroupContextValue | null>(null);
const PanelIndexContext = createContext(0);
const HandleIndexContext = createContext(0);
function useGroup(component: string) {
const context = useContext(GroupContext);
if (!context) throw new Error(`${component} must be rendered inside a SnapPanelGroup.`);
return context;
}
interface SnapPanelGroupHandle {
/** Sizes in each panel's own declared unit. */
getLayout: () => Size[];
collapse: (id: string) => void;
expand: (id: string) => void;
toggle: (id: string) => void;
}
interface SnapPanelGroupProps extends Omit<ComponentPropsWithoutRef<"div">, "onChange"> {
direction: Direction;
layout?: Size[];
/** Spring an incoming `layout` toward its new value rather than setting it.
* Off by default; see the motion note. */
animateLayout?: boolean;
defaultLayout?: Size[];
onLayoutChange?: (sizes: Size[]) => void;
onLayoutChanging?: (sizes: Size[]) => void;
captureRadius?: number;
ref?: Ref<SnapPanelGroupHandle>;
}
function SnapPanelGroup({
direction,
layout,
animateLayout = false,
defaultLayout,
onLayoutChange,
onLayoutChanging,
captureRadius = DEFAULT_CAPTURE_RADIUS,
className,
children,
ref,
...props
}: SnapPanelGroupProps) {
const groupId = useId().replace(/:/g, "");
const groupRef = useRef<HTMLDivElement>(null);
const reduceMotion = Boolean(useReducedMotion());
const items = Children.toArray(children).filter((child): child is ReactElement =>
isValidElement(child),
);
const rawSpecs: PanelSpec[] = [];
const slots: { kind: "panel" | "handle" | "other"; index: number; child: ReactElement }[] = [];
for (const child of items) {
if (child.type === SnapPanel) {
const panelProps = child.props as SnapPanelProps;
slots.push({ kind: "panel", index: rawSpecs.length, child });
rawSpecs.push({
id: panelProps.id ?? String(rawSpecs.length),
defaultSize: panelProps.defaultSize,
minSize: panelProps.minSize,
maxSize: panelProps.maxSize,
snapPoints: panelProps.snapPoints,
collapsible: panelProps.collapsible,
collapsedSize: panelProps.collapsedSize,
onCollapse: panelProps.onCollapse,
onExpand: panelProps.onExpand,
});
} else if (child.type === SnapPanelHandle) {
slots.push({ kind: "handle", index: rawSpecs.length - 1, child });
} else {
slots.push({ kind: "other", index: -1, child });
}
}
const handleCount = slots.filter((slot) => slot.kind === "handle").length;
const panelCount = rawSpecs.length;
// Specs are read off the children on every render, so they need a value
// identity that effects can depend on without re-running each time.
const specKey = JSON.stringify(rawSpecs.map(({ onCollapse: _c, onExpand: _e, ...rest }) => rest));
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `rawSpecs` is rebuilt from the children on every render, so the serialised constraints are the only stable identity the effects below can depend on.
const specsMemo = useMemo(() => rawSpecs, [specKey]);
const [available, setAvailable] = useState(0);
const [sizes, setSizes] = useState<number[] | null>(null);
const resolvedSpecs = useMemo(
() => resolveSpecs(specsMemo, available, groupId),
[specsMemo, available, groupId],
);
const specsRef = useRef<ResolvedSpec[]>(resolvedSpecs);
specsRef.current = resolvedSpecs;
// Callbacks live behind refs so the drag loop never closes over a stale one.
const callbacksRef = useRef({ onLayoutChange, onLayoutChanging });
callbacksRef.current = { onLayoutChange, onLayoutChanging };
const sizesRef = useRef<number[]>([]);
const availableRef = useRef(0);
availableRef.current = available;
const interactingRef = useRef(false);
const collapsedRef = useRef<boolean[]>([]);
/** The running controlled-layout spring, and the panel ids `sizesRef` is
* currently indexed by. Separate from `interactingRef`: that one blocks an
* incoming `layout`, and this animation has to stay re-targetable by one. */
const layoutRunRef = useRef<{ stop: () => void } | null>(null);
const layoutIdsRef = useRef<string[]>([]);
/** What the running spring is aimed at. `layout` is a fresh array on every
* render of the consumer, so the effect below re-runs constantly and has to
* compare targets by value or it restarts its own animation mid-flight. */
const layoutAimRef = useRef<number[] | null>(null);
const toSizes = useCallback(
(values: number[]) =>
values.map((value, index) =>
toSize(value, availableRef.current, specsRef.current[index]?.pixelTyped ?? false),
),
[],
);
const write = useCallback(
(values: number[], changing: boolean) => {
const group = groupRef.current;
sizesRef.current = values;
if (group) {
// Every index, every time. A nested group that wrote only the panel
// being dragged would leave its siblings inheriting the outer group's.
for (let index = 0; index < values.length; index += 1) {
group.style.setProperty(panelVar(index), `${values[index]!.toFixed(2)}px`);
}
}
if (changing) callbacksRef.current.onLayoutChanging?.(toSizes(values));
},
[toSizes],
);
const commit = useCallback(
(values: number[]) => {
write(values, false);
setSizes(values);
callbacksRef.current.onLayoutChange?.(toSizes(values));
const specs = specsRef.current;
const previous = collapsedRef.current;
const next = values.map((value, index) => {
const spec = specs[index];
return Boolean(spec?.collapsible) && value <= (spec?.collapsed ?? 0) + 0.5;
});
collapsedRef.current = next;
next.forEach((isCollapsed, index) => {
if (previous[index] === isCollapsed) return;
if (isCollapsed) specs[index]?.onCollapse?.();
else if (previous[index] !== undefined) specs[index]?.onExpand?.();
});
},
[toSizes, write],
);
// Measure in a layout effect and keep the last non-zero value: a new child
// has one blind frame before any observer fires, and a zero here would
// divide the container width.
useIsomorphicLayoutEffect(() => {
const group = groupRef.current;
if (!group) return;
const measure = () => {
const rect = group.getBoundingClientRect();
const span = direction === "horizontal" ? rect.width : rect.height;
const next = Math.max(0, span - handleCount * HANDLE_SIZE);
if (next <= 0) return;
setAvailable((current) => (Math.abs(current - next) < 0.5 ? current : next));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(group);
return () => observer.disconnect();
}, [direction, handleCount]);
useIsomorphicLayoutEffect(() => {
if (available <= 0 || panelCount === 0) return;
setSizes((previous) => {
if (!previous || previous.length !== panelCount) {
return initialLayout(resolvedSpecs, available, layout ?? defaultLayout);
}
const next = refitLayout(previous, resolvedSpecs, available);
return next.every((size, index) => Math.abs(size - previous[index]!) < 0.01)
? previous
: next;
});
}, [available, panelCount, resolvedSpecs]);
// Controlled layout. The drag owns the custom properties until it lets go,
// so an incoming prop mid-gesture is deliberately ignored.
useIsomorphicLayoutEffect(() => {
if (!layout || available <= 0 || interactingRef.current) return;
const target = normalise(
layout.map((size) => resolveSize(size, available, 0)),
resolvedSpecs,
available,
);
const ids = resolvedSpecs.map((spec) => spec.id);
const aim = layoutAimRef.current;
if (
layoutRunRef.current &&
aim !== null &&
aim.length === target.length &&
target.every((value, i) => Math.abs(value - aim[i]!) < 0.5)
) {
return;
}
const settle = (values: number[]) => {
layoutRunRef.current?.stop();
layoutRunRef.current = null;
layoutAimRef.current = null;
setSizes(values);
};
// Where each panel is coming from, by id. `sizesRef` is still the previous
// frame here: the effect that writes new state runs after this one.
const previous = new Map(layoutIdsRef.current.map((id, i) => [id, sizesRef.current[i] ?? 0]));
const from = ids.map((id) => previous.get(id) ?? 0);
const fromTotal = from.reduce((sum, value) => sum + value, 0);
// From here on `write` is ordered by `ids`, including every frame of the
// spring below. A re-target that arrives mid-flight reads the live sizes
// through this, so a panel already growing is not sent back to zero.
layoutIdsRef.current = ids;
// Fractions, not pixels: a panel count change moves `available` by a
// handle's width, and a removed panel leaves its share unclaimed.
const start = fromTotal > 0 ? from.map((value) => (value * available) / fromTotal) : target;
const moved = target.some((value, i) => Math.abs(value - start[i]!) > 0.5);
if (!animateLayout || reduceMotion || fromTotal <= 0 || !moved) {
settle(target);
return;
}
// One spring for the whole vector so every panel shares a curve, re-aimed
// from wherever the last one had reached.
layoutRunRef.current?.stop();
layoutAimRef.current = target;
layoutRunRef.current = animate(0, 1, {
...spring.moderate.enter,
onUpdate: (t) =>
write(
start.map((value, i) => value + (target[i]! - value) * t),
true,
),
onComplete: () => settle(target),
});
}, [layout, available, resolvedSpecs, animateLayout, reduceMotion, write]);
useEffect(() => () => layoutRunRef.current?.stop(), []);
useIsomorphicLayoutEffect(() => {
if (!sizes || interactingRef.current || layoutRunRef.current) return;
write(sizes, false);
if (collapsedRef.current.length === 0) {
collapsedRef.current = sizes.map((size, index) => {
const spec = resolvedSpecs[index];
return Boolean(spec?.collapsible) && size <= (spec?.collapsed ?? 0) + 0.5;
});
}
}, [sizes, write, resolvedSpecs]);
/** Programmatic collapse runs through the same spring as the drag, so the
* button and the gesture produce identical motion. */
const applyCollapse = useCallback(
(id: string, mode: "collapse" | "expand" | "toggle") => {
const specs = specsRef.current;
const index = specs.findIndex((spec) => spec.id === id);
const current = sizesRef.current;
if (index === -1 || current.length === 0) return;
const spec = specs[index]!;
if (!spec.collapsible) return;
const partner = index + 1 < current.length ? index + 1 : index - 1;
if (partner < 0) return;
const isCollapsed = current[index]! <= spec.collapsed + 0.5;
const collapsing = mode === "toggle" ? !isCollapsed : mode === "collapse";
const restored = spec.defaultSize ?? Math.max(spec.min, spec.collapsed);
const target = collapsing ? spec.collapsed : Math.max(spec.min, restored);
const total = current[index]! + current[partner]!;
const next = [...current];
const settle = (value: number) => {
next[index] = clamp(value, 0, total);
next[partner] = total - next[index]!;
write([...next], true);
};
if (reduceMotion) {
settle(target);
commit([...sizesRef.current]);
return;
}
interactingRef.current = true;
animate(current[index]!, target, {
...SETTLE,
onUpdate: settle,
onComplete: () => {
interactingRef.current = false;
commit([...sizesRef.current]);
},
});
},
[commit, reduceMotion, write],
);
useImperativeHandle(
ref,
() => ({
getLayout: () => toSizes(sizesRef.current),
collapse: (id) => applyCollapse(id, "collapse"),
expand: (id) => applyCollapse(id, "expand"),
toggle: (id) => applyCollapse(id, "toggle"),
}),
[applyCollapse, toSizes],
);
const context: GroupContextValue = {
direction,
groupId,
groupRef,
specsRef,
sizesRef,
availableRef,
captureRadius,
reduceMotion,
measured: sizes !== null,
interactingRef,
write,
commit,
};
return (
<GroupContext.Provider value={context}>
<div
ref={groupRef}
data-slot="snap-panel-group"
data-direction={direction}
className={cn(
"relative flex h-full w-full overflow-hidden",
direction === "horizontal" ? "flex-row" : "flex-col",
className,
)}
{...props}
>
{slots.map((slot, position) => {
if (slot.kind === "panel") {
return (
<PanelIndexContext.Provider key={position} value={slot.index}>
{slot.child}
</PanelIndexContext.Provider>
);
}
if (slot.kind === "handle") {
return (
<HandleIndexContext.Provider key={position} value={slot.index}>
{slot.child}
</HandleIndexContext.Provider>
);
}
return slot.child;
})}
</div>
</GroupContext.Provider>
);
}
interface SnapPanelProps extends Omit<ComponentPropsWithoutRef<"div">, "id"> {
/** Used for `aria-controls`, the imperative handle, and layout callbacks. */
id?: string;
defaultSize?: Size;
minSize?: Size;
maxSize?: Size;
/** Positions this panel's own edge is drawn to, in this panel's size terms. */
snapPoints?: Size[];
collapsible?: boolean;
collapsedSize?: Size;
onCollapse?: () => void;
onExpand?: () => void;
}
function SnapPanel({
id,
defaultSize,
minSize: _minSize,
maxSize: _maxSize,
snapPoints: _snapPoints,
collapsible: _collapsible,
collapsedSize: _collapsedSize,
onCollapse: _onCollapse,
onExpand: _onExpand,
className,
style,
children,
...props
}: SnapPanelProps) {
const { direction, measured, specsRef } = useGroup("SnapPanel");
const index = useContext(PanelIndexContext);
const spec = specsRef.current[index];
// Before the first measure the declared size is the fallback in the `var()`,
// so the first paint is already the right shape rather than N equal panels.
const fallback =
defaultSize === undefined
? "0px"
: typeof defaultSize === "number"
? `${defaultSize}%`
: defaultSize;
const basis = `var(${panelVar(index)}, ${fallback})`;
return (
<div
id={spec?.domId}
data-slot="snap-panel"
data-panel-id={id}
className={cn(
"relative overflow-hidden",
direction === "horizontal" ? "h-full" : "w-full",
className,
)}
style={{
flexBasis: basis,
flexGrow: measured || defaultSize !== undefined ? 0 : 1,
flexShrink: measured ? 0 : 1,
minWidth: 0,
minHeight: 0,
...style,
}}
{...props}
>
{children}
</div>
);
}
interface SnapPanelHandleProps extends ComponentPropsWithoutRef<"div"> {
disabled?: boolean;
"aria-label"?: string;
}
function SnapPanelHandle({
disabled = false,
className,
children,
style,
...props
}: SnapPanelHandleProps) {
const group = useGroup("SnapPanelHandle");
const index = useContext(HandleIndexContext);
const { direction, reduceMotion } = group;
const horizontal = direction === "horizontal";
const handleRef = useRef<HTMLDivElement>(null);
const tickListRef = useRef<HTMLDivElement>(null);
const size = useMotionValue(0);
const animationRef = useRef<{ stop: () => void } | null>(null);
const samplerRef = useRef(createSampler());
const capturedRef = useRef(-1);
const cycleRef = useRef(1);
const [dragging, setDragging] = useState(false);
const [ticks, setTicks] = useState<Tick[]>([]);
const [aria, setAria] = useState({ now: 0, min: 0, max: 100 });
/** Everything a gesture needs about this handle's two panels, in the
* left/top panel's size space. */
const describe = useCallback(() => {
const specs = group.specsRef.current;
const sizes = group.sizesRef.current;
const first = specs[index];
const second = specs[index + 1];
if (!first || !second || sizes.length === 0) return null;
const total = sizes[index]! + sizes[index + 1]!;
const lowerFirst = first.collapsible ? Math.min(first.collapsed, first.min) : first.min;
const lowerSecond = second.collapsible ? Math.min(second.collapsed, second.min) : second.min;
const lower = clamp(Math.max(lowerFirst, total - second.max), 0, total);
const upper = clamp(Math.min(first.max, total - lowerSecond), lower, total);
const positions = [...first.snaps, ...second.snaps.map((snap) => total - snap)];
const fields = buildFields({
lower,
upper,
captureRadius: group.captureRadius,
positions,
bandStart:
first.collapsible && first.min > first.collapsed
? { collapsed: first.collapsed, min: first.min }
: undefined,
bandEnd:
second.collapsible && second.min > second.collapsed
? { collapsed: total - second.collapsed, min: total - second.min }
: undefined,
});
return { specs, sizes, first, second, total, lower, upper, fields };
}, [group, index]);
const apply = useCallback(
(value: number, changing: boolean) => {
const sizes = [...group.sizesRef.current];
const total = sizes[index]! + sizes[index + 1]!;
const next = clamp(value, 0, total);
sizes[index] = next;
sizes[index + 1] = total - next;
group.write(sizes, changing);
const handle = handleRef.current;
// The ticks sit at fixed group positions while the handle moves, so the
// handle publishes its own origin and each tick subtracts it in CSS.
if (handle) handle.style.setProperty("--tcn-handle-origin", `${next.toFixed(2)}px`);
},
[group, index],
);
const publishAria = useCallback(() => {
const model = describe();
const available = group.availableRef.current;
if (!model || available <= 0) return;
const percent = (value: number) => Math.round((value / available) * 1000) / 10;
setAria({
now: percent(model.sizes[index]!),
min: percent(model.lower),
max: percent(model.upper),
});
}, [describe, group, index]);
useEffect(() => {
publishAria();
}, [publishAria, group.measured]);
const settleTo = useCallback(
(target: number, velocity: number) => {
const from = group.sizesRef.current[index]!;
if (reduceMotion) {
apply(target, true);
group.interactingRef.current = false;
group.commit([...group.sizesRef.current]);
publishAria();
return;
}
group.interactingRef.current = true;
size.jump(from);
const controls = animate(size, target, {
...SETTLE,
velocity,
onUpdate: (value) => apply(value, true),
onComplete: () => {
animationRef.current = null;
group.interactingRef.current = false;
group.commit([...group.sizesRef.current]);
publishAria();
},
});
animationRef.current = controls;
},
[apply, group, index, publishAria, reduceMotion, size],
);
/** Which mark the divider is currently held by. A collapse band puts two
* marks on screen for one field, so this has to resolve to a *tick* index:
* indexing the field list instead lights the wrong mark from the band on. */
const highlightCaptured = useCallback((fields: SnapField[], marks: Tick[], value: number) => {
const list = tickListRef.current;
if (!list) return;
const field = fields.findIndex((entry) => fieldHolds(entry, value));
let held = -1;
let nearest = Number.POSITIVE_INFINITY;
marks.forEach((mark, order) => {
if (mark.field !== field) return;
const distance = Math.abs(mark.pos - value);
if (distance >= nearest) return;
nearest = distance;
held = order;
});
if (held === capturedRef.current) return;
capturedRef.current = held;
// Direct DOM, deliberately: this changes only when the divider crosses a
// field boundary, and routing it through state would re-render the tree
// the CSS custom property exists to keep still.
Array.from(list.children).forEach((node, order) => {
(node as HTMLElement).dataset.captured = String(order === held);
});
}, []);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (disabled || event.button !== 0) return;
const model = describe();
const groupEl = group.groupRef.current;
if (!model || !groupEl) return;
animationRef.current?.stop();
animationRef.current = null;
group.interactingRef.current = true;
const inFlight = size.getVelocity();
const startRendered = model.sizes[index]!;
samplerRef.current.seed(startRendered, inFlight);
const rtl = horizontal && getComputedStyle(groupEl).direction === "rtl";
const axisSign = rtl ? -1 : 1;
const origin = horizontal ? event.clientX : event.clientY;
// The map is monotonic, so re-grabbing anchors raw to the current rendered
// position with no inversion — and a re-grab while parked lands the pointer
// back inside that snap's field.
let raw = startRendered;
const fields = model.fields;
const marks: Tick[] = fields.flatMap((field, order) =>
field.edge === undefined
? [{ pos: field.pos, field: order }]
: [
{ pos: field.pos, field: order },
{ pos: field.edge, field: order },
],
);
setTicks(marks);
setDragging(true);
capturedRef.current = -1;
event.currentTarget.setPointerCapture(event.pointerId);
const previousCursor = document.body.style.cursor;
const previousSelect = document.body.style.userSelect;
document.body.style.cursor = horizontal ? "col-resize" : "row-resize";
document.body.style.userSelect = "none";
const move = (moveEvent: PointerEvent) => {
const point = horizontal ? moveEvent.clientX : moveEvent.clientY;
raw = clamp(startRendered + (point - origin) * axisSign, model.lower, model.upper);
const rendered = magnetise(raw, fields);
apply(rendered, true);
samplerRef.current.push(rendered);
highlightCaptured(fields, marks, rendered);
};
const release = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousSelect;
setDragging(false);
capturedRef.current = -1;
const rendered = group.sizesRef.current[index]!;
const velocity = samplerRef.current.read();
samplerRef.current.clear();
const target = resolveRelease({
rendered,
velocity,
fields,
lower: model.lower,
upper: model.upper,
});
if (Math.abs(target - rendered) < 0.5) {
group.interactingRef.current = false;
group.commit([...group.sizesRef.current]);
publishAria();
return;
}
settleTo(target, velocity);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
};
const step = (delta: number) => {
const model = describe();
if (!model) return;
settleTo(clamp(model.sizes[index]! + delta, model.lower, model.upper), 0);
};
const toSnap = (way: 1 | -1) => {
const model = describe();
if (!model) return;
const current = model.sizes[index]!;
const stops = [
model.lower,
model.upper,
...model.fields.flatMap((field) =>
field.edge === undefined ? [field.pos] : [field.pos, field.edge],
),
].toSorted((a, b) => a - b);
const next =
way === 1
? stops.find((stop) => stop > current + 0.5)
: stops.toReversed().find((stop) => stop < current - 0.5);
if (next === undefined) return;
settleTo(clamp(next, model.lower, model.upper), 0);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const groupEl = group.groupRef.current;
const rtl = horizontal && groupEl ? getComputedStyle(groupEl).direction === "rtl" : false;
const nudge = event.shiftKey ? ARROW_FINE_STEP : ARROW_STEP;
const back = horizontal ? "ArrowLeft" : "ArrowUp";
const forward = horizontal ? "ArrowRight" : "ArrowDown";
switch (event.key) {
case back:
step(-nudge * (rtl ? -1 : 1));
break;
case forward:
step(nudge * (rtl ? -1 : 1));
break;
case "PageUp":
toSnap(-1);
break;
case "PageDown":
toSnap(1);
break;
case "Home": {
const model = describe();
if (model) settleTo(model.lower, 0);
break;
}
case "End": {
const model = describe();
if (model) settleTo(model.upper, 0);
break;
}
case "Enter": {
const model = describe();
if (!model) return;
const collapsible = model.first.collapsible
? { target: model.first.collapsed, restore: model.first.defaultSize ?? model.first.min }
: model.second.collapsible
? {
target: model.total - model.second.collapsed,
restore: model.total - (model.second.defaultSize ?? model.second.min),
}
: null;
if (!collapsible) return;
const current = model.sizes[index]!;
const atCollapse = Math.abs(current - collapsible.target) < 0.5;
settleTo(
clamp(atCollapse ? collapsible.restore : collapsible.target, model.lower, model.upper),
0,
);
break;
}
default:
return;
}
event.preventDefault();
};
/** Double-click steps to the next snap and reverses at the last one. A wrap
* would teleport the divider the full width, which is the jump this
* component exists to remove. */
const onDoubleClick = () => {
if (disabled) return;
const model = describe();
if (!model) return;
const stops = model.fields
.flatMap((field) => (field.edge === undefined ? [field.pos] : [field.pos, field.edge]))
.toSorted((a, b) => a - b);
if (stops.length === 0) return;
const current = model.sizes[index]!;
let next =
cycleRef.current === 1
? stops.find((stop) => stop > current + 0.5)
: stops.toReversed().find((stop) => stop < current - 0.5);
if (next === undefined) {
cycleRef.current = cycleRef.current === 1 ? -1 : 1;
next =
cycleRef.current === 1
? stops.find((stop) => stop > current + 0.5)
: stops.toReversed().find((stop) => stop < current - 0.5);
}
if (next === undefined) return;
settleTo(clamp(next, model.lower, model.upper), 0);
};
return (
<div
ref={handleRef}
data-slot="snap-panel-handle"
data-dragging={dragging || undefined}
role="separator"
tabIndex={disabled ? -1 : 0}
aria-orientation={horizontal ? "vertical" : "horizontal"}
aria-controls={group.specsRef.current[index]?.domId}
aria-valuenow={aria.now}
aria-valuemin={aria.min}
aria-valuemax={aria.max}
aria-disabled={disabled || undefined}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
onDoubleClick={onDoubleClick}
className={cn(
"group/handle relative z-10 shrink-0 grow-0 bg-border outline-none",
"transition-colors duration-fast",
"hover:bg-muted-foreground/40 data-dragging:bg-muted-foreground/60",
"focus-visible:bg-ring focus-visible:ring-2 focus-visible:ring-ring/50",
disabled && "pointer-events-none",
className,
)}
style={{ flexBasis: HANDLE_SIZE, ...style }}
{...props}
>
<div
aria-hidden
className={cn(
"absolute touch-none",
horizontal ? "inset-y-0 cursor-col-resize" : "inset-x-0 cursor-row-resize",
disabled && "cursor-default",
)}
style={
horizontal
? { left: -HANDLE_HIT, right: -HANDLE_HIT }
: { top: -HANDLE_HIT, bottom: -HANDLE_HIT }
}
/>
{/* The grip. A 1px rule is not an affordance, and this component is
nothing but the rule, so it gets one that is visible at rest.
`docs/design-system.md` files constantly-seen chrome under
almost-invisible and exempts a component's own signature detail;
the divider is this one's. It reads as the thumb the marks below
are a scale for. */}
<span
aria-hidden
className={cn(
"pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
"rounded-full bg-foreground/15 transition-[background-color,transform] duration-fast",
"group-hover/handle:bg-foreground/40 group-data-dragging/handle:bg-foreground/70",
horizontal
? "h-6 w-[3px] group-data-dragging/handle:scale-y-125"
: "h-[3px] w-6 group-data-dragging/handle:scale-x-125",
disabled && "opacity-40",
)}
/>
<div ref={tickListRef} aria-hidden className="pointer-events-none absolute inset-0">
{ticks.map((tick, order) => (
<motion.div
key={`${tick.pos}-${order}`}
data-captured="false"
initial={false}
animate={{ opacity: dragging ? 1 : 0 }}
transition={dragging ? spring.quick.enter : spring.quick.exit}
className="group/tick absolute inset-0"
style={
{
[TICK_OFFSET]: `calc(${tick.pos.toFixed(2)}px - var(--tcn-handle-origin, 0px))`,
} as CSSProperties
}
>
{/* The lag, drawn. While a field holds the divider the pointer
keeps going and the divider does not, and without this the
gap reads as dropped input. Spanning it makes the same gap
read as tension, and it closes to nothing at the mark. */}
<span
className={cn(
"absolute bg-foreground/40 opacity-0 transition-opacity duration-fast",
"group-data-[captured=true]/tick:opacity-100",
horizontal ? "top-[calc(50%_+_20px)] h-px" : "left-[calc(50%_+_20px)] w-px",
)}
style={
horizontal
? {
insetInlineStart: `min(var(${TICK_OFFSET}), 0px)`,
width: `max(var(${TICK_OFFSET}), calc(-1 * var(${TICK_OFFSET})))`,
}
: {
top: `min(var(${TICK_OFFSET}), 0px)`,
height: `max(var(${TICK_OFFSET}), calc(-1 * var(${TICK_OFFSET})))`,
}
}
/>
<span
className={cn(
"absolute rounded-full bg-muted-foreground/40",
"transition-[transform,background-color] duration-fast",
"group-data-[captured=true]/tick:bg-foreground",
horizontal
? "top-[calc(50%_+_12px)] h-4 w-px scale-y-[0.5] group-data-[captured=true]/tick:scale-y-100"
: "left-[calc(50%_+_12px)] h-px w-4 scale-x-[0.5] group-data-[captured=true]/tick:scale-x-100",
)}
style={
horizontal
? { insetInlineStart: `var(${TICK_OFFSET})` }
: { top: `var(${TICK_OFFSET})` }
}
/>
</motion.div>
))}
</div>
{children}
</div>
);
}
export { SnapPanelGroup, SnapPanel, SnapPanelHandle };
export type {
Size,
SnapPanelGroupHandle,
SnapPanelGroupProps,
SnapPanelProps,
SnapPanelHandleProps,
};API Reference
SnapPanelGroupdirection | "horizontal" | "vertical" Required. Groups nest, so a vertical group inside a panel is the usual way to build an IDE layout. |
layout / defaultLayout | Size[] Controlled and uncontrolled starting sizes, one per panel, overriding each panel's own `defaultSize`. An incoming `layout` mid-drag is ignored, because the gesture owns the divider until it lets go. |
onLayoutChange | (sizes: Size[]) => void Fires once the settle lands. This is the one to persist on. There is no built-in localStorage: where layout is stored is the caller's decision and the wrong thing for a copy-paste component to assume. |
onLayoutChanging | (sizes: Size[]) => void Fires every frame of the drag. Setting React state in it hands back the per-frame render the CSS custom property exists to avoid — use it to mirror the drag somewhere else on the page, imperatively. |
captureRadius24 | number How far from a snap the magnet starts pulling, in pixels. Clamped per snap so two fields can never overlap; the collapse band sets its own radius from the panel's minimum size. |
ref | Ref<SnapPanelGroupHandle> Exposes `collapse(id)`, `expand(id)`, `toggle(id)`, and `getLayout()`. Programmatic collapse runs the same spring as a released drag. |
API Reference
SnapPanelidindex | string Names the panel for `aria-controls` and for the group's imperative handle. |
defaultSize / minSize / maxSize | Size A `Size` is a number, meaning a percentage, or a pixel string typed as `${number}px`. The template literal type is what makes "280px" check and "280" not. Sidebars are sized in pixels and content panes in percentages, and sizes come back in whatever unit they were declared in. |
snapPoints[] | Size[] Positions this panel's own edge is drawn to, in this panel's size terms, because that is how people think about them. The handle between two panels unions both sides' snaps. |
collapsible / collapsedSizefalse / 0 | boolean / Size Adds an implicit snap at `collapsedSize`, so it is never repeated in `snapPoints`. The band between it and `minSize` becomes one wide capture field: passable, but nothing rests there. |
onCollapse / onExpand | () => void Fire on commit when the panel crosses into or out of its collapsed size, however it got there. |
API Reference
SnapPanelHandledisabledfalse | boolean Freezes this divider. The panels either side keep their sizes. |
aria-label | string Names the separator. It is focusable: arrows nudge 8px and Shift-arrows 1px, PageUp and PageDown jump snap to snap, Home and End go to the ends, Enter toggles collapse. Keyboard input is deliberately not magnetic — it is already discrete, and bending it would fight the user. |
className / children | string / ReactNode The paint is a hairline; the hit area is an absolutely positioned overlay 5px wider either side, so widening the target never changes the layout arithmetic. |