Two panes and the handle between them that sets how they share the space.
On this page
A splitter is two panes and the handle between them that sets how they share the space: a file list beside an editor, an editor over a terminal. The panes meet at a hairline. Under the pointer, or with focus, the line takes ink and a small grip appears on it.
A splitter is a component, not a layout, though layouts use it: its handle has a material, states and a keyboard contract. For two panes that need no resizing, see master-detail.
The stylesheet draws the splitter and lays it out; the splitterbehavior moves it. The behavior writes the first pane's size to --splitter-size on the container and the same number to the handle's aria-valuenow. Nothing else changes hands.
Splitter
<divclass="splitter sheet overflow-hiddenh(240)"style="--splitter-size: 36%"><divclass="splitter-pane p-mdtext-smfg-text-body"><pclass="m(0)fg-text-strong">Files</p><pclass="mt-smm(0)">theme.css</p><pclass="m(0)">master.css</p><pclass="m(0)">components/</p></div><divclass="splitter-handle"role="separator"tabindex="0"aria-label="Resize the file list"aria-valuenow="36"aria-valuemin="15"aria-valuemax="85"></div><divclass="splitter-pane p-mdtext-smfg-text-body"><pclass="m(0)fg-text-strong">Editor</p><pclass="mt-smm(0)">Drag the line between the panes, or focus it and press the arrow keys.</p></div></div><scripttype="module">import{ attach }from'@master/ui/behaviors'attach()</script>
Drag the line, or focus it and use the arrow keys, Home and End.
splitter takes the root, the element with the splitter class, and returns the function that detaches it. The handle it moves is the splitter-handle that is a child of the root, so a pane may hold a splitter of its own.
Dragging. On pointerdown it captures the pointer on the handle and, while the pointer moves, writes its place inside the container as a percentage.
The keys. An arrow of the handle's own axis moves it by a step; Home and End go to the least and the greatest size.
Each change sets --splitter-size on the root and aria-valuenow on the handle. The size stays between the handle's aria-valuemin and aria-valuemax, 0 and 100 when it has none.
Events. The handle dispatches input each time the size changes, and change when a drag ends or a key moved it. Both bubble. Read aria-valuenow on the handle.
Disabled. A handle with aria-disabled="true" does not move.
Option
What it sets
step
How far an arrow key moves the handle, in percent. 5 by default
The stylesheet keeps the first pane --splitter-min away from both ends whatever size is asked for, stops text selection while the handle is held, and switches the layout by the handle's orientation. Without the behavior the panes keep the share --splitter-size gives them and the handle does not move.
aria-orientation says which way the line runs. A separator is vertical by default, between panes side by side. With aria-orientation="horizontal" the panes stack, and the handle becomes a horizontal line; the container reads the attribute, so there is no modifier class to keep in step.
Stacked panes
<divclass="splitter sheet overflow-hiddenh(280)max-w(420)mx-auto"style="--splitter-size: 60%"><divclass="splitter-pane p-mdtext-smfg-text-body"><pclass="m(0)fg-text-strong">Editor</p></div><divclass="splitter-handle"role="separator"tabindex="0"aria-orientation="horizontal"aria-label="Resize the editor"aria-valuenow="60"aria-valuemin="15"aria-valuemax="85"></div><divclass="splitter-pane p-mdtext-smfg-text-body"><pclass="m(0)fg-text-strong">Terminal</p></div></div><scripttype="module">import{ attach }from'@master/ui/behaviors'attach()</script>
The handle is a focusable role="separator" with a label that names what it resizes, and aria-valuenow, aria-valuemin and aria-valuemax as percentages of the first pane. That makes it a window splitter to assistive technology.
The keyboard must do everything the pointer does: arrows to move, Home and End for the ends. The behavior covers these.
The line is one pixel in the layout with a 9px target across it, and 25px on touch screens, where the grip is always shown.
A pane scrolls by itself. Give a pane that scrolls a label and tabindex="0" when it holds no focusable content, so the keyboard can reach it.
The splitter component attaches the behavior and passes the size in and out. The two panes stay the page's own markup. Copy it into the project with npx master-ui add splitter; React, Svelte and Vue says how the components are built and used.
ui/splitter.tsx
'use client'import{ useRef, useState,type ComponentProps,type CSSProperties,type ReactNode }from'react'import{ splitter }from'@master/ui/behaviors/splitter.js'import{ useBehavior }from'./use-behavior'exportinterfaceSplitterPropsextendsOmit<ComponentProps<'div'>,'defaultValue'|'children'>{/** The first and the second pane. */start:ReactNodeend:ReactNode/** The first pane's size, in percent of the container. */value?:numberdefaultValue?:number/** Called as the handle moves. */onValueChange?:(value:number)=>void/** Called once a drag ends or a key moved the handle. */onValueCommit?:(value:number)=>void/** The least and the greatest size, in percent. */min?:numbermax?:number/** How far an arrow key moves the handle, in percent. */step?:number/** Stack the panes, with a handle that moves up and down. */stacked?:boolean/** The name of the handle: what it resizes. */label:stringdisabled?:boolean}/** Two panes and the handle between them. */exportfunctionSplitter({ start, end, value, defaultValue =50, onValueChange, onValueCommit, min =0, max =100, step =5, stacked, label, disabled, className, style,...root }:SplitterProps){const[first]=useState(value ?? defaultValue)const size =useRef(first)const ref =useBehavior(splitter,{ step },{ value, defaultValue,onValueChange:(next:number)=>{ size.current= next;onValueChange?.(next)},// The handle says change once a drag ends or a key moved it. A field in a pane says change too.on:{change:(event)=>{if((event.targetasElement).parentElement=== event.currentTarget&&(event.targetasElement).classList.contains('splitter-handle'))onValueCommit?.(size.current)}},})return(<divclassName={className ?`splitter ${className}`:'splitter'}style={{'--splitter-size':`${first}%`,...style }asCSSProperties}ref={ref}{...root}><divclassName="splitter-pane">{start}</div><divclassName="splitter-handle"role="separator"tabIndex={disabled ?-1:0}aria-label={label}aria-valuenow={Math.round(first)}aria-valuemin={min}aria-valuemax={max}aria-orientation={stacked ?'horizontal':undefined}aria-disabled={disabled ||undefined}/><divclassName="splitter-pane">{end}</div></div>)}
ui/Splitter.svelte
<scriptlang="ts"module>importtype{ Snippet }from'svelte'importtype{ HTMLAttributes }from'svelte/elements'exportinterfaceSplitterPropsextendsOmit<HTMLAttributes<HTMLDivElement>,'children'>{/** The first and the second pane. */start:Snippetend:Snippet/** The first pane's size, in percent of the container. Bind it, or leave it out: it starts at 50. */value?:number/** Called as the handle moves. */onValueChange?:(value:number)=>void/** Called once a drag ends or a key moved the handle. */onValueCommit?:(value:number)=>void/** The least and the greatest size, in percent. */min?:numbermax?:number/** How far an arrow key moves the handle, in percent. */step?:number/** Stack the panes, with a handle that moves up and down. */stacked?:boolean/** The name of the handle: what it resizes. */label:stringdisabled?:boolean}</script><scriptlang="ts">import{ untrack }from'svelte'import{ splitter }from'@master/ui/behaviors/splitter.js'import{ behavior }from'./behavior.svelte.js'let{ start, end, value =$bindable(), onValueChange, onValueCommit, min =0, max =100, step =5, stacked, label, disabled,class: className,...root }:SplitterProps=$props()const first =untrack(()=> value)??50// Svelte writes a style again whenever its owner changes it, so it writes the size the behavior holds.let size =$state(first)const attach =behavior(splitter,()=>({ step }),{value:()=> value,change:(next)=>{ value = size = next;onValueChange?.(next)},settled:(held)=>{ size = held },// The handle says change once a drag ends or a key moved it. A field in a pane says change too.on:{change:(event)=>{if((event.targetasElement).parentElement=== event.currentTarget&&(event.targetasElement).classList.contains('splitter-handle'))onValueCommit?.(size)}},})</script><!--@component Two panes and the handle between them.--><divclass={['splitter', className]}style:--splitter-size="{size}%"{@attach attach}{...root}><divclass="splitter-pane">{@renderstart()}</div><!-- A separator that takes focus is a control: the keys move it. --><!-- svelte-ignore a11y_no_noninteractive_tabindex --><divclass="splitter-handle"role="separator"tabindex={disabled ?-1:0}aria-label={label}aria-valuenow={Math.round(first)}aria-valuemin={min}aria-valuemax={max}aria-orientation={stacked ?'horizontal':undefined}aria-disabled={disabled ||undefined}></div><divclass="splitter-pane">{@renderend()}</div></div>
ui/Splitter.vue
<scriptsetuplang="ts">import{ useTemplateRef }from'vue'import{ splitter }from'@master/ui/behaviors/splitter.js'import{ useBehavior }from'./use-behavior'defineOptions({inheritAttrs:false})const{ min =0, max =100, step =5}=defineProps<{/** The least and the greatest size, in percent. */min?:numbermax?:number/** How far an arrow key moves the handle, in percent. */step?:number/** Stack the panes, with a handle that moves up and down. */stacked?:boolean/** The name of the handle: what it resizes. */label:stringdisabled?:boolean}>()/** The first pane's size, in percent of the container: `v-model`. It changes as the handle moves. */const model =defineModel<number>({default:50})const emit =defineEmits<{/** The size, once a drag ends or a key moved the handle. */valueCommit:[value:number]}>()defineSlots<{/** The first and the second pane. */start?:()=>unknownend?:()=>unknown}>()/* Two panes and the handle between them. */const first = model.valueconst root =useTemplateRef<HTMLDivElement>('root')const handle =useTemplateRef<HTMLDivElement>('handle')const current =useBehavior(root, splitter,()=>({ step }), model,{// The handle says change once a drag ends or a key moved it. A field in a pane says change too.change:(event)=>{if(event.target=== handle.value)emit('valueCommit',size())},})// Vue writes a style again each time it renders, so it writes the size the behavior holds, not the first one.constsize=()=>{const held =current()return held !==undefined&& Number.isFinite(held)? held : first}</script><template><divclass="splitter":style="{'--splitter-size':`${size()}%`}"ref="root"v-bind="$attrs"><divclass="splitter-pane"><slotname="start" /></div><divclass="splitter-handle"role="separator"ref="handle":tabindex="disabled ?-1:0":aria-label="label":aria-valuenow="Math.round(first)":aria-valuemin="min":aria-valuemax="max":aria-orientation="stacked ?'horizontal':undefined":aria-disabled="disabled ||undefined" /><divclass="splitter-pane"><slotname="end" /></div></div></template>
The container: first pane, handle, second pane, side by side or stacked by the handle's aria-orientation
splitter-pane
A pane; it scrolls by itself
splitter-handle
The focusable role="separator" between the panes: a hairline with a 9px target, 25px on touch screens. Its label names what it resizes; aria-valuenow, aria-valuemin and aria-valuemax are percentages of the first pane
/* * Splitter: two panes and the handle between them that sets how they share the space. * The stylesheet draws it and lays it out; a script moves it. The script writes the first pane's size to * --splitter-size and the same number to the handle's aria-valuenow, on pointer drag and on the arrow keys. * The handle is a role="separator" that can take focus. Its aria-orientation says which way the line runs: * vertical (the default) between panes side by side, horizontal between panes stacked. * The line is a hairline until it is touched; then a grip appears on it and the line takes ink. */@layer components {.splitter{--splitter-size:50%;--splitter-min:4rem;/* The first pane's track: the size asked for, kept --splitter-min away from both ends. */--splitter-track:clamp(var(--splitter-min),var(--splitter-size),calc(100%-var(--splitter-min)-1px));display:grid;grid-template-columns:var(--splitter-track)autominmax(0,1fr);min-width:0;min-height:0;&:has(.splitter-handle[aria-orientation="horizontal"]){grid-template-rows:var(--splitter-track)autominmax(0,1fr);grid-template-columns:none;}/* While the handle is held, nothing in the panes is selected by the drag. */&:has(.splitter-handle:active){user-select:none;-webkit-user-select:none;}}/* A pane scrolls by itself, so a long one never moves its neighbor. */.splitter-pane{min-width:0;min-height:0;overflow:auto;scrollbar-width:thin;scrollbar-color:var(--color-line-strong)transparent;}/* * The handle is one pixel of line in the layout, with a target of 9px across it, so the panes meet at * a hairline and the hand still finds it. */.splitter-handle{position:relative;z-index:1;width:1px;margin:0;padding:0;border:0;outline:none;background:var(--color-line-divider);cursor:col-resize;touch-action:none;transition: background-color var(--duration-quick)var(--easing-glide);/* The target, wider than the line. */&::before{content:'';position:absolute;inset:0-4px;}/* The grip: a small pill of sheet on the line, shown when the handle is touched or focused. */&::after{content:'';position:absolute;top:calc(50%-.75rem);left:-2.5px;width:6px;height:1.5rem;border-radius:999px;@apply--surface-sheet;box-shadow:var(--shadow-raised);opacity:0;scale:.6.8;transition-property: opacity, scale;transition-duration:var(--duration-quick);transition-timing-function:var(--easing-glide);}@media(hover:hover){&:hover{background:var(--color-line-mark);}&:hover::after{opacity:1;scale:1;}}&:is(:focus-visible,:active){background:var(--color-focus);}&:is(:focus-visible,:active)::after{opacity:1;scale:1;}&:focus-visible::after{@apply--focus-ring;}&[aria-orientation="horizontal"]{width:auto;height:1px;cursor:row-resize;&::before{inset:-4px0;}&::after{top:-2.5px;left:calc(50%-.75rem);width:1.5rem;height:6px;scale:.8.6;}&:is(:focus-visible,:active)::after{scale:1;}@media(hover:hover){&:hover::after{scale:1;}}}&[aria-disabled="true"]{background:var(--color-line-divider);cursor:default;pointer-events:none;}@media(pointer:coarse){&::before{inset:0-12px;}&[aria-orientation="horizontal"]::before{inset:-12px0;}/* Without a pointer to hover, the grip is always there to be found. */&::after{opacity:1;scale:1;}}@media(prefers-reduced-motion:reduce){&::after{scale:none;}}@media(forced-colors:active){background: CanvasText;&:is(:focus-visible,:active){background: Highlight;}&::after{border:1pxsolid CanvasText;background: Canvas;}}}}