A master and detail layout with a keyboard-resizable divider. Panes sit side by side in wide containers and stack with a Back button in narrow ones.
"use client";
import * as React from "react";
import { Inbox, Star } from "lucide-react";
import { SplitView, SplitViewBack, SplitViewDetail, SplitViewList, useSplitView } from "@/components/ballmac/split-view";
const messages = [
{ id: "1", from: "Ana Lima", subject: "Launch checklist", time: "9:41", body: "Here is the final checklist for Friday. Copy is approved and the screenshots are attached. Can you confirm the rollout window?" },
{ id: "2", from: "Kofi Mensah", subject: "Invoice INV-2044", time: "8:15", body: "Attached is the invoice for September. Let me know if anything needs correcting before it goes out." },
{ id: "3", from: "Mei Tanaka", subject: "Design review notes", time: "Yesterday", body: "Thanks for the walkthrough. Two small notes on spacing in the pricing table, otherwise we are good." },
{ id: "4", from: "Sam Okafor", subject: "Team offsite", time: "Mon", body: "Voting is open for the offsite dates. Please pick the days that work for you by Wednesday." },
];
function List({ selected, onPick }: { selected: string; onPick: (id: string) => void }) {
const { setDetailOpen } = useSplitView();
return (
<ul className="divide-y">
{messages.map((m) => (
<li key={m.id}>
<button
type="button"
aria-current={selected === m.id ? "true" : undefined}
onClick={() => {
onPick(m.id);
setDetailOpen(true);
}}
className="grid w-full gap-0.5 px-4 py-3 text-start outline-none transition-colors hover:bg-accent/60 focus-visible:bg-accent focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50 aria-[current=true]:bg-accent"
>
<span className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-semibold">{m.from}</span>
<span className="shrink-0 text-xs text-muted-foreground">{m.time}</span>
</span>
<span className="truncate text-sm">{m.subject}</span>
<span className="line-clamp-1 text-xs text-muted-foreground">{m.body}</span>
</button>
</li>
))}
</ul>
);
}
export default function SplitViewDemo() {
const [selected, setSelected] = React.useState("1");
const message = messages.find((m) => m.id === selected)!;
return (
<div className="h-80 w-full max-w-3xl overflow-hidden rounded-xl border bg-card shadow-sm">
<SplitView>
<SplitViewList label="Messages">
<div className="flex h-11 items-center gap-2 border-b px-4 text-sm font-semibold">
<Inbox aria-hidden="true" className="size-4" /> Inbox
</div>
<List selected={selected} onPick={setSelected} />
</SplitViewList>
<SplitViewDetail label="Message">
<div className="flex h-11 items-center gap-2 border-b px-3">
<SplitViewBack>Inbox</SplitViewBack>
<span className="ms-auto text-muted-foreground"><Star aria-hidden="true" className="size-4" /></span>
</div>
<article className="grid gap-2 p-5">
<h3 className="text-lg font-semibold tracking-tight">{message.subject}</h3>
<p className="text-xs text-muted-foreground">From {message.from} · {message.time}</p>
<p className="mt-2 text-sm leading-relaxed">{message.body}</p>
</article>
</SplitViewDetail>
</SplitView>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/split-viewInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18n @ballmac/directionCopy the source into your project.
components/ballmac/split-view.tsx// Ballmac UI: Split View. https://ui.ballmac.com/components/split-view "use client"; import * as React from "react"; import { ArrowLeft } from "lucide-react"; import { cn } from "@/lib/utils"; import { useMessages } from "@/lib/ballmac/i18n"; import { useDirection } from "@/lib/ballmac/direction"; type SplitViewContextValue = { detailOpen: boolean; setDetailOpen: (open: boolean) => void; }; const SplitViewContext = React.createContext<SplitViewContextValue | null>(null); /** Read or change whether the detail pane is showing on narrow screens. */ function useSplitView() { const context = React.useContext(SplitViewContext); if (!context) throw new Error("useSplitView must be used inside <SplitView>"); return context; } type SplitViewProps = React.ComponentProps<"div"> & { /** Controlled: show the detail pane when the container is narrow. */ detailOpen?: boolean; /** Initial state when uncontrolled. */ defaultDetailOpen?: boolean; /** Called when the detail pane opens or closes on narrow screens. */ onDetailOpenChange?: (open: boolean) => void; /** Initial width of the list pane in pixels when both panes show. */ listWidth?: number; /** Smallest width the list pane can be dragged to. */ minListWidth?: number; /** Largest width the list pane can be dragged to. */ maxListWidth?: number; }; /** * A master and detail layout (mail, files, settings). Both panes show side by side when the container is wide; * below ~40rem it shows one at a time with a Back button. The divider resizes with the mouse, touch or arrow keys. */ function SplitView({ detailOpen: detailOpenProp, defaultDetailOpen = false, onDetailOpenChange, listWidth = 300, minListWidth = 220, maxListWidth = 520, className, style, children, ...props }: SplitViewProps) { const dir = useDirection() const msg = useMessages() const [inner, setInner] = React.useState(defaultDetailOpen); const detailOpen = detailOpenProp ?? inner; const setDetailOpen = React.useCallback( (open: boolean) => { if (detailOpenProp === undefined) setInner(open); onDetailOpenChange?.(open); }, [detailOpenProp, onDetailOpenChange], ); const [width, setWidth] = React.useState(listWidth); const clamp = React.useCallback((w: number) => Math.min(maxListWidth, Math.max(minListWidth, w)), [minListWidth, maxListWidth]); const value = React.useMemo(() => ({ detailOpen, setDetailOpen }), [detailOpen, setDetailOpen]); const drag = React.useRef<{ x: number; w: number } | null>(null); return ( <SplitViewContext.Provider value={value}> <div data-slot="split-view" data-detail-open={detailOpen || undefined} className={cn("@container/split group/split h-full min-h-0 w-full overflow-hidden", className)} {...props} > <div style={{ "--split-list-w": `${width}px`, ...style } as React.CSSProperties} className="grid h-full min-h-0 w-full grid-cols-1 @2xl/split:grid-cols-[var(--split-list-w)_auto_minmax(0,1fr)]" > {children} <div role="separator" aria-orientation="vertical" aria-label={msg("split-view.resizeList", "Resize list")} aria-valuemin={minListWidth} aria-valuemax={maxListWidth} aria-valuenow={width} tabIndex={0} data-slot="split-view-divider" onPointerDown={(event) => { event.currentTarget.setPointerCapture(event.pointerId); drag.current = { x: event.clientX, w: width }; }} onPointerMove={(event) => { if (drag.current) setWidth(clamp(drag.current.w + (dir === "rtl" ? -1 : 1) * (event.clientX - drag.current.x))); }} onPointerUp={() => (drag.current = null)} onKeyDown={(event) => { const step = event.shiftKey ? 64 : 16; if (event.key === "ArrowLeft") setWidth((w) => clamp(w + (dir === "rtl" ? step : -step))); else if (event.key === "ArrowRight") setWidth((w) => clamp(w + (dir === "rtl" ? -step : step))); else if (event.key === "Home") setWidth(minListWidth); else if (event.key === "End") setWidth(maxListWidth); else return; event.preventDefault(); }} className="group/divider relative col-start-2 row-start-1 hidden w-px cursor-col-resize touch-none bg-border outline-none transition-colors after:absolute after:inset-y-0 after:-inset-x-1.5 hover:bg-ring/60 focus-visible:bg-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 @2xl/split:block motion-reduce:transition-none" /> </div> </div> </SplitViewContext.Provider> ); } type SplitViewPaneProps = React.ComponentProps<"section"> & { /** Accessible name of the pane. */ label?: string; }; /** The list (master) pane. Hidden on narrow containers while the detail pane is open. */ function SplitViewList({ label, className, ...props }: SplitViewPaneProps) { const msg = useMessages() label ??= msg("split-view.label", "List") return ( <section aria-label={label} data-slot="split-view-list" className={cn( "col-start-1 row-start-1 min-h-0 min-w-0 overflow-y-auto @max-2xl/split:group-data-[detail-open]/split:hidden", className, )} {...props} /> ); } /** The detail pane. Hidden on narrow containers until something is opened. */ function SplitViewDetail({ label, className, ...props }: SplitViewPaneProps) { const msg = useMessages() label ??= msg("split-view.label2", "Detail") return ( <section aria-label={label} data-slot="split-view-detail" className={cn( "col-start-1 row-start-1 hidden min-h-0 min-w-0 overflow-y-auto group-data-[detail-open]/split:block @2xl/split:col-start-3 @2xl/split:block", className, )} {...props} /> ); } type SplitViewBackProps = React.ComponentProps<"button">; /** Returns to the list on narrow containers. Not rendered visibly when both panes are showing. */ function SplitViewBack({ className, children = "Back", onClick, ...props }: SplitViewBackProps) { const { setDetailOpen } = useSplitView(); return ( <button type="button" data-slot="split-view-back" onClick={(event) => { onClick?.(event); setDetailOpen(false); }} className={cn( "inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sm font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 @2xl/split:hidden", className, )} {...props} > <ArrowLeft aria-hidden="true" className="size-4 rtl:rotate-180" /> {children} </button> ); } export { SplitView, SplitViewList, SplitViewDetail, SplitViewBack, useSplitView, type SplitViewProps, type SplitViewPaneProps, type SplitViewBackProps, };Update the import paths to match your project setup.
Usage
import { SplitView, SplitViewList, SplitViewDetail, SplitViewBack, useSplitView } from "@/components/ballmac/split-view"The full example is in the Code tab above.
Examples
Stacked on narrow
"use client";
import { SplitView, SplitViewBack, SplitViewDetail, SplitViewList, useSplitView } from "@/components/ballmac/split-view";
const files = ["Brand guidelines.pdf", "Launch checklist.md", "Pricing model.xlsx"];
function Files() {
const { setDetailOpen } = useSplitView();
return (
<ul className="divide-y">
{files.map((f) => (
<li key={f}>
<button type="button" onClick={() => setDetailOpen(true)} className="w-full px-4 py-3 text-start text-sm outline-none hover:bg-accent/60 focus-visible:bg-accent focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50">
{f}
</button>
</li>
))}
</ul>
);
}
export default function SplitViewStates() {
return (
<div className="h-56 w-full max-w-xs overflow-hidden rounded-xl border bg-card">
<SplitView>
<SplitViewList label="Files">
<Files />
</SplitViewList>
<SplitViewDetail label="File details">
<div className="p-3">
<SplitViewBack>Files</SplitViewBack>
<p className="mt-3 px-2 text-sm text-muted-foreground">In a narrow container the panes stack: choose a file, then go back.</p>
</div>
</SplitViewDetail>
</SplitView>
</div>
);
}API reference
<SplitView>
| Prop | Type | Default |
|---|---|---|
detailOpenControlled: show the detail pane when the container is narrow. | boolean | — |
defaultDetailOpenInitial state when uncontrolled. | boolean | false |
onDetailOpenChangeCalled when the detail pane opens or closes on narrow screens. | (open: boolean) => void | — |
listWidthInitial width of the list pane in pixels when both panes show. | number | 300 |
minListWidthSmallest width the list pane can be dragged to. | number | 220 |
maxListWidthLargest width the list pane can be dragged to. | number | 520 |
<SplitViewPane>
| Prop | Type | Default |
|---|---|---|
labelAccessible name of the pane. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| ArrowLeft / ArrowRight | Resize the list from the divider; Shift for larger steps; Home and End for the limits |
| Tab | Back button appears only when panes stack |
| Screen readers | Panes are labelled regions; the divider is a separator with value bounds |
Use with AI
SplitView > SplitViewList, SplitViewDetail and SplitViewBack. Use useSplitView().setDetailOpen(true) when an item is chosen. Responds to its own width, not the screen's. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Split View (@ballmac/split-view) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Mail, files and settings screens
- Any list that opens a detail
Not for
- User-arranged dock panels; use resizable
- Overlay details; use sheet or drawer
Registry JSON: https://ui.ballmac.com/r/split-view.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Resizable
Draggable split panels with keyboard resizing, nested horizontal and vertical groups, collapsible panels, and a grip that appears on hover and focus.
Scroll Area
A focusable, named scroll region with theme-aware custom thumb and native scrolling behavior for long lists and documents.
Sidebar
An app sidebar with icon-rail collapse, floating and inset variants, grouped menus with badges and sub-items, a mobile sheet, tooltips when collapsed and a Cmd/Ctrl+B shortcut.
App Shell
The frame of an application page: skip link, sticky header, sidebar that becomes a sheet on small screens, main area, optional aside and footer.