Split View React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React split view: a list and its detail side by side on wide screens, a push and pop stack with swipe back on phones.
On a wide screen pick a row and its detail fades in beside the list; both panes scroll on their own. Drag the hairline between them to resize the list (it follows the pointer and remembers the width with persistKey), double-click it to reset; focus it with Tab and use Left / Right to move it by 16px, Home / End for the limits and Enter to reset. Below the breakpoint the list fills the screen and a picked row pushes its detail in from the side while the list slides back under a dim; go back with the glass back bar, Escape, or by dragging the detail toward the edge with a finger (it follows the finger and completes past a quarter of the width or on a flick). Focus moves into the detail on push and back to the row on pop.
Split View is a Pro React component in the React layout components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add split-view, together with UIProvider, Button, usePersistedState, useScrollState and Motion presets and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add split-viewSplit View is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import SplitView from @/components/ui/split-view.
import { useState } from "react"
import { SplitView } from "@/components/ui/split-view"
const clients = [
{ id: "tech", name: "Tech Solutions Inc.", city: "New York" },
{ id: "summit", name: "Summit Logistics", city: "Boston" },
{ id: "greenleaf", name: "Greenleaf Pharmacy", city: "Portland" },
]
export function Clients() {
const [selected, setSelected] = useState<string | null>(null)
const client = clients.find((c) => c.id === selected)
return (
<div className="h-dvh">
<SplitView
persistKey="crm:clients:split"
backLabel="Clients"
onBack={() => setSelected(null)}
list={
<ul className="p-2">
{clients.map((c) => (
<li key={c.id}>
<button type="button" onClick={() => setSelected(c.id)} className="w-full rounded-2xl px-3 py-2.5 text-start">
{c.name}
</button>
</li>
))}
</ul>
}
detail={
client && (
<article key={client.id} className="p-6">
<h2 className="text-lg font-semibold">{client.name}</h2>
<p className="text-sm text-muted-foreground">{client.city}</p>
</article>
)
}
/>
</div>
)
}Props
list*ReactNodeThe master pane: a list of clients, conversations or tickets. It scrolls on its own and keeps its scroll position while the detail is pushed over it.
detailReactNodeThe detail pane's content. Give the element a key (the item id): a new key fades the content in and scrolls the pane back to the top, with no push.
showDetaildefaultfalsebooleanWhether the detail is open (on phones: pushed). Unset, it follows Boolean(detail). True without a detail shows the empty state.
onBack() => voidPhones: called by the back button, a completed swipe and Escape. Clear the selection here (and call router.back() when the detail is a route).
defaultListWidthdefault360numberStarting width of the list column in px (also what a double-click or Enter on the divider resets to).
listWidthnumberControlled width of the list column in px. Pair it with onListWidthChange.
onListWidthChange(width: number) => voidFires once when a resize ends (pointer up, a key press, a reset), never per frame.
minListWidthdefault260numberThe narrowest the list can get, in px.
maxListWidthdefault560numberThe widest the list can get, in px. A drag also leaves the detail at least minListWidth wide.
resizabledefaulttruebooleanDrag the divider to resize; arrow keys, Home / End and Enter on the focused divider; double-click resets. Off, the divider is a plain hairline.
persistKeydefault""stringA storage key that remembers the list width across reloads (local storage). Empty keeps it in memory.
breakpointdefault"md""md" | "lg"Below this width of the split view itself the panes stack: md (768px) or lg (1024px, tablets and narrow app-shell columns stack too). It is a CSS container query, so the server render is right and it also stacks inside a phone frame.
emptyTextdefault"Select an item to see it here"ReactNodeThe placeholder text in the detail pane while nothing is selected (wide screens).
emptyIconReactNodeThe placeholder icon. Defaults to MousePointerClick in a soft tile.
emptyDetailReactNodeReplaces the whole placeholder, e.g. a summary of the list or a call to action.
swipeBackdefaulttruebooleanPhones: drag the detail toward the end to go back. It starts anywhere on the pane after an 8px horizontal slop, never inside [data-no-swipe], inputs or horizontal scrollers.
showBackButtondefaulttruebooleanPhones: a sticky glass back bar at the top of the detail. Turn it off and put SplitViewBackButton (Button props plus label, default "Back"; calls onBack and hides itself while the panes sit side by side) in your own header. useSplitView() inside the split view returns { mode: "split" | "stack", showDetail, back }.
backLabeldefault"Back"ReactNodeThe back button's text. Use the list's name, like "Clients", as iOS does.
resizeLabeldefault"Resize list"stringThe accessible name of the divider.
bordereddefaulttruebooleanThe hairline between the panes. Off, the divider is invisible until hovered or dragged.
classNamesPartial<Record<"list" | "detail" | "divider" | "back" | "empty", string>>Extra classes for the inner parts: the two scrolling panes, the divider, the phone back bar and the placeholder.
classNamestringExtra classes for the root. It fills its parent's height (h-full), so give the parent a height.
Parts
SplitViewBackButton
componentA back button for your own detail header: it shows only while the panes are stacked and pops the detail like the built-in back bar.
labeldefault"Back"ReactNodeThe visible text, usually the list's name ("Clients").
variantdefault"ghost"ButtonVariantThe Button variant.
sizedefault"sm"ButtonSizeThe Button size.
onClick(event: MouseEvent) => voidRuns first; call event.preventDefault() to stop the pop.
useSplitView
hookInside a split view: { mode, showDetail, back }. mode is "split" or "stack" ("split" on the server) and back() pops the detail on phones.
Examples
Inbox
Conversations and a thread, with unread counts and a sticky composer.
Remembered width
persistKey keeps the list width in localStorage; reload and it is still there.
Empty detail
emptyDetail replaces the placeholder with a summary of the list.
On a phone
The split view measures itself: in a narrow frame the detail pushes over the list and swipes back.
Tablets stack too
breakpoint="lg" keeps the push / pop stack up to 1024px.
Fixed and borderless
resizable={false} and bordered={false}: two panes on one surface.
Files
- components/ui/split-view.tsx
FAQ
Is the React Split View component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Split View?
Run npx wingo-ui@latest add split-view. It also adds the Wingo UI items it builds on (UIProvider, Button, usePersistedState, useScrollState and Motion presets) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/split-view.
See also
Used in
More React layout components
Articles using this component
- Order History Page Design: List, Detail and Actions
Build it: screens and apps9 min read