Theme
On this page (13)
Component Skill — components/resizer/component-skill.md

Native basis

Pointer Events with setPointerCapture drive the drag; getBoundingClientRect() reads the box. In classes mode the runtime only swaps w-*/h-* classes, so the sizing utilities remain the single source of size truth.

Web Platform APIs

setPointerCapture()touch-actionrole="separator"CustomEventMutationObserver

Classes

.resizer.resizer-handle

Variants (data attributes)

• data-handles - space list of n e s w ne nw se sw, or all (default se)

• data-resize-mode - px (default) | classes | controlled

• data-axis - both (default) | w | h; handles the axis forbids are never placed

• data-min/data-max - clamp in px (80 / 2000; per-axis data-min-w … override), data-step quantizes drags

• data-w-classes/data-h-classes replace the classes ladder with your own token list

Accessibility

• Handles are focusable role="separator" controls with a position-naming aria-label; edge handles carry aria-orientation

• Keyboard parity: Arrow keys resize (Right/Up grow, Left/Down shrink, Shift ×10), Home/End jump to the clamp bounds

• While dragging, the wrapper carries data-resizing - a dashed outline plus a filled handle

§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:

StateTypeValuesDefaultDescription
widthnumber—320Live width in px, mirrored on data-width (drags and keyboard write it; panel edits apply back).
heightnumber—160Live 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