Split Pane
Two resizable panes: a draggable divider that snaps a pane closed, remembers its size and resets on double-click.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/split-pane.json'use client'
import { useId, useRef, useState } from 'react'
import './split-pane.css'
export interface SplitPaneProps {
direction?: 'horizontal' | 'vertical'
defaultSize?: number
min?: number
label?: string
children: [React.ReactNode, React.ReactNode]
onResize?: (size: number) => void
fill?: 'dark' | 'light'
className?: string
}
/** Two panes with a draggable, collapsible divider. */
export function SplitPane({ direction = 'horizontal', defaultSize = 40, min = 20, label = 'Sidebar', children, onResize, fill = 'dark', className }: SplitPaneProps) {
const [size, setSize] = useState(defaultSize)
const [last, setLast] = useState(defaultSize)
const [drag, setDrag] = useState(false)
const [anim, setAnim] = useState(false)
const box = useRef<HTMLDivElement>(null)
const pid = useId()
const h = direction === 'horizontal'
const apply = (v: number, animate = false) => {
let n = Math.max(0, Math.min(80, v))
if (n < min) n = n < min / 2 ? 0 : min
setAnim(animate); setSize(n); if (n > 0) setLast(n); onResize?.(n)
}
const at = (e: React.PointerEvent) => { const r = box.current!.getBoundingClientRect(); return h ? ((e.clientX - r.left) / r.width) * 100 : ((e.clientY - r.top) / r.height) * 100 }
const toggle = () => apply(size === 0 ? last || defaultSize : 0, true)
return (
<div ref={box} className={['sd-spn', className].filter(Boolean).join(' ')} data-fill={fill} data-dir={direction} data-drag={drag || undefined}>
<div id={pid} className="sd-spn__p" data-anim={anim || undefined} style={{ flexBasis: `${size}%`, flexShrink: 0 }} aria-hidden={size === 0 || undefined}>
{children[0]}
</div>
<div className="sd-spn__div" role="separator" tabIndex={0} aria-orientation={h ? 'vertical' : 'horizontal'} aria-controls={pid} aria-label={`Resize ${label}`}
aria-valuemin={0} aria-valuemax={80} aria-valuenow={Math.round(size)} data-drag={drag || undefined}
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); setDrag(true) }}
onPointerMove={(e) => { if (drag) apply(at(e)) }}
onPointerUp={() => setDrag(false)}
onDoubleClick={() => apply(defaultSize, true)}
onKeyDown={(e) => {
const k = { ArrowLeft: -5, ArrowUp: -5, ArrowRight: 5, ArrowDown: 5 }[e.key]
if (k) { e.preventDefault(); apply((size || 0) + k, true) }
if (e.key === 'Enter') { e.preventDefault(); toggle() }
}}>
<span className="sd-spn__grip" />
</div>
<div className="sd-spn__p" style={{ position: 'relative' }}>
{size === 0 && <button type="button" className="sd-spn__tab" onClick={toggle}>{label}</button>}
<span className="sd-spn__size" aria-hidden>{Math.round(size)}%</span>
{children[1]}
</div>
</div>
)
}.sd-spn { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-spn[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-spn :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-spn :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-spn .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-spn { display: flex; width: min(100%, 340px); height: 300px; border-radius: 16px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-spn[data-dir='vertical'] { flex-direction: column; }
.sd-spn__p { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
.sd-spn__p[data-anim] { transition: flex-basis 260ms cubic-bezier(0.3, 1.2, 0.4, 1); }
.sd-spn__p:last-child { flex: 1; }
.sd-spn__div { position: relative; flex: none; width: 9px; margin: 0 -4px; z-index: 1; display: grid; place-items: center; cursor: col-resize; touch-action: none; outline: 0; }
.sd-spn[data-dir='vertical'] .sd-spn__div { width: auto; height: 9px; margin: -4px 0; cursor: row-resize; }
.sd-spn__div::before { content: ''; position: absolute; inset: 0 4px; background: var(--line); transition: background-color 120ms; }
.sd-spn[data-dir='vertical'] .sd-spn__div::before { inset: 4px 0; }
.sd-spn__div:hover::before, .sd-spn__div[data-drag]::before, .sd-spn__div:focus-visible::before { background: var(--fg); }
.sd-spn__grip { position: relative; width: 5px; height: 26px; border-radius: 99px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-spn[data-dir='vertical'] .sd-spn__grip { width: 26px; height: 5px; }
.sd-spn__size { position: absolute; top: 8px; right: 8px; padding: 2px 7px; border-radius: 99px; background: var(--strong); color: var(--on); font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; opacity: 0; transition: opacity 150ms; pointer-events: none; }
.sd-spn[data-drag] .sd-spn__size { opacity: 1; }
.sd-spn__tab { position: absolute; z-index: 2; top: 50%; left: 4px; transform: translateY(-50%); padding: 8px 5px; border-radius: 8px; background: var(--soft) !important; font-size: 11px; writing-mode: vertical-rl; }
.sd-spn[data-dir='vertical'] .sd-spn__tab { top: 4px; left: 50%; transform: translateX(-50%); writing-mode: horizontal-tb; padding: 4px 10px; }
.sd-spn__list { padding: 10px; font-size: 13px; }
.sd-spn__list p { margin: 0; padding: 8px 9px; border-radius: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sd-spn__list p:first-child { background: var(--soft); font-weight: 500; }
.sd-spn__doc { padding: 14px; font-size: 13px; line-height: 1.55; color: var(--mute); }
.sd-spn__doc b { display: block; margin-bottom: 6px; color: var(--fg); font-size: 15px; }
@media (prefers-reduced-motion: reduce) { .sd-spn *, .sd-spn *::before, .sd-spn *::after { animation: none !important; transition: none !important; } }Overview
Drag the divider to resize; drag a pane below its minimum and it collapses with a snap. Double-click the divider to restore the default split.
Works horizontally or vertically. The divider is a proper separator you can focus and move with the arrow keys.
When to use
- Editors, mail clients, master–detail layouts on wide screens.
When not to use
- Small phones: stack the content instead.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/split-pane.json$
npx shadcn@latest add https://sundaeui.dev/r/split-pane.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/split-pane.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/split-pane.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy split-pane.tsx and split-pane.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { SplitPane } from '@/components/sundae/split-pane'
<SplitPane />Props
| Prop | Type | Default |
|---|---|---|
directionSplit axis. | 'horizontal' | 'vertical' | 'horizontal' |
defaultSizeFirst pane size in percent. | number | 40 |
minMin size before collapsing, percent. | number | 20 |
children*The two panes. | [ReactNode, ReactNode] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The divider is role="separator" with aria-valuenow in percent and aria-controls the first pane.
- Enter toggles collapse; arrows move it 5%.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-spn; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / → or ↑ / ↓
- Move the divider
- Enter
- Collapse / restore the first pane
- Double-click
- Reset
FAQ
- Is Split Pane free?
- Yes. Split Pane is free for personal and commercial projects, with full source on this page.
- How do I install Split Pane?
- Run npx shadcn@latest add https://sundaeui.dev/r/split-pane.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Split Pane support keyboard and screen readers?
- The divider is role="separator" with aria-valuenow in percent and aria-controls the first pane. Enter toggles collapse; arrows move it 5%.
- Does Split Pane respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Grid / List View Switcher
Components · RefinedImage Cropper
Components · RefinedVideo Trimmer
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined