# Split View

> 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.

- Type: React component (Layout)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add split-view`
- Docs: https://wingo-ui.com/components/split-view

## Interaction

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.

## Installation

```bash
npx wingo-ui@latest add split-view
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/split-view` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/split-view.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [usePersistedState](https://wingo-ui.com/components/use-persisted-state) ([Markdown](https://wingo-ui.com/components/use-persisted-state.md))
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))

## Usage

```tsx
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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `list` | `ReactNode` |  | The 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. |
| `detail?` | `ReactNode` |  | The 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. |
| `showDetail?` | `boolean` | `false` | Whether the detail is open (on phones: pushed). Unset, it follows Boolean(detail). True without a detail shows the empty state. |
| `onBack?` | `() => void` |  | Phones: called by the back button, a completed swipe and Escape. Clear the selection here (and call router.back() when the detail is a route). |
| `defaultListWidth?` | `number` | `360` | Starting width of the list column in px (also what a double-click or Enter on the divider resets to). |
| `listWidth?` | `number` |  | Controlled width of the list column in px. Pair it with onListWidthChange. |
| `onListWidthChange?` | `(width: number) => void` |  | Fires once when a resize ends (pointer up, a key press, a reset), never per frame. |
| `minListWidth?` | `number` | `260` | The narrowest the list can get, in px. |
| `maxListWidth?` | `number` | `560` | The widest the list can get, in px. A drag also leaves the detail at least minListWidth wide. |
| `resizable?` | `boolean` | `true` | Drag the divider to resize; arrow keys, Home / End and Enter on the focused divider; double-click resets. Off, the divider is a plain hairline. |
| `persistKey?` | `string` | `""` | A storage key that remembers the list width across reloads (local storage). Empty keeps it in memory. |
| `breakpoint?` | `"md" \| "lg"` | `"md"` | 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. |
| `emptyText?` | `ReactNode` | `"Select an item to see it here"` | The placeholder text in the detail pane while nothing is selected (wide screens). |
| `emptyIcon?` | `ReactNode` |  | The placeholder icon. Defaults to MousePointerClick in a soft tile. |
| `emptyDetail?` | `ReactNode` |  | Replaces the whole placeholder, e.g. a summary of the list or a call to action. |
| `swipeBack?` | `boolean` | `true` | Phones: 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. |
| `showBackButton?` | `boolean` | `true` | Phones: 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 }. |
| `backLabel?` | `ReactNode` | `"Back"` | The back button's text. Use the list's name, like "Clients", as iOS does. |
| `resizeLabel?` | `string` | `"Resize list"` | The accessible name of the divider. |
| `bordered?` | `boolean` | `true` | The hairline between the panes. Off, the divider is invisible until hovered or dragged. |
| `classNames?` | `Partial<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. |
| `className?` | `string` |  | Extra classes for the root. It fills its parent's height (h-full), so give the parent a height. |

## Parts

### SplitViewBackButton (component)

A 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label?` | `ReactNode` | `"Back"` | The visible text, usually the list's name ("Clients"). |
| `variant?` | `ButtonVariant` | `"ghost"` | The Button variant. |
| `size?` | `ButtonSize` | `"sm"` | The Button size. |
| `onClick?` | `(event: MouseEvent) => void` |  | Runs first; call event.preventDefault() to stop the pop. |

### useSplitView (hook)

Inside 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.

## See also

- [Sheet](https://wingo-ui.com/components/sheet) ([Markdown](https://wingo-ui.com/components/sheet.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Family Drawer](https://wingo-ui.com/components/family-drawer) ([Markdown](https://wingo-ui.com/components/family-drawer.md))
- [usePersistedState](https://wingo-ui.com/components/use-persisted-state) ([Markdown](https://wingo-ui.com/components/use-persisted-state.md))
- [List](https://wingo-ui.com/components/list) ([Markdown](https://wingo-ui.com/components/list.md))

---

Canonical: https://wingo-ui.com/components/split-view
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
