Skip to content

Split

<aparte-split>

Two panes and a draggable seam — a chat beside a preview, an editor, an artifact card.

The grid is the mechanism: --aparte-split-position is the primary pane’s size and the min/max are CSS clamp bounds, so the browser clamps and nothing here parses a unit. The element adds only what CSS cannot do — the drag, the arrow keys, the ARIA of an APG window splitter, and one pane at a time under a breakpoint. Without the element the .aparte-split recipe is still a split; it just does not move.

It stores nothing: position in, one aparte-split-resize out on commit. The attribute is written on COMMIT only — a release, a key up, a double-click, a property set — and the live value during a drag travels on the custom property, so a framework’s reconciler is not in the drag loop. Persist from the event; restore by setting position.

A pane CONTAINS a chat; a chat never contains a split.

aria-orientation on the handle is the INVERSE of this element’s orientation: the attribute names the SEPARATOR’s own axis, which is what the APG means by “Left Arrow moves a vertical splitter” and what ARIA 1.2 means by the attribute. Do not “fix” it — consumers style against it.

Shift + an arrow is ±10%. That is an ecosystem convention, not the APG, which specifies the single step alone.

Width Open in a tab
The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes.
<!-- The chat in one pane, your own in the other. Drag the seam, or tab to it and use
the arrows; double-click resets it. `--aparte-split-min` is a CSS length, so the
chat cannot be dragged narrower than 16rem whatever the percentage says. Under
48rem of window the split shows one pane, and the buttons switch it. -->
<div class="aparte-app-header">
<button class="aparte-btn aparte-btn--sm aparte-btn--surface" type="button" data-aparte-split-pane="start">Chat</button>
<button class="aparte-btn aparte-btn--sm aparte-btn--surface" type="button" data-aparte-split-pane="end">Preview</button>
</div>
<aparte-split position="38" style="height: 22rem; --aparte-split-min: 16rem">
<aparte-chat>
<aparte-chat-viewport>
<aparte-chat-bubble message-id="u1" data-role="user" content="Show me the preview beside the chat."></aparte-chat-bubble>
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="It is the pane on the right. Drag the seam to give it more room, or double-click the seam to reset."></aparte-chat-bubble>
</aparte-chat-viewport>
<aparte-composer>
<div class="aparte-composer-shell">
<div class="aparte-composer-row">
<aparte-composer-input></aparte-composer-input>
<aparte-composer-send></aparte-composer-send>
</div>
</div>
</aparte-composer>
</aparte-chat>
<section class="aparte-split__pane">
<!-- Your document, not ours: it brings its own colour scheme and typeface. -->
<iframe title="Preview" style="inline-size: 100%; block-size: 100%; border: 0"
srcdoc="<!doctype html><style>:root { color-scheme: light dark; font: 15px/1.5 system-ui, sans-serif; padding: 1rem 1.5rem } h1 { font-size: 1.25rem }</style><h1>Your pane</h1><p>A preview, a document, a map — anything that lives beside the conversation.</p>"></iframe>
</section>
</aparte-split>
<script>
document.querySelector('aparte-split').addEventListener('aparte-split-resize', (e) => {
localStorage.setItem('split', String(e.detail.position));
});
</script>
AttributeDescription
positionThe primary pane’s size, as a percentage of the container. Reflected on commit, never per drag frame.
orientationhorizontal (panes side by side, the default) or vertical (stacked). Names the CONTAINER’s axis.
primarystart (default) or end: which pane position sizes.
collapsedThe primary pane folded to its minimum. Enter on the seam toggles it; a second Enter restores the size it had.
breakpointBelow this width the split shows one pane at a time. A length (default 48rem), or none to never stack.
panestart (default) or end: which pane is shown while stacked.
disabledNo drag, no keys, no tab stop; the seam stays drawn.
labelThe seam’s accessible name. Defaults to the locale’s splitHandleLabel.
singleShow one pane — the one pane names — whatever the width: the seam and the other pane are gone, as under the breakpoint. For a host that decides itself when a second pane exists (a preview with nothing to preview yet). collapsed is not this: it folds the primary pane to --aparte-split-min and keeps the seam. The CSS route, .aparte-split--only-start / --only-end, is the same state for a host that owns its breakpoints.
data-stackedWritten BY the element while one pane is shown. Read-only; style against it.
PropertyTypeDescription
positionnumberThe primary pane’s size, as a percentage of the container. Setting it commits.
orientation'horizontal' | 'vertical'The container’s axis: horizontal (side by side) or vertical (stacked).
primary'start' | 'end'Which pane position sizes.
collapsedbooleanThe primary pane is folded to its minimum.
pane'start' | 'end'Which pane is shown while the split is stacked.
disabledbooleanNo drag, no keys, no tab stop. The seam stays drawn.
stacked (readonly)booleanTrue while the split is showing one pane. Read-only. Both routes into that state count. data-stacked is the one this element writes from its own breakpoint; .aparte-split--only-start / --only-end are the CSS route a host takes when it owns its breakpoints and sets breakpoint="none". The sheet gives the two byte-identical rules, so the element has to read them the same way — every guard downstream keys on this getter, and a split stacked by the class alone would measure a one-track grid and commit position="100".
singlebooleanOne pane on demand, whatever the width (#54). Reflected.
MethodDescription
collapse(): voidFold the primary pane to its minimum, remembering the size it had.
expand(): voidUnfold it, back to the size it had before it collapsed.
toggleCollapse(): void
showPane(which: 'start' | 'end'): voidShow one pane, which only has a visible effect while the split is stacked.
reset(): voidBack to the position the split was FIRST connected with — a re-parent restores that, it does not redefine it. What a double-click on the seam does.
EventTypeDescription
aparte-split-resizeCustomEvent&lt;AparteSplitResizeDetail>The position settled: after a drag, a key, a double-click or a property set. Never during a drag — persist from here.

Override any of these on :root, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at :root moves everything that reads it. The full set is in the CSS variables reference.

VariableDefaultDescription
--aparte-split-position38%The primary pane’s size.
--aparte-split-minmin(20rem, 100%)Floor of the primary pane. Any length or percentage.
--aparte-split-max60%Ceiling of the primary pane.
--aparte-split-handle-size12pxThe seam’s track: the grab zone, and what the grid reserves between the panes. The painted line inside it is --aparte-split-seam-width.
--aparte-split-hit-area12pxThe invisible grab zone around it. Grows to the touch target on a coarse pointer.

The element is the same object everywhere — the tag does not change. What changes is how an attribute is written and how an event reaches you.

<aparte-split collapsed=""></aparte-split>
el.addEventListener('aparte-split-resize', (e) => use(e.detail));

Installation and the framework-specific traps: React · Vue · Svelte · Angular.