Split view
A resizable list-and-detail pair that becomes one pane at a time on a phone.
Two panes and the upstream resize handle above 768px; below it, one pane at a time — the list, or the detail with a labelled back button when showDetail is set. The breakpoint is read with useMediaQuery rather than duplicated in CSS, so list and detail are rendered once and keep their state; useMediaQuery reads false on the server, so a server-rendered page paints the narrow layout first and settles into the split after hydration. Sizes are percentages of the group: react-resizable-panels reads a bare number as pixels, so they are passed through as percentage strings. Persistence is deliberately hand-rolled rather than using the library's useDefaultLayout, which defaults its storage to localStorage at call time and so cannot be called during a server render; storageKey is read once on mount and written back only for layouts the reader dragged. Panel ids are derived from storageKey (or from useId without one), so two split views on a page never collide. readLayout is exported and pure — it answers undefined for a missing key, malformed JSON, or anything that is not a map of panel id to number.
Install
npx shadcn@latest add @vibra/split-viewNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Badge } from "@/components/ui/badge"
import { RelativeTime } from "@/components/ui/relative-time"
import { SplitView } from "@/components/ui/split-view"
// A fixed reference instant, not Date.now(): a statically built page freezes
// the server's copy at build time, so a live clock here would read one thing in
// the HTML and another once the reader's own clock takes over. format-demo.tsx
// pins its instant for the same reason.
const NOW = new Date(2026, 8, 4, 12, 0, 0).getTime()
const THREADS = [
{
id: "t_401",
subject: "Refund for INV-2093",
from: "Priya Raman",
at: new Date(NOW - 12 * 60_000),
status: "Open",
body: "The duplicate charge on 2 September has been refunded to the card ending 4242. It should land within five business days.",
},
{
id: "t_400",
subject: "SSO is failing for new hires",
from: "Marcus Bell",
at: new Date(NOW - 3 * 60 * 60_000),
status: "Open",
body: "Six accounts created this morning cannot complete the single sign-on handshake. The assertion is missing the email claim.",
},
{
id: "t_399",
subject: "Raise the export row limit",
from: "Jonas Weber",
at: new Date(NOW - 26 * 60 * 60_000),
status: "Waiting",
body: "Weekly exports hit the 50,000 row cap every Monday. Can the workspace be moved to the higher tier?",
},
{
id: "t_398",
subject: "Webhook retries are duplicating orders",
from: "Lena Fischer",
at: new Date(NOW - 3 * 24 * 60 * 60_000),
status: "Closed",
body: "Every 502 from our endpoint created a second order. We have added idempotency keys on our side.",
},
]
export default function SplitViewDemo() {
const [activeId, setActiveId] = React.useState("t_401")
const [showDetail, setShowDetail] = React.useState(false)
const active = THREADS.find((thread) => thread.id === activeId) ?? THREADS[0]
return (
<div className="h-[360px] w-full max-w-3xl overflow-hidden rounded-lg border">
<SplitView
storageKey="vibra-split-view-demo"
defaultSize={38}
minSize={25}
maxSize={60}
showDetail={showDetail}
onBack={() => setShowDetail(false)}
list={
<ul className="divide-y">
{THREADS.map((thread) => (
<li key={thread.id}>
<button
type="button"
onClick={() => {
setActiveId(thread.id)
setShowDetail(true)
}}
aria-current={thread.id === active.id ? "true" : undefined}
className="flex w-full flex-col gap-0.5 px-3 py-2.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:bg-muted/60 aria-[current]:bg-accent"
>
<span className="truncate text-sm font-medium">{thread.subject}</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate">{thread.from}</span>
<span aria-hidden="true">·</span>
<RelativeTime date={thread.at} />
</span>
</button>
</li>
))}
</ul>
}
detail={
<article className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h3 className="truncate text-sm font-medium">{active.subject}</h3>
<span className="text-xs text-muted-foreground">
{active.from} · <RelativeTime date={active.at} />
</span>
</div>
<Badge variant="outline">{active.status}</Badge>
</div>
<p className="text-sm text-muted-foreground">{active.body}</p>
</article>
}
/>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| list | React.ReactNode | — | The master pane — a list of things to pick from. |
| detail | React.ReactNode | — | The detail pane — whatever the picked thing is. |
| defaultSize | number | 32 | The list pane's share of the group, as a percentage. |
| minSize | number | 20 | Smallest the list pane may be, as a percentage. |
| maxSize | number | 60 | Largest the list pane may be, as a percentage. |
| direction | "horizontal" | "vertical" | "horizontal" | Side by side, or stacked with the handle between them. |
| showDetail | boolean | false | Below md, shows the detail instead of the list. |
| onBack | () => void | — | Adds the small-screen back button above the detail. |
| className | string | — | Classes for the group, or for the single pane below md; the remaining div props are spread onto whichever of the two is the root. |
| storageKey | string | — | localStorage key the dragged sizes are remembered under. |
Dependencies
Source
"use client"
import * as React from "react"
import { ChevronLeftIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { useMediaQuery } from "@/hooks/use-media-query"
import { Button } from "@/components/ui/button"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
/** Below this the two panes take turns; above it they sit side by side. */
const WIDE = "(min-width: 768px)"
type Layout = Record<string, number>
/** The layout saved under `key` — a map of panel id to flex-grow — or undefined when there is none to trust. */
export function readLayout(key: string | undefined): Layout | undefined {
if (!key || typeof window === "undefined") return undefined
try {
const raw = window.localStorage.getItem(key)
if (!raw) return undefined
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined
const entries = Object.entries(parsed as Record<string, unknown>)
if (entries.length === 0 || entries.some(([, size]) => typeof size !== "number")) {
return undefined
}
return Object.fromEntries(entries) as Layout
} catch {
// A private window, a full store, or something else's key under ours.
return undefined
}
}
// The panes arrive as `list` and `detail`, so `children` would land beside the
// resizable group's own and break it.
export type SplitViewProps = Omit<React.ComponentProps<"div">, "children"> & {
/** The master pane — a list of things to pick from. */
list: React.ReactNode
/** The detail pane — whatever the picked thing is. */
detail: React.ReactNode
/** The list pane's share of the group, as a percentage. */
defaultSize?: number
minSize?: number
maxSize?: number
direction?: "horizontal" | "vertical"
/** Below md, shows the detail instead of the list. */
showDetail?: boolean
/** Adds the small-screen back button above the detail. */
onBack?: () => void
/** localStorage key the dragged sizes are remembered under. */
storageKey?: string
}
/** A resizable list-and-detail pair that becomes one pane at a time on a phone. */
function SplitView({
list,
detail,
defaultSize = 32,
minSize = 20,
maxSize = 60,
direction = "horizontal",
showDetail = false,
onBack,
className,
storageKey,
...props
}: SplitViewProps) {
const wide = useMediaQuery(WIDE)
// Read once, on mount, rather than every render: a re-read after a drag
// would hand the group a layout it has already moved on from.
const [storedLayout] = React.useState(() => readLayout(storageKey))
// Panel ids key the saved layout, so they are stable per storageKey; without
// one they only need to be unique on the page, which useId already is.
const fallbackId = React.useId()
const prefix = storageKey ?? fallbackId
if (!wide) {
return (
<div
data-slot="split-view"
data-direction={direction}
data-pane={showDetail ? "detail" : "list"}
className={cn("flex h-full min-h-0 w-full flex-col", className)}
{...props}
>
{showDetail ? (
<>
{onBack ? (
<div className="flex h-10 shrink-0 items-center border-b px-1.5">
<Button type="button" variant="ghost" size="sm" onClick={onBack}>
<ChevronLeftIcon data-icon="inline-start" className="rtl:rotate-180" />
Back
</Button>
</div>
) : null}
<div className="min-h-0 flex-1 overflow-auto">{detail}</div>
</>
) : (
<div className="min-h-0 flex-1 overflow-auto">{list}</div>
)}
</div>
)
}
return (
<ResizablePanelGroup
data-slot="split-view"
data-direction={direction}
orientation={direction}
defaultLayout={storedLayout}
onLayoutChanged={(layout, meta) => {
// Only what the reader chose is worth remembering; the library also
// reports layouts it computed itself, on mount and on window resize.
if (!storageKey || !meta.isUserInteraction) return
try {
window.localStorage.setItem(storageKey, JSON.stringify(layout))
} catch {
// Saving the split is a convenience, never a reason to break a drag.
}
}}
className={cn("h-full w-full", className)}
{...props}
>
{/* Sizes are percentages of the group; the primitive reads bare numbers
as pixels, so they go in as explicit percentage strings. */}
<ResizablePanel
id={`${prefix}-list`}
data-slot="split-view-list"
defaultSize={`${defaultSize}%`}
minSize={`${minSize}%`}
maxSize={`${maxSize}%`}
className="min-w-0"
>
{list}
</ResizablePanel>
<ResizableHandle withHandle aria-label="Resize panes" />
<ResizablePanel id={`${prefix}-detail`} data-slot="split-view-detail" className="min-w-0">
{detail}
</ResizablePanel>
</ResizablePanelGroup>
)
}
export { SplitView }