Splitter
Use the splitter patch to customize this element.
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:
- Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
- Context attributes. Use
dataTone,dataSize, anddataDensityon a container to shift tone, size, or density for an entire subtree without touching individual elements. - Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
- 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:
| U | w=0 | w=1 | w=2 | w=3 |
|---|---|---|---|---|
height (n = 1) | 6 | 9 | 12 | 15 |
| paddingBlock | 0 | 1.5 | 3 | 4.5 |
| paddingInline | 3 | 4.5 | 6 | 4.5 |
| radius | 0 | 1.5 | 3 | 4.5 |
Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.
| Role | Shift | n=0 |
|---|---|---|
| Background | parent +/- n | 0 |
| Text | bg + K | 6 |
| Border | bg + K/2 | 3 |
| Hover | bg + 2K/3 | 4 |
| Selected / Focus | above +/- K/3 | 2-4 |
State shift range: K/3 <= delta <= 2K/3.
<div class="blocks">
<div class="block active" data-tab="0">
import { merge, type PartialElement, toState } from "@domphy/core";
import { themeColor, themeSpacing } from "@domphy/theme";
/**
* 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,
},
},
});
},
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. 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";
el.style[prop] = `${ctx.size.get()}%`;
el.style.flexShrink = "0";
el.style.overflow = "auto";
const release = ctx.size.addListener((size: number) => {
el.style[prop] = `${size}%`;
});
node.addHook("Remove", release);
},
};
}
/**
* The draggable divider inside a `splitter`. Reads the `splitter` context, shows the
* appropriate resize cursor, and on mouse drag updates the context `size` state (clamped to
* `min`/`max`). Warns if used outside a `splitter`. Takes no props.
*
* @example { div: null, $: [splitterHandle()] }
*/
function splitterHandle(): PartialElement {
return {
_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";
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);
};
document.addEventListener("mousemove", onMousemove);
document.addEventListener("mouseup", onMouseup);
};
handle.addEventListener("mousedown", onMousedown);
node.addHook("Remove", () =>
handle.removeEventListener("mousedown", onMousedown),
);
},
style: {
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: (listener) => themeColor(listener, "shift-2"),
"&:hover": {
backgroundColor: (listener) => themeColor(listener, "shift-3"),
},
"&::after": {
content: '""',
borderRadius: themeSpacing(999),
backgroundColor: (listener) => themeColor(listener, "shift-4"),
},
},
};
}
export { splitter, splitterPanel, splitterHandle };
</div>
</div>