Skip to content
Domphy

Splitter

Use splitter on a container div to create a resizable split layout. It works with two companion patches:

  • splitterPanel — applied to each panel div; reads the splitter context and binds its width (horizontal) or height (vertical) reactively. Meant for exactly two panels either side of one splitterHandle: the first one mounted takes the size percentage, the second takes the complement, so the pair always sums to 100% instead of both tracking the same number.
  • splitterHandle — applied to the divider div between panels; handles mouse drag and keyboard (Arrow, Home, End) resize, and sets role="separator" with aria-valuenow/min/max.

The direction prop sets split orientation ("horizontal" | "vertical", default "horizontal"). The defaultSize prop sets the initial first-panel size as a percentage (default 50). min and max clamp the draggable range (defaults 10/90).

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 { merge, type PartialElement, toState } from "@domphy/core";
import { themeColor, themeSpacing } from "@domphy/theme";
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,
            // Mutable bookkeeping (not reactive state) so splitterPanel can
            // tell the first panel from later ones — see its own comment.
            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 splitterPanel(): PartialElement {
  return {
    _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 isFirst = ctx.panelCount++ === 0;

      const apply = (size: number) => {
        el.style[prop] = `${isFirst ? size : 100 - size}%`;
      };
      apply(ctx.size.get());
      el.style.flexShrink = "0";
      el.style.overflow = "auto";

      const release = ctx.size.addListener(apply);
      node.addHook("Remove", 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`. Takes no props.
 *
 * @example { div: null, $: [splitterHandle()] }
 */
function splitterHandle(): PartialElement {
  return {
    role: "separator",
    tabindex: 0,
    _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) {
          if (e.key === "ArrowRight")
            next = Math.min(ctx.size.get() + step, ctx.max);
          else if (e.key === "ArrowLeft")
            next = Math.max(ctx.size.get() - step, ctx.min);
        } 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;

      const onMousedown = (e: MouseEvent) => {
        e.preventDefault();
        const container = handle.parentElement!;

        const onMousemove = (e: MouseEvent) => {
          const rect = container.getBoundingClientRect();
          const raw = isHorizontal
            ? ((e.clientX - rect.left) / rect.width) * 100
            : ((e.clientY - rect.top) / rect.height) * 100;
          ctx.size.set(Math.min(Math.max(raw, ctx.min), ctx.max));
        };

        const onMouseup = () => {
          document.removeEventListener("mousemove", onMousemove);
          document.removeEventListener("mouseup", onMouseup);
          cancelDrag = null;
        };

        cancelDrag = () => {
          document.removeEventListener("mousemove", onMousemove);
          document.removeEventListener("mouseup", onMouseup);
          cancelDrag = null;
        };

        document.addEventListener("mousemove", onMousemove);
        document.addEventListener("mouseup", onMouseup);
      };

      handle.addEventListener("keydown", onKeydown);
      handle.addEventListener("mousedown", onMousedown);
      node.addHook("Remove", () => {
        cancelDrag?.();
        releaseAriaValue();
        handle.removeEventListener("keydown", onKeydown);
        handle.removeEventListener("mousedown", onMousedown);
      });
    },
    // Soft divider surface — shift via dataTone, paint with inherit.
    dataTone: "shift-2",
    style: {
      flexShrink: 0,
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      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 };