Skip to contentVibraUI
Navigation & layout

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

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
listReact.ReactNode—The master pane — a list of things to pick from.
detailReact.ReactNode—The detail pane — whatever the picked thing is.
defaultSizenumber32The list pane's share of the group, as a percentage.
minSizenumber20Smallest the list pane may be, as a percentage.
maxSizenumber60Largest the list pane may be, as a percentage.
direction"horizontal" | "vertical""horizontal"Side by side, or stacked with the handle between them.
showDetailbooleanfalseBelow md, shows the detail instead of the list.
onBack() => void—Adds the small-screen back button above the detail.
classNamestring—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.
storageKeystring—localStorage key the dragged sizes are remembered under.

Dependencies

Source

components/ui/split-view.tsx
"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 }