Skip to content
Domphy

Splitter

Resizable split layout. Apply splitter on the container, splitterPanel on each panel, splitterHandle on the divider.

  • splitterPanel — binds width (horizontal) or height (vertical) from splitter context. Two panels either side of one handle: the first takes the size percentage, the second takes the complement (always 100%).
  • splitterHandle — pointer drag (mouse, pen and touch) and keyboard (Arrow, Home, End) resize; role="separator" with aria-valuenow/min/max and an accessible name. Takes one optional prop, label (default "Resize"), used as aria-label.

Props

PropTypeDefaultDescription
direction"horizontal" | "vertical""horizontal"Split orientation.
defaultSizenumber50Initial first-panel size (percentage).
minnumber10Minimum panel size (percentage).
maxnumber90Maximum panel size (percentage).
labelstring"Resize"(splitterHandle) Accessible name for the separator, used as aria-label.
Customization

Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.

There are four levels of customization, in increasing order of effort:

  1. Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
  2. Context attributes. Use dataTone, dataSize, and dataDensity on a container to shift tone, size, or density for an entire subtree without touching individual elements.
  3. Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
  4. Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas

Unit - U = fontSize / 4 - convert final values with themeSpacing(n).

Size - n = intrinsic text lines, w = wrapping level, d = density factor:

height        = (n * 6 + 2 * d * w) * U
paddingBlock  = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius        = d * w * U

Base density d = 1.5:

Uw=0w=1w=2w=3
height (n = 1)691215
paddingBlock01.534.5
paddingInline34.564.5
radius01.534.5

Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.

RoleShiftn=0
Backgroundparent +/- n0
Textbg + K6
Borderbg + K/23
Hoverbg + 2K/34
Selected / Focusabove +/- K/32-4

State shift range: K/3 <= delta <= 2K/3.

import { ElementNode, merge, type PartialElement, toState } from "@domphy/core";
import { themeColor, themeSpacing } from "@domphy/theme";
import { horizontalArrowStep } from "../utils/direction.js";
import { focusRing } from "../utils/focusRing.js";

/**
 * Root of a resizable split layout. Lays out children as a flex row (horizontal) or column
 * (vertical) and provides a `splitter` context (`{ direction, size, min, max }`) consumed by
 * `splitterPanel` and `splitterHandle`. `size` is a reactive state holding the first panel's
 * percentage. No host-tag check; typically applied to a `div`.
 *
 * @param props.direction - Split orientation, `"horizontal"` | `"vertical"`. Defaults to `"horizontal"`.
 * @param props.defaultSize - Initial size (percentage) of the resizable panel. Defaults to `50`.
 * @param props.min - Minimum panel size (percentage). Defaults to `10`.
 * @param props.max - Maximum panel size (percentage). Defaults to `90`.
 * @example { div: [...], $: [splitter({ direction: "vertical" })] }
 */
function splitter(
  props: {
    direction?: "horizontal" | "vertical";
    defaultSize?: number;
    min?: number;
    max?: number;
  } = {},
): PartialElement {
  const {
    direction = "horizontal",
    defaultSize = 50,
    min = 10,
    max = 90,
  } = props;
  return {
    _onSchedule: (_node, element) => {
      merge(element, {
        _context: {
          splitter: {
            direction,
            size: toState(defaultSize),
            min,
            max,
            // Legacy bookkeeping kept on the context object for compatibility.
            // First-vs-second assignment uses sibling order (see splitterPanel).
            panelCount: 0,
          },
        },
      });
    },
    style: {
      display: "flex",
      flexDirection: direction === "horizontal" ? "row" : "column",
      overflow: "hidden",
    },
  };
}

/**
 * The resizable panel inside a `splitter`. Reads the `splitter` context and binds its
 * width (horizontal) or height (vertical) to the context `size` state, updating reactively as
 * the handle is dragged. Intended for exactly two panels either side of one `splitterHandle`:
 * the first `splitterPanel` mounted takes `size`%, and the second takes the complementary
 * `100 - size`% — so the pair always sums to the full width/height instead of both tracking
 * the same number (which would make them grow and shrink together instead of trading space).
 * Warns if used outside a `splitter`. Takes no props.
 *
 * @example { div: [...], $: [splitterPanel()] }
 */
function isFirstSplitterPanel(node: ElementNode): boolean {
  const parent = node.parent;
  if (!parent) return true;
  for (const item of parent.children.items) {
    if (item instanceof ElementNode && item.getMetadata("splitterPanel")) {
      return item === node;
    }
  }
  return true;
}

function splitterPanel(): PartialElement {
  return {
    _metadata: { splitterPanel: true },
    _onMount: (node) => {
      const ctx = node.getContext("splitter");
      if (!ctx) {
        console.warn(`"splitterPanel" patch must be used inside a "splitter"`);
        return;
      }
      const el = node.domElement as HTMLElement;
      const prop = ctx.direction === "horizontal" ? "width" : "height";

      const apply = (size: number) => {
        el.style[prop] = `${isFirstSplitterPanel(node) ? size : 100 - size}%`;
      };
      apply(ctx.size.get());
      // The two percentages already sum to 100%, so the handle's own width made
      // the row wider than the splitter and the far panel was cut off by its
      // `overflow: hidden`. Let the panels give the handle its space back.
      el.style.flexShrink = "1";
      el.style.overflow = "auto";

      const release = ctx.size.addListener(apply);
      node.addHook("Remove", () => {
        ctx.panelCount = Math.max(0, (ctx.panelCount ?? 1) - 1);
        release();
      });
    },
  };
}

/**
 * The draggable divider inside a `splitter`. Reads the `splitter` context, shows the
 * appropriate resize cursor, and updates the context `size` state (clamped to `min`/`max`)
 * via mouse drag or keyboard: Arrow keys move by 1%, Home/End jump to min/max, hold Shift
 * for 10× step. Sets `role="separator"`, `tabindex="0"`, and `aria-value*` attributes.
 * Warns if used outside a `splitter`. Drag works with mouse, pen and touch
 * (Pointer Events).
 *
 * @param props.label - Accessible name for the separator (WCAG 4.1.2 — a
 *   focusable `role="separator"` with `aria-value*` is a widget and needs a
 *   name). Defaults to `"Resize"`. A native `ariaLabel`/`ariaLabelledby` on
 *   the host element wins.
 * @example { div: null, $: [splitterHandle()] }
 */
function splitterHandle(props: { label?: string } = {}): PartialElement {
  const { label = "Resize" } = props;
  return {
    role: "separator",
    tabindex: 0,
    ariaLabel: label,
    _onMount: (node) => {
      const ctx = node.getContext("splitter");
      if (!ctx) {
        console.warn(`"splitterHandle" patch must be used inside a "splitter"`);
        return;
      }
      const handle = node.domElement as HTMLElement;
      const isHorizontal = ctx.direction === "horizontal";

      handle.style.cursor = isHorizontal ? "col-resize" : "row-resize";
      handle.setAttribute(
        "aria-orientation",
        isHorizontal ? "vertical" : "horizontal",
      );
      handle.setAttribute("aria-valuemin", String(ctx.min));
      handle.setAttribute("aria-valuemax", String(ctx.max));
      handle.setAttribute("aria-valuenow", String(Math.round(ctx.size.get())));

      const releaseAriaValue = ctx.size.addListener((size: number) => {
        handle.setAttribute("aria-valuenow", String(Math.round(size)));
      });

      const onKeydown = (e: KeyboardEvent) => {
        const step = e.shiftKey ? 10 : 1;
        let next: number | null = null;
        if (isHorizontal) {
          // Move the divider toward the physically-pressed arrow: the
          // first panel's share grows when that moves the divider right in
          // LTR, or left in RTL (both panels are laid out with `flexDirection:
          // row`, which itself flips source order under RTL).
          const dir = horizontalArrowStep(e.key, handle);
          if (dir !== 0)
            next = Math.min(
              Math.max(ctx.size.get() + dir * step, ctx.min),
              ctx.max,
            );
        } else {
          if (e.key === "ArrowDown")
            next = Math.min(ctx.size.get() + step, ctx.max);
          else if (e.key === "ArrowUp")
            next = Math.max(ctx.size.get() - step, ctx.min);
        }
        if (e.key === "Home") next = ctx.min;
        else if (e.key === "End") next = ctx.max;
        if (next !== null) {
          e.preventDefault();
          ctx.size.set(next);
        }
      };

      let cancelDrag: (() => void) | null = null;

      // Pointer Events, not mouse events: a touch drag never fires
      // mousemove/mouseup, so the handle was mouse-and-pen only — every peer
      // resizable splitter (react-resizable-panels, MUI, Mantine) drags on
      // touch. `touch-action: none` (set in the style block) is required or
      // the browser claims the gesture for scrolling and fires pointercancel.
      const onPointerdown = (e: PointerEvent) => {
        e.preventDefault();
        const container = handle.parentElement!;
        handle.setPointerCapture?.(e.pointerId);

        const onPointermove = (move: PointerEvent) => {
          if (move.pointerId !== e.pointerId) return;
          const rect = container.getBoundingClientRect();
          const raw = isHorizontal
            ? ((move.clientX - rect.left) / rect.width) * 100
            : ((move.clientY - rect.top) / rect.height) * 100;
          ctx.size.set(Math.min(Math.max(raw, ctx.min), ctx.max));
        };

        cancelDrag = () => {
          document.removeEventListener("pointermove", onPointermove);
          document.removeEventListener("pointerup", onPointerup);
          document.removeEventListener("pointercancel", onPointerup);
          cancelDrag = null;
          // releasePointerCapture throws NotFoundError once the pointer is no
          // longer active — which is exactly the pointercancel path. Ask
          // first, and only after the listeners are already detached so a
          // throw could not leak them or strand `cancelDrag`.
          if (handle.hasPointerCapture?.(e.pointerId))
            handle.releasePointerCapture(e.pointerId);
        };

        function onPointerup(up: PointerEvent) {
          if (up.pointerId !== e.pointerId) return;
          cancelDrag?.();
        }

        document.addEventListener("pointermove", onPointermove);
        document.addEventListener("pointerup", onPointerup);
        document.addEventListener("pointercancel", onPointerup);
      };

      handle.addEventListener("keydown", onKeydown);
      handle.addEventListener("pointerdown", onPointerdown);
      node.addHook("Remove", () => {
        cancelDrag?.();
        releaseAriaValue();
        handle.removeEventListener("keydown", onKeydown);
        handle.removeEventListener("pointerdown", onPointerdown);
      });
    },
    // Soft divider surface — shift via dataTone, paint with inherit.
    dataTone: "shift-2",
    style: {
      flexShrink: 0,
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      // Hand the drag gesture to the pointer handler instead of the browser's
      // scroller — without this a touch drag is swallowed by panning.
      touchAction: "none",
      backgroundColor: (listener) => themeColor(listener, "inherit"),
      color: (listener) => themeColor(listener, "text"),
      "&:hover": {
        backgroundColor: (listener) => themeColor(listener, "increase-1"),
      },
      transition: "background-color 140ms ease, box-shadow 140ms ease",
      "&:focus-visible": {
        boxShadow: (listener) => focusRing(listener, "primary"),
      },
      "&::after": {
        content: '""',
        // A small grip dot so the handle is visibly draggable. Without an
        // explicit size the pseudo-element collapses to 0x0 and is invisible.
        width: themeSpacing(1),
        height: themeSpacing(1),
        borderRadius: themeSpacing(999),
        backgroundColor: (listener) => themeColor(listener, "shift-4"),
      },
    },
  };
}

export { splitter, splitterPanel, splitterHandle };