ResizerATM
Wraps one container element and makes it resizable via draggable handles on
any subset of its sides and corners - either pixel-perfect (inline styles) or class-based
(the w-*/h-* utilities stay the source of truth). This documentation
site dogfoods it: the preview box of every CodeExample
carries resizer handles on all 8 positions.
On this page (13)
§Pixel-perfect
The default: one SE handle, sizes land as inline px on the wrapped element. Drag any handle, then open State to see data-width/data-height follow.
§All sides + corners
data-handles='all' places all 8 handles - every side AND every corner drags independently.
§Width only
data-axis='w' keeps the height owned by the content - the n/s handles are never placed, and corners drag width only.
§Keys move the edge
data-keys='edge': the arrow keys move the focused edge the way they point - ArrowLeft on the w handle grows the box, ArrowRight shrinks it - the window-splitter pattern Border Layout builds on. Without it ArrowRight/Up always grow.
§Split-pane divider
data-variant='divider': the e handle becomes a full-height strip on the list's edge - a thin line shows on hover, focus and drag, there is no outline while it resizes, and a double-click puts the column back to its authored width. With data-keys='edge' the arrow keys move the edge.
§Class-based sizing
data-resize-mode='classes' snaps to the sizing.css ladder: the element's class attribute IS the size (open State/Code while dragging to watch w-*/h-* swap).
§Controlled
data-resize-mode='controlled' writes NOTHING - the component only measures and fires resizer-resize, so the consumer owns the size (this is how the CodeExample preview toolbar uses it).
§Resizing a Sidebar
The same component on a real Sidebar - first with pixel sizes (inline styles the runtime writes), then class-based, where the size lives in w-*/h-* utility classes only:
§Sidebar - pixels
Every side + corner drags the panel; the size lands as inline px on the aside. data-state-name='default' declares the expanded state deliberately - that is the component's own opt-out from auto-collapse, so the rail shrinks continuously (labels clip first) instead of snapping to the icon dock. The width transition is suspended while dragging, so the box tracks the pointer exactly.
§Sidebar - classes
data-resize-mode='classes': dragging swaps w-N/h-N utilities on the aside - inspect the Code tab while you drag and the class attribute is the only thing that changes. data-state-name keeps the rail expanded at every ladder step (no icon-dock snap) - down to w-16 it just clips.
§States
The default state restores the size the markup was authored with (snapshotted at init); setState('default', { width, height }) applies an explicit px size instead, and getState().config always reports the live box plus the active mode. The wrapper mirrors the live size onto data-width/data-height, and external writes to those attributes are applied back - that is the loop the State tab uses.
Machine contract - verified against resizer.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
width | number | — | 320 | Live width in px, mirrored on data-width (drags and keyboard write it; panel edits apply back). |
height | number | — | 160 | Live height in px, mirrored on data-height (dropped while data-axis="w"). |
The action reset dispatches resizer-reset on the wrapper - equivalent to setState('default').
§CSS view file
Handle geometry, cursors and the drag feedback state live here; the sizing behavior is runtime-driven.
@layer components { /* -- Resizer -------------------------------------------------- Wraps ONE container element and adds draggable handles on any subset of its 8 positions (sides + corners). The wrapper is the positioning context; handles overhang the border box by design, so keep overflow visible. Sizing behavior (px vs w-/h- classes) is driven by the runtime - this sheet owns only the handle geometry, cursors and feedback states. */ .resizer { position: relative; /* block box hugging the wrapped element (its intrinsic width) so handles sit on the BOX's edges, not the container's; an explicit authored/ runtime width overrides this (e.g. the CodeExample toolbar writes px) */ width: fit-content; } /* The resizer owns resizing: a native CSS `resize` on the wrapped element (e.g. a resizable canvas) would double the affordances - the runtime neutralizes it inline; this is the authored-CSS fallback. */ .resizer[data-resize-mode] { resize: none; } .resizer-handle { position: absolute; z-index: 5; display: block; box-sizing: border-box; width: 0.375rem; height: 0.375rem; background: var(--background); border: 1px solid var(--primary); border-radius: var(--radius-sm); box-shadow: var(--shadow-2xs); touch-action: none; /* the pointer-drag is ours - no scroll/pinch steal */ opacity: 0.85; transition: opacity 120ms ease, background-color 120ms ease, transform 120ms ease; /* generous hit area around the small visible chip */ &::after { content: ''; position: absolute; inset: -0.5rem; } &:hover { opacity: 1; background: var(--primary); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; opacity: 1; } } /* drag in progress - the active handle (and the box) read as engaged */ .resizer[data-resizing] > .resizer-handle { opacity: 1; background: var(--primary); } .resizer[data-resizing] { outline: 1px dashed var(--ring); outline-offset: 2px; } /* the box must track the pointer 1:1 - a size TRANSITION on the wrapped element (e.g. the sidebar's `transition: width 200ms`) would ease toward the drag instead of following it, which reads as the handle lagging behind the mouse. Only during the drag; authored transitions resume after (trigger-toggle collapse animation stays intact) */ .resizer[data-resizing] > * { transition-duration: 0s; } /* -- side geometry: edge centers + corners, with the right cursor -------- */ .resizer-handle[data-handle='n'] { top: -0.1875rem; left: 50%; translate: -50% -50%; cursor: ns-resize; } .resizer-handle[data-handle='s'] { bottom: -0.1875rem; left: 50%; translate: -50% 50%; cursor: ns-resize; } .resizer-handle[data-handle='e'] { right: -0.1875rem; top: 50%; translate: 50% -50%; cursor: ew-resize; } .resizer-handle[data-handle='w'] { left: -0.1875rem; top: 50%; translate: -50% -50%; cursor: ew-resize; } .resizer-handle[data-handle='ne'] { top: -0.1875rem; right: -0.1875rem; translate: 50% -50%; cursor: nesw-resize; } .resizer-handle[data-handle='nw'] { top: -0.1875rem; left: -0.1875rem; translate: -50% -50%; cursor: nwse-resize; } .resizer-handle[data-handle='se'] { bottom: -0.1875rem; right: -0.1875rem; translate: 50% 50%; cursor: nwse-resize; } .resizer-handle[data-handle='sw'] { bottom: -0.1875rem; left: -0.1875rem; translate: -50% 50%; cursor: nesw-resize; } /* -- Divider: a split pane's edge (data-variant="divider") ---------------- The edge handles become full-length strips along the box - invisible until hovered, focused or dragged, then a thin line - and the box gets no dashed outline while it resizes. Corners keep their chip. */ .resizer[data-variant='divider'] { & > .resizer-handle:is([data-handle='e'], [data-handle='w'], [data-handle='n'], [data-handle='s']) { width: 0.5rem; height: 0.5rem; border: 0; border-radius: 0; background: none; box-shadow: none; opacity: 1; &::before { content: ''; position: absolute; inset: 0; margin: auto; background: var(--primary); opacity: 0; transition: opacity 120ms ease; } &::after { inset: 0; } &:hover::before, &:focus-visible::before { opacity: 0.6; } &:focus-visible { outline: none; } } & > .resizer-handle:is([data-handle='e'], [data-handle='w']) { top: 0; bottom: 0; height: auto; translate: 0 0; cursor: col-resize; &::before { width: 2px; } } & > .resizer-handle[data-handle='e'] { right: -0.25rem; } & > .resizer-handle[data-handle='w'] { left: -0.25rem; } & > .resizer-handle:is([data-handle='n'], [data-handle='s']) { left: 0; right: 0; width: auto; translate: 0 0; cursor: row-resize; &::before { height: 2px; } } & > .resizer-handle[data-handle='n'] { top: -0.25rem; } & > .resizer-handle[data-handle='s'] { bottom: -0.25rem; } &[data-resizing] { outline: none; } &[data-resizing] > .resizer-handle { background: none; } &[data-resizing] > .resizer-handle::before { opacity: 1; } } /* -- Accessibility media features (REQUIRED) ------------------ */ @media (prefers-reduced-motion: reduce) { .resizer-handle, .resizer[data-resizing] > .resizer-handle, .resizer[data-variant='divider'] > .resizer-handle::before { transition: none; } } @media (prefers-contrast: more) { /* a divider always shows its line */ .resizer[data-variant='divider'] > .resizer-handle::before { opacity: 0.5; } .resizer-handle { width: 0.625rem; height: 0.625rem; opacity: 1; border-width: 2px; } } @media (forced-colors: active) { .resizer-handle { background: Canvas; border-color: CanvasText; forced-color-adjust: none; } .resizer[data-resizing] > .resizer-handle { background: Highlight; border-color: Highlight; } .resizer[data-variant='divider'] > .resizer-handle::before { background: Highlight; } }}§JavaScript view file
One applySize() funnels drag, keyboard, panel-edit and setState traffic through the same mode switch, so px / classes / controlled differ in exactly one place.
// -- Resizer ----------------------------------------------------// Wraps ONE container element and makes it resizable via draggable handles// on any subset of the 8 positions (n/e/s/w + ne/se/sw/nw), configured by// data attributes (AGENTS.md "State API").//// Contract (all attributes on the .resizer wrapper; the resized box is the// wrapper's first element child - the wrapper tracks the child's box):// data-handles which handles to place: space list (e.g. "e s se"),// or "all"; default "se". Handles the axis restricts// (e.g. n/s under data-axis="w") are never placed —// changing either attribute re-syncs them live.// data-resize-mode "px" (pixel-perfect, default) | "classes" (snap to the// sizing.css w-N / h-N ladder - the class attribute IS// the size state) | "controlled" (the runtime writes// NOTHING and only fires `resizer-resize` events —// consumers own the size, e.g. the CodeExample toolbar)// data-axis "both" (default) | "w" | "h"// data-min / data-max shared clamp px (defaults 80 / 2000); per-axis// overrides data-min-w/-max-w/-min-h/-max-h// data-step px quantization for drag (default 1)// data-step-key keyboard arrow step (default 10; Shift multiplies ×10)// data-w-classes / data-h-classes classes-mode ladder override: space-list// of exact tokens (e.g. "w-40 w-48 w-56"); the default// ladder is the integer w-N/h-N scale 16..96 (4–24 rem)// data-width / data-height px mirror of the live size - the observation// surface for the machine contract (the CodeExample State// tab edits these attributes; a MutationObserver applies// them back, so panel edits and drags stay in sync)//// Handles are runtime-made chrome: marked `data-ce-chrome` so the CodeExample// sandbox serializer never leaks them into example source. Without JS the// markup renders untouched - resizing is intrinsically interactive behavior.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();const resizerStates = ['default'];/** The 8 handle positions; corners resize both axes unless data-axis limits it. */const HANDLES = ['n', 'e', 's', 'w', 'ne', 'nw', 'se', 'sw'] as const;type Handle = (typeof HANDLES)[number];/** Default classes-mode ladder numbers: w-N/h-N from 16 (4rem) to 96 (24rem), * the integer scale sizing.css ships (the demo-friendly band). */const CLASS_NUMBERS = Array.from({ length: 81 }, (_, i) => i + 16);const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));/** Read one numeric data attribute (camelCase key) with a fallback. */const numAttr = (el: HTMLElement, key: string, fallback: number): number => { const v = parseFloat(el.dataset[key] ?? ''); return Number.isFinite(v) ? v : fallback;};/** The box being resized: the wrapper's first element child (never a handle). */function targetOf(wrapper: HTMLElement): HTMLElement | null { for (const el of Array.from(wrapper.children)) { if (!(el as HTMLElement).hasAttribute('data-handle')) return el as HTMLElement; } return null;}/** Handles this wrapper asks for ("all" or a space list), restricted to the * axes the box can actually take (an axis-restricted handle would drag * against a cursor that promises nothing). */function handleSet(wrapper: HTMLElement): Handle[] { const spec = (wrapper.dataset.handles || 'se').trim(); const requested = spec === 'all' ? [...HANDLES] : spec.split(/[\s,]+/).filter((h): h is Handle => (HANDLES as readonly string[]).includes(h)); const axis = wrapper.dataset.axis || 'both'; if (axis === 'both') return requested; const moves = (h: string): boolean => (axis === 'w' ? /e|w/.test(h) : /n|s/.test(h)); return requested.filter(moves);}/** The classes-mode token list for one axis (default integer ladder 16..96). */function ladderTokens(wrapper: HTMLElement, axis: 'w' | 'h'): string[] { const custom = axis === 'w' ? wrapper.dataset.wClasses : wrapper.dataset.hClasses; if (custom) return custom.trim().split(/\s+/); return CLASS_NUMBERS.map((n) => `${axis}-${n}`);}// Token → px cache (classes mode). The ladder is static CSS, so one token// resolves to one px value per document (--size-base changes would need a// reload - no shipped surface does that mid-session). Measured on the resized// element itself with ONLY the probed token applied (real styles). Keyed per// axis because a token like `w-40` measures differently from `h-40`.const LADDER_PX = new Map<string, number>();function tokenPx(wrapper: HTMLElement, token: string, axis: 'w' | 'h'): number { const key = axis + token; let px = LADDER_PX.get(key); if (px !== undefined) return px; const target = targetOf(wrapper)!; const had = Array.from(target.classList).filter((c) => /^[wh]-[\d.]+$/.test(c)); had.forEach((c) => target.classList.remove(c)); target.classList.add(token); const box = target.getBoundingClientRect(); px = (axis === 'w' ? box.width : box.height) / zoomOf(wrapper); target.classList.remove(token); had.forEach((c) => target.classList.add(c)); LADDER_PX.set(key, px); return px;}/** Nearest ladder token to a px request. */function nearestToken(wrapper: HTMLElement, ladder: string[], wantedPx: number, axis: 'w' | 'h'): string { let best = ladder[0]; let bestD = Infinity; for (const token of ladder) { const d = Math.abs(tokenPx(wrapper, token, axis) - wantedPx); if (d < bestD) { bestD = d; best = token; } } return best;}/** Replace the axis's sizing-ladder token on the target (classes mode). */function setClassSize(el: HTMLElement, token: string, ladder: string[]): void { const owned = new Set(ladder); for (const cls of Array.from(el.classList)) if (owned.has(cls) && cls !== token) el.classList.remove(cls); if (!el.classList.contains(token)) el.classList.add(token);}/** Apply a px size to the target (px mode). Guarded write - same bytes must * not fire the sandbox serializer nor a redundant style mutation. */function setPxSize(el: HTMLElement, axis: 'w' | 'h', px: number): void { const prop = axis === 'w' ? 'width' : 'height'; const value = `${Math.round(px)}px`; if (el.style.getPropertyValue(prop) !== value) el.style.setProperty(prop, value);}/** The wrapper's clamp bounds for an axis. */function bounds(wrapper: HTMLElement, axis: 'w' | 'h'): [number, number] { const min = numAttr(wrapper, axis === 'w' ? 'minW' : 'minH', numAttr(wrapper, 'min', 80)); const max = numAttr(wrapper, axis === 'w' ? 'maxW' : 'maxH', numAttr(wrapper, 'max', 2000)); return [min, max];}/** CSS `zoom` on the WRAPPER (Chromium serializes it as a number, other * engines may use a percentage - normalize both). The toolbar of the * CodeExample zooms the wrapper; `zoom` is not inherited, so it must be read * where it is applied (getBoundingClientRect already includes it). */function zoomOf(el: HTMLElement): number { const zs = getComputedStyle(el).zoom || '1'; const z = zs.includes('%') ? parseFloat(zs) / 100 : parseFloat(zs); return Number.isFinite(z) && z > 0 ? z : 1;}/** Live px size of one axis (the target owns the box). getBoundingClientRect() * already includes any CSS `zoom` on the wrapper, so divide it out - the drag * math works in true layout px (a drag started from a zoomed measure jumps). */function currentPx(wrapper: HTMLElement, axis: 'w' | 'h'): number { const target = targetOf(wrapper); if (!target) return 0; const box = target.getBoundingClientRect(); const raw = axis === 'w' ? box.width : box.height; return Math.round(raw / zoomOf(wrapper));}/** * Apply one size change through the configured mode. Everything - drag, * keyboard, panel edits, setState - funnels through here, so px/classes/ * controlled differ in exactly this one place. Fires `resizer-resize` with * the resulting box (controlled mode reports the requested px - nothing was * applied yet; consumers own the size). */function applySize(wrapper: HTMLElement, axis: 'w' | 'h', px: number): void { const target = targetOf(wrapper); if (!target) return; const mode = wrapper.dataset.resizeMode || 'px'; const [min, max] = bounds(wrapper, axis); const wanted = clamp(px, min, max); let resolved = wanted; if (mode === 'classes') { const ladder = ladderTokens(wrapper, axis); setClassSize(target, nearestToken(wrapper, ladder, wanted, axis), ladder); resolved = currentPx(wrapper, axis); // CSS owns the size - read it back } else if (mode !== 'controlled') { const step = Math.max(1, numAttr(wrapper, 'step', 1)); resolved = Math.round(wanted / step) * step; setPxSize(target, axis, resolved); } if (mode !== 'controlled') { // px mirror (the observation surface) - guarded so our own write leaves // the MutationObserver idle when nothing changed const key = axis === 'w' ? 'width' : 'height'; const value = String(Math.round(resolved)); if (wrapper.dataset[key] !== value) wrapper.dataset[key] = value; } wrapper.dispatchEvent( new CustomEvent('resizer-resize', { bubbles: true, detail: { axis, width: axis === 'w' ? (mode === 'controlled' ? Math.round(wanted) : currentPx(wrapper, 'w')) : currentPx(wrapper, 'w'), height: axis === 'h' ? (mode === 'controlled' ? Math.round(wanted) : currentPx(wrapper, 'h')) : currentPx(wrapper, 'h'), }, }), );}/** * UI side of setState. 'default' restores the authored snapshot taken at init * (or an explicit { width, height } px config). Unknown names are rejected * upstream by the api. */function triggerStateChange(wrapper: HTMLElement, stateName: string, config: Record<string, unknown> = {}): void { if (stateName !== 'default') return; if (config.width !== undefined || wrapper._defaultSize) applySize(wrapper, 'w', Number(config.width ?? wrapper._defaultSize?.[0])); if (config.height !== undefined || wrapper._defaultSize) applySize(wrapper, 'h', Number(config.height ?? wrapper._defaultSize?.[1]));}/** Registry-level API; pass the .resizer wrapper explicitly. Unknown names throw. */export const resizerApi = { setState(wrapper: HTMLElement, stateName: string, config: Record<string, unknown> = {}) { if (!resizerStates.includes(stateName)) { throw new Error(`resizer: unknown state "${stateName}" (supported: ${resizerStates.join(', ')})`); } triggerStateChange(wrapper, stateName, config); // state lives on the ELEMENT, not module scope (AGENTS.md "State API") wrapper.dataset.stateName = stateName; wrapper._stateConfig = config; }, getState(wrapper: HTMLElement) { // reflect reality: drags move the size without setState() return { name: wrapper.dataset.stateName || 'default', config: { ...wrapper._stateConfig, width: currentPx(wrapper, 'w'), height: currentPx(wrapper, 'h'), mode: wrapper.dataset.resizeMode || 'px', }, }; },};df$.resizerApi = resizerApi;df$.resizerStates = resizerStates;const HANDLE_LABEL: Record<string, string> = { n: 'top edge', s: 'bottom edge', e: 'right edge', w: 'left edge', ne: 'top-right corner', nw: 'top-left corner', se: 'bottom-right corner', sw: 'bottom-left corner',};function makeHandle(wrapper: HTMLElement, h: Handle): HTMLElement { const el = document.createElement('span'); el.className = 'resizer-handle'; el.dataset.handle = h; el.setAttribute('role', 'separator'); el.setAttribute('tabindex', '0'); // edge handles announce their orientation; corners own both axes if (h.length === 1) el.setAttribute('aria-orientation', h === 'n' || h === 's' ? 'horizontal' : 'vertical'); el.setAttribute('aria-label', `Resize ${HANDLE_LABEL[h]}`); // sandbox chrome marker: the bridge serializer must never leak runtime // handles into a CodeExample's editable source el.setAttribute('data-ce-chrome', ''); el.addEventListener('pointerdown', (ev) => startDrag(wrapper, el, ev as PointerEvent)); el.addEventListener('keydown', (ev) => handleKeys(wrapper, el, ev)); // a divider resets on double-click, as split panes do (read at click time: // the variant may be set or removed after init) el.addEventListener('dblclick', () => { if (wrapper.dataset.variant === 'divider') resizerApi.setState(wrapper, 'default'); }); return el;}/** Place/repair the handle set for the current config (also re-run on live * data-handles / data-axis changes - the CodeExample toolbar flips modes). */function syncHandles(wrapper: HTMLElement): void { const want = handleSet(wrapper); for (const el of Array.from(wrapper.querySelectorAll(':scope > .resizer-handle'))) { if (!want.includes((el as HTMLElement).dataset.handle as Handle)) el.remove(); } for (const h of want) { if (!wrapper.querySelector(`:scope > .resizer-handle[data-handle="${h}"]`)) wrapper.appendChild(makeHandle(wrapper, h)); }}/** Keyboard parity (same convention as the CodeExample preview grip): * ArrowRight / ArrowUp grow, ArrowLeft / ArrowDown shrink - Shift ×10, * Home/End jump to the clamp bounds. Corners drive both axes at once. */function handleKeys(wrapper: HTMLElement, handle: HTMLElement, ev: KeyboardEvent): void { const sides = handle.dataset.handle; if (!sides) return; // data-keys="edge" (a divider - the window-splitter pattern): the arrows // move the handle's edge, so the key pointing away from the box grows it - // ArrowLeft on a west edge, ArrowDown on a south edge. Default: Right/Up grow. const edge = wrapper.dataset.keys === 'edge' && sides.length === 1; const OUT: Record<string, string> = { e: 'ArrowRight', w: 'ArrowLeft', s: 'ArrowDown', n: 'ArrowUp' }; const IN: Record<string, string> = { e: 'ArrowLeft', w: 'ArrowRight', s: 'ArrowUp', n: 'ArrowDown' }; const dir = edge ? ev.key === OUT[sides] ? 1 : ev.key === IN[sides] ? -1 : 0 : ev.key === 'ArrowRight' || ev.key === 'ArrowUp' ? 1 : ev.key === 'ArrowLeft' || ev.key === 'ArrowDown' ? -1 : 0; if (dir === 0 && ev.key !== 'Home' && ev.key !== 'End') return; ev.preventDefault(); const step = (numAttr(wrapper, 'stepKey', 10) || 10) * (ev.shiftKey ? 10 : 1); const apply = (axis: 'w' | 'h'): void => { const [min, max] = bounds(wrapper, axis); const cur = currentPx(wrapper, axis); applySize(wrapper, axis, ev.key === 'Home' ? min : ev.key === 'End' ? max : cur + dir * step); }; if (sides.includes('e') || sides.includes('w')) apply('w'); if (sides.includes('n') || sides.includes('s')) apply('h');}/** One handle's drag: capture the pointer, translate movement into the axis * deltas the position implies, feed applySize (mode-agnostic). The release * may never arrive: pointer capture is document-scoped, so a drag started * inside an iframe and released over the parent page produces no pointerup * here - the drag would stay live and resize on every later move. Three * guards end it: lostpointercapture (capture revoked), pointercancel (the * sandbox bridge re-dispatches it when the HOST reports the release) and a * captured pointermove with buttons === 0 (the button is up; the up event * was swallowed by another document). */function startDrag(wrapper: HTMLElement, handle: HTMLElement, ev: PointerEvent): void { if (ev.button !== 0) return; ev.preventDefault(); handle.setPointerCapture(ev.pointerId); const side = handle.dataset.handle || 'se'; const axis = wrapper.dataset.axis || 'both'; const startX = ev.clientX; const startY = ev.clientY; const startW = currentPx(wrapper, 'w'); const startH = currentPx(wrapper, 'h'); // pointer deltas are SCREEN px; the box works in layout px - undo the zoom const z = zoomOf(wrapper); // sign per edge: handles on the left/top grow the box by NEGATIVE movement const dx = side.includes('w') ? -1 : side.includes('e') ? 1 : 0; const dy = side.includes('n') ? -1 : side.includes('s') ? 1 : 0; wrapper.dataset.resizing = side; const onUp = (): void => { delete wrapper.dataset.resizing; handle.removeEventListener('pointermove', onMove); handle.removeEventListener('pointerup', onUp); handle.removeEventListener('pointercancel', onUp); handle.removeEventListener('lostpointercapture', onUp); // the sandbox bridge dispatches the host-relayed cancel ON document (a // document-dispatched event never bubbles down to the handle) - watch it // there for exactly the drag's lifetime document.removeEventListener('pointercancel', onUp); }; document.addEventListener('pointercancel', onUp); const onMove = (e: PointerEvent): void => { // captured moves carry the pressed button - buttons === 0 means the // release happened where we cannot see it (outside this document): end if (e.buttons === 0) { onUp(); return; } if (dx !== 0 && axis !== 'h') applySize(wrapper, 'w', startW + (dx * (e.clientX - startX)) / z); if (dy !== 0 && axis !== 'w') applySize(wrapper, 'h', startH + (dy * (e.clientY - startY)) / z); }; handle.addEventListener('pointermove', onMove); handle.addEventListener('pointerup', onUp); handle.addEventListener('pointercancel', onUp); handle.addEventListener('lostpointercapture', onUp);}function init(): void { document.querySelectorAll<HTMLElement>('.resizer:not([data-init])').forEach((wrapper) => { wrapper.dataset.init = ''; if (!targetOf(wrapper)) return; // a resizer wraps exactly ONE element // authored snapshot for the 'default' state (computed - works for px and // classes authored alike) wrapper._defaultSize = [currentPx(wrapper, 'w'), currentPx(wrapper, 'h')]; wrapper.api = { setState: (stateName: string, config?: Record<string, unknown>) => resizerApi.setState(wrapper, stateName, config), getState: () => resizerApi.getState(wrapper), }; syncHandles(wrapper); // native CSS `resize` (e.g. a resizable textarea) would double the // affordances - this component owns the interaction const target = targetOf(wrapper); target?.style.setProperty('resize', 'none'); // data-width/data-height are the machine-contract surface: bridge panel // edits apply back through here. data-handles/data-axis re-sync handles. new MutationObserver((records) => { for (const r of records) { if (r.attributeName === 'data-handles' || r.attributeName === 'data-axis') { syncHandles(wrapper); continue; } const axis: 'w' | 'h' = r.attributeName === 'data-width' ? 'w' : 'h'; const v = parseFloat(wrapper.dataset[axis === 'w' ? 'width' : 'height'] ?? ''); // act only on EXTERNAL writes that differ from the live box (our own // mirror write lands here too - same value, so it no-ops) if (Number.isFinite(v) && Math.abs(currentPx(wrapper, axis) - v) > 0.5) applySize(wrapper, axis, v); } }).observe(wrapper, { attributes: true, attributeFilter: ['data-width', 'data-height', 'data-handles', 'data-axis'] }); // schema action surface: `resizer-reset` → authored size wrapper.addEventListener('resizer-reset', () => resizerApi.setState(wrapper, 'default')); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub