All components
Splitter
Resizable panel dividers with a separator line and a raised drag handle.
1234567891011121314import { Splitter } from "#components/ui/base-ui/splitter.tsx";
export function Example() { return ( // Base UI has no splitter machine; the component carries the percentage // sizes itself (size / defaultSize / onSizeChange) and resizes the // adjacent pair with the arrow keys from the focused trigger <Splitter.Root defaultSize={[50, 50]}> <Splitter.Panel index={0}>{/* panel a */}</Splitter.Panel> <Splitter.ResizeTrigger index={0} /> <Splitter.Panel index={1}>{/* panel b */}</Splitter.Panel> </Splitter.Root> );}Install
1npx stylexblocks add splitterSource
components/ui/base-ui/splitter.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290import * as stylex from "@stylexjs/stylex";import { createContext, useContext, useMemo, useState } from "react";import type { CSSProperties } from "react";import { mergeProps } from "@base-ui-components/react/merge-props";import { useRender } from "@base-ui-components/react/use-render";import type { WithStyleOverrides } from "#lib/recipe-props.ts";import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { radii } from '@stylexblocks/ui/tokens/radii.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticShadows } from '@stylexblocks/ui/semantic-tokens/shadows.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'
const splitterResizeTrigger = stylex.create({ base: { '--splitter-border-color': { default: semanticColors.border, ':focus': semanticColors.borderEmphasized, }, '--splitter-thumb-color': { default: semanticColors.bg, ':focus': colorPalette.subtle, }, '--splitter-thumb-size': sizes._2, '--splitter-thumb-inset': 'calc(var(--splitter-thumb-size) * -0.5)', '--splitter-border-size': '1px', '--splitter-handle-size': sizes._6, outline: 'none', display: 'grid', placeItems: 'center', position: 'relative', },})
const splitterResizeTriggerSeparator = stylex.create({ base: { position: 'absolute', backgroundColor: 'var(--splitter-border-color)', },})
const splitterResizeTriggerIndicator = stylex.create({ base: { position: 'relative', borderRadius: radii.full, backgroundColor: 'var(--splitter-thumb-color)', boxShadow: semanticShadows.xs, borderWidth: '1px', zIndex: 1, },})
// The generated recipe styles the trigger's colors and vars; the trigger's// browser-button reset and the orientation-dependent geometry (a horizontal// splitter renders a vertical divider, and vice versa) live with the// component.const splitterStatic = stylex.create({ root: { display: "flex", width: "100%", height: "100%" }, rootVertical: { flexDirection: "column" }, panel: { minWidth: 0, minHeight: 0, flexGrow: 1, flexShrink: 1 }, trigger: { appearance: "none", borderWidth: 0, borderStyle: "none", backgroundColor: "transparent", padding: 0, margin: 0, flexShrink: 0, }, triggerVertical: { width: "9px" }, triggerHorizontal: { height: "9px" }, separatorVertical: { top: 0, bottom: 0, left: "calc(50% - 0.5px)", width: "var(--splitter-border-size)", }, separatorHorizontal: { left: 0, right: 0, top: "calc(50% - 0.5px)", height: "var(--splitter-border-size)", }, indicator: { borderStyle: "solid", borderColor: semanticColors.border, }, indicatorVertical: { width: "var(--splitter-thumb-size)", height: "var(--splitter-handle-size)", }, indicatorHorizontal: { height: "var(--splitter-thumb-size)", width: "var(--splitter-handle-size)", },});
// Base UI has no splitter machine; the component carries the panel sizes// (percentages) itself and resizes on arrow keys from the trigger.interface SplitterState { orientation: "horizontal" | "vertical"; value: number[]; resize: (index: number, delta: number) => void;}
const SplitterContext = createContext<SplitterState>({ orientation: "horizontal", value: [50, 50], resize: () => {},});
/** * Splitter — the generated recipe composed through Base UI's `useRender` / * `mergeProps` primitives with controlled panel sizes. `<Splitter.Root>` * holds percentage sizes (`size` / `defaultSize` / `onSizeChange`); * `<Splitter.Panel index>` reads its share; `<Splitter.ResizeTrigger index>` * renders the divider and resizes the adjacent pair with the arrow keys. * Composition per part via `render`. */export type SplitterRootProps = WithStyleOverrides<useRender.ComponentProps<"div">> & { orientation?: "horizontal" | "vertical"; /** Controlled percentage sizes, one per panel. */ size?: number[]; /** Initial percentage sizes when uncontrolled. */ defaultSize?: number[]; onSizeChange?: (details: { size: number[] }) => void; /** Smallest percentage a panel may shrink to (default 20). */ minSize?: number; };
function Root(allProps: SplitterRootProps) { const { render, orientation = "horizontal", size, defaultSize, onSizeChange, minSize = 20, palette, style, ...rest } = allProps; const [innerSize, setInnerSize] = useState(defaultSize ?? [50, 50]); const sizes = size ?? innerSize; const state = useMemo<SplitterState>( () => ({ orientation, value: sizes, resize: (index, delta) => { const a = sizes[index]; const b = sizes[index + 1]; if (a === undefined || b === undefined) return; const clamped = Math.max( minSize, Math.min(a + b - minSize, a + delta), ); const next = [...sizes]; next[index] = clamped; next[index + 1] = a + b - clamped; setInnerSize(next); onSizeChange?.({ size: next }); }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [orientation, sizes.join(","), minSize], ); const element = useRender({ defaultTagName: "div", render: render as useRender.RenderProp, state: {}, props: mergeProps( { "data-orientation": orientation, ...stylex.props( palette, splitterStatic.root, orientation === "vertical" && splitterStatic.rootVertical, style, ), }, rest as Record<string, unknown>, ), }); return ( <SplitterContext.Provider value={state}> {element} </SplitterContext.Provider> );}Root.displayName = "SplitterRoot";
export type SplitterPanelProps = WithStyleOverrides<useRender.ComponentProps<"div">> & { /** Zero-based index of this panel in the root's sizes. */ index: number; };
function Panel(allProps: SplitterPanelProps) { const { render, index, palette, style, ...rest } = allProps; const splitter = useContext(SplitterContext); const p = stylex.props(palette, splitterStatic.panel, style); return useRender({ defaultTagName: "div", render: render as useRender.RenderProp, state: {}, props: mergeProps( { className: p.className, style: { ...p.style, flexBasis: `${splitter.value[index] ?? 0}%`, } as CSSProperties, }, rest as Record<string, unknown>, ), });}Panel.displayName = "SplitterPanel";
export type SplitterResizeTriggerProps = WithStyleOverrides<useRender.ComponentProps<"button">> & { /** Index of the panel before this trigger (default 0). */ index?: number; /** Percentage step per key press (default 5). */ step?: number; };
function ResizeTrigger(allProps: SplitterResizeTriggerProps) { const { render, index = 0, step = 5, palette, style, children, ...rest } = allProps; const splitter = useContext(SplitterContext); // a horizontal splitter lays panels in a row, so its divider is vertical const vertical = splitter.orientation === "horizontal"; const p = stylex.props( palette, splitterStatic.trigger, splitterResizeTrigger.base, vertical ? splitterStatic.triggerVertical : splitterStatic.triggerHorizontal, style, ); return useRender({ defaultTagName: "button", render: render as useRender.RenderProp, state: {}, props: mergeProps( { type: "button" as const, role: "separator", "aria-orientation": (vertical ? "vertical" : "horizontal") as | "vertical" | "horizontal", "aria-valuenow": Math.round(splitter.value[index] ?? 0), "aria-valuemin": 0, "aria-valuemax": 100, onKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => { const decrease = vertical ? "ArrowLeft" : "ArrowUp"; const increase = vertical ? "ArrowRight" : "ArrowDown"; if (event.key === decrease) splitter.resize(index, -step); if (event.key === increase) splitter.resize(index, step); }, className: p.className, style: p.style, children: ( <> <div {...stylex.props( splitterResizeTriggerSeparator.base, vertical ? splitterStatic.separatorVertical : splitterStatic.separatorHorizontal, )} /> <div {...stylex.props( splitterResizeTriggerIndicator.base, splitterStatic.indicator, vertical ? splitterStatic.indicatorVertical : splitterStatic.indicatorHorizontal, )} /> {children} </> ), }, rest as Record<string, unknown>, ), });}ResizeTrigger.displayName = "SplitterResizeTrigger";
export const Splitter = { Root, Panel, ResizeTrigger };