Layout
Split pane
Two panels with a divider you can drag or move with the keyboard. For sidebars, editors and previews.
- Built on
CSS gridPointer capturerole="separator"- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.4 kB
- Dependencies
- React
# AtlasSelf-hosted status pages. Run pnpm dev and open localhost:5173.
Code
import { SplitPane } from "@/components/equip/split-pane";
const files = ["README.md", "package.json", "tsconfig.json", "vite.config.ts"];
export default function Demo() {
return (
<SplitPane
label="Files"
defaultSize={42}
min={25}
max={75}
style={{
width: 224,
height: 112,
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-m)",
fontSize: "var(--eq-text-xs)",
}}
>
<ul style={{ margin: 0, padding: 4, listStyle: "none" }}>
{files.map((file, index) => (
<li
key={file}
style={{
padding: "4px 6px",
borderRadius: "var(--eq-radius-s)",
background: index === 0 ? "var(--eq-tint-strong)" : undefined,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{file}
</li>
))}
</ul>
<div style={{ padding: "6px 10px", lineHeight: 1.45 }}>
<strong style={{ display: "block", marginBottom: 2 }}># Atlas</strong>
<span style={{ color: "var(--eq-ink-2)" }}>
Self-hosted status pages. Run pnpm dev and open localhost:5173.
</span>
</div>
</SplitPane>
);
}Examples
Stacked
With orientation="vertical" the panels stack and Up and Down move the divider.
export function total(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}$ pnpm test PASS cart.test.ts (4 tests) Done in 1.2s
Code
import { SplitPane } from "@/components/equip/split-pane";
const mono = {
margin: 0,
padding: 12,
fontFamily: "var(--eq-font-mono)",
fontVariationSettings: "var(--eq-font-mono-settings)",
fontSize: "var(--eq-text-xs)",
lineHeight: 1.6,
} as const;
export default function Example() {
return (
<SplitPane
label="Editor"
orientation="vertical"
defaultSize={60}
min={20}
max={85}
style={{ height: 220, maxWidth: 360, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-m)" }}
>
<pre style={mono}>{"export function total(items) {\n return items.reduce((sum, item) => sum + item.price, 0);\n}"}</pre>
<pre style={{ ...mono, color: "var(--eq-ink-2)" }}>
{"$ pnpm test\n PASS cart.test.ts (4 tests)\n Done in 1.2s"}
</pre>
</SplitPane>
);
}Collapsible sidebar, controlled
Double click the divider or press Enter on it to tuck the sidebar away, and again to bring it back at its old width.
Quarterly planning notes from Ana, 3 attachments
Code
"use client";
import { useState } from "react";
import { SplitPane } from "@/components/equip/split-pane";
export default function Example() {
const [size, setSize] = useState(30);
return (
<div style={{ display: "grid", gap: 8, maxWidth: 420 }}>
<SplitPane
label="Inbox folders"
size={size}
onSizeChange={setSize}
min={8}
max={50}
collapsible
style={{ height: 160, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-m)" }}
>
<nav aria-label="Folders" style={{ padding: 12, fontSize: "var(--eq-text-s)", lineHeight: 1.9 }}>
Inbox
<br />
Drafts
<br />
Sent
<br />
Archive
</nav>
<div style={{ padding: 12, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>
Quarterly planning notes from Ana, 3 attachments
</div>
</SplitPane>
<span style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>Sidebar width {Math.round(size)}%</span>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the first panel. The divider carries this name, as the window splitter pattern asks. |
orientation | "horizontal" | "vertical" | "horizontal" | horizontal puts the panels side by side with an upright divider; vertical stacks them. |
size | number | none | Size of the first panel in percent, when you control it. |
defaultSize | number | 50 | Starting size of the first panel in percent. |
onSizeChange | (size: number) => void | none | Called with the new size in percent, to one decimal. |
min | number | 10 | Smallest size of the first panel, in percent. |
max | number | 90 | Largest size of the first panel, in percent. |
collapsible | boolean | false | Double click or Enter on the divider sends the first panel to min, and again restores it. |
children | [ReactNode, ReactNode] | none | Exactly two: the first panel and the second. |
...rest | ComponentProps<"div"> | none | Every native div attribute. Give it a height when stacked. |
Anatomy
| root | .eq-split-pane | A grid; the first panel's size is the --_size custom property. |
|---|---|---|
| panel | .eq-split-pane-panel | Each of the two panels. They scroll on their own. |
| divider | .eq-split-pane-divider | role="separator": a 1px line with an invisible grip, 10px for a mouse and 24px for touch, that thickens on hover and focus. |
Keyboard
| ArrowLeftArrowRight | Shrinks or grows the first panel by 2% when the panels sit side by side. |
|---|---|
| ArrowUpArrowDown | The same when the panels are stacked. |
| Shift | Held with an arrow key, moves by 10%. |
| HomeEnd | Sends the first panel to min or max. |
| Enter | With collapsible, collapses the first panel to min and restores it. |
Notes
- The divider follows the APG window splitter pattern: it is focusable, carries aria-valuenow in percent and controls the first panel.
- aria-orientation on the divider is the line's own direction, so side by side panels have a vertical separator.
- Dragging captures the pointer, so the drag continues when the pointer leaves the divider, and works with touch and pen.
- Sizes are percentages of the root, so the split keeps its proportion when the window resizes.
- The grip is 10px wide under a mouse so it stays clear of the first panel's scrollbar, and 24px on touch screens.
- Key presses and collapsing move the divider on the spring; dragging follows the pointer exactly.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/split-pane.tsx144 lines
"use client";
import {
Children,
useId,
useRef,
useState,
type ComponentProps,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import "./split-pane.css";
export type SplitPaneProps = Omit<ComponentProps<"div">, "children"> & {
/** Names the first panel. The divider carries this name, as the window splitter pattern asks. */
label: string;
/** "horizontal" puts the panels side by side; "vertical" stacks them. */
orientation?: "horizontal" | "vertical";
/** Size of the first panel in percent, when you control it. */
size?: number;
/** Starting size of the first panel in percent. */
defaultSize?: number;
onSizeChange?: (size: number) => void;
/** Smallest size of the first panel, in percent. */
min?: number;
/** Largest size of the first panel, in percent. */
max?: number;
/** Double click or Enter on the divider sends the first panel to min and back. */
collapsible?: boolean;
/** Exactly two children: the first panel and the second. */
children: [ReactNode, ReactNode];
};
export function SplitPane({
label,
orientation = "horizontal",
size,
defaultSize = 50,
onSizeChange,
min = 10,
max = 90,
collapsible = false,
className,
style,
children,
...rest
}: SplitPaneProps) {
const id = useId();
const root = useRef<HTMLDivElement>(null);
const restore = useRef<number | null>(null);
const grab = useRef(0);
const [inner, setInner] = useState(defaultSize);
const [dragging, setDragging] = useState(false);
const clamp = (value: number) => Math.min(max, Math.max(min, value));
const current = clamp(size ?? inner);
const [first, second] = Children.toArray(children);
const side = orientation === "horizontal";
function resize(next: number) {
const value = Math.round(clamp(next) * 10) / 10;
if (value === current) return;
if (size === undefined) setInner(value);
onSizeChange?.(value);
}
function toggle() {
if (!collapsible) return;
if (current > min) {
restore.current = current;
resize(min);
} else {
resize(restore.current ?? Math.max(defaultSize, min + 1));
restore.current = null;
}
}
function fromPointer(event: PointerEvent<HTMLDivElement>) {
const box = root.current?.getBoundingClientRect();
if (!box) return;
const at = (side ? event.clientX : event.clientY) - grab.current;
const ratio = side ? (at - box.left) / box.width : (at - box.top) / box.height;
resize(ratio * 100);
}
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
if (event.button !== 0) return;
event.preventDefault();
// Remember where on the grip it was taken, so the line does not jump to the pointer.
const line = event.currentTarget.getBoundingClientRect();
grab.current = side ? event.clientX - line.left : event.clientY - line.top;
event.currentTarget.focus();
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const step = event.shiftKey ? 10 : 2;
const less = side ? "ArrowLeft" : "ArrowUp";
const more = side ? "ArrowRight" : "ArrowDown";
if (event.key === less) resize(current - step);
else if (event.key === more) resize(current + step);
else if (event.key === "Home") resize(min);
else if (event.key === "End") resize(max);
else if (event.key === "Enter" && collapsible) toggle();
else return;
event.preventDefault();
}
return (
<div
ref={root}
{...rest}
className={["eq-split-pane", className].filter(Boolean).join(" ")}
data-orientation={orientation}
data-dragging={dragging || undefined}
style={{ "--_size": `${current}%`, ...style } as CSSProperties}
>
<div id={id} className="eq-split-pane-panel">
{first}
</div>
<div
role="separator"
tabIndex={0}
aria-label={label}
aria-controls={id}
aria-orientation={side ? "vertical" : "horizontal"}
aria-valuenow={Math.round(current)}
aria-valuemin={min}
aria-valuemax={max}
className="eq-split-pane-divider"
onPointerDown={handlePointerDown}
onPointerMove={(event) => {
if (dragging) fromPointer(event);
}}
onLostPointerCapture={() => setDragging(false)}
onDoubleClick={toggle}
onKeyDown={handleKeyDown}
/>
<div className="eq-split-pane-panel">{second}</div>
</div>
);
}components/equip/split-pane.css90 lines
@layer equip {
.eq-split-pane {
--_size: 50%;
--_hit: 10px;
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, var(--_size)) 1px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
min-width: 0;
min-height: 0;
color: var(--eq-ink);
/* The first panel travels to its new size on a key press or a collapse. */
transition: grid-template-columns var(--eq-dur-move) var(--eq-spring);
}
.eq-split-pane[data-orientation="vertical"] {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, var(--_size)) 1px minmax(0, 1fr);
transition: grid-template-rows var(--eq-dur-move) var(--eq-spring);
}
/* Under the pointer it follows the hand exactly. */
.eq-split-pane[data-dragging] {
transition: none;
cursor: col-resize;
user-select: none;
}
.eq-split-pane[data-orientation="vertical"][data-dragging] {
cursor: row-resize;
}
.eq-split-pane-panel {
min-width: 0;
min-height: 0;
overflow: auto;
}
.eq-split-pane-divider {
position: relative;
z-index: 1;
background: var(--eq-line);
cursor: col-resize;
touch-action: none;
transition:
background-color var(--eq-dur) var(--eq-ease),
box-shadow var(--eq-dur) var(--eq-ease);
}
.eq-split-pane[data-orientation="vertical"] .eq-split-pane-divider {
cursor: row-resize;
}
/* Fingers get the full 24px. */
@media (pointer: coarse) {
.eq-split-pane {
--_hit: 24px;
}
}
/* A wider invisible grip around the 1px line. */
.eq-split-pane-divider::before {
content: "";
position: absolute;
inset: 0 calc((var(--_hit) - 1px) / -2);
}
.eq-split-pane[data-orientation="vertical"] .eq-split-pane-divider::before {
inset: calc((var(--_hit) - 1px) / -2) 0;
}
@media (hover: hover) {
.eq-split-pane-divider:hover {
background: var(--eq-line-strong);
box-shadow: 0 0 0 1px var(--eq-line-strong);
}
}
.eq-split-pane[data-dragging] .eq-split-pane-divider,
.eq-split-pane-divider:focus-visible {
background: var(--eq-ink-3);
box-shadow: 0 0 0 1px var(--eq-ink-3);
}
.eq-split-pane-divider:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
}