Navigation
Split View
Show a sidebar, content and an inspector side by side, with dividers people can drag.
- Unread, Inès Moreau9:41 AMLaunch checklistEverything is green except the App Store screenshots.
- Unread, Kofi Mensah8:12 AMQ3 dashboardI added the retention chart you asked for on Friday.
- Wren TakahashiYesterdayRe: OffsiteThursday works for the whole team. I booked the room.
- Hollis GrantYesterdayInvoice #4189Attached is the invoice for the October retainer.
Everything is green except the App Store screenshots. Kofi is reshooting them on the new devices this afternoon, so we should be ready to submit tomorrow morning.
Thanks, Inès
import { Sidebar, SidebarItem, SidebarSection } from "macbetter/sidebar"
import { SplitView, SplitViewDivider, SplitViewPane } from "macbetter/split-view"
import { Window, WindowBody, WindowTitle, WindowTitlebar, WindowTrafficLights } from "macbetter/window"
import { ArchiveIcon, FileTextIcon, InboxIcon, SendIcon, Trash2Icon } from "lucide-react"
const mailboxes = [
{ name: "Inbox", icon: InboxIcon, count: 4 },
{ name: "Drafts", icon: FileTextIcon },
{ name: "Sent", icon: SendIcon },
{ name: "Archive", icon: ArchiveIcon },
{ name: "Trash", icon: Trash2Icon },
]
const messages = [
{ from: "Inès Moreau", subject: "Launch checklist", preview: "Everything is green except the App Store screenshots.", time: "9:41 AM", unread: true },
{ from: "Kofi Mensah", subject: "Q3 dashboard", preview: "I added the retention chart you asked for on Friday.", time: "8:12 AM", unread: true },
{ from: "Wren Takahashi", subject: "Re: Offsite", preview: "Thursday works for the whole team. I booked the room.", time: "Yesterday" },
{ from: "Hollis Grant", subject: "Invoice #4189", preview: "Attached is the invoice for the October retainer.", time: "Yesterday" },
]
export default function Example() {
return (
<Window aria-label="Mail" style={{ height: 440 }}>
<WindowTitlebar>
<WindowTrafficLights />
<WindowTitle subtitle="4 unread">Inbox</WindowTitle>
</WindowTitlebar>
<WindowBody>
<SplitView>
<SplitViewPane variant="sidebar" defaultSize="180px" minSize="150px" maxSize="260px" collapsible>
<Sidebar aria-label="Mailboxes" selection="gray" style={{ height: "100%" }}>
<SidebarSection>
{mailboxes.map(({ name, icon: Icon, count }) => (
<SidebarItem key={name} href={`#${name.toLowerCase()}`} icon={<Icon />} badge={count} current={name === "Inbox"}>
{name}
</SidebarItem>
))}
</SidebarSection>
</Sidebar>
</SplitViewPane>
<SplitViewDivider aria-label="Resize mailboxes" />
<SplitViewPane defaultSize={38} minSize={25}>
<ul aria-label="Messages" style={{ listStyle: "none", margin: 0, padding: "8px 0" }}>
{messages.map((message, index) => (
<li
key={message.subject}
style={{
display: "grid",
gap: 2,
margin: "0 8px",
padding: "10px 12px 10px 18px",
borderRadius: "var(--mb-row-radius)",
position: "relative",
backgroundColor: index === 0 ? "var(--mb-accent)" : undefined,
color: index === 0 ? "var(--mb-on-accent)" : undefined,
backgroundImage: index === 0 ? undefined : "linear-gradient(var(--mb-separator), var(--mb-separator))",
backgroundSize: "calc(100% - 24px) 1px",
backgroundPosition: "bottom",
backgroundRepeat: "no-repeat",
}}
>
<span style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
{message.unread ? (
<span
aria-hidden="true"
style={{
position: "absolute",
left: 6,
width: 7,
height: 7,
borderRadius: 9,
background: index === 0 ? "var(--mb-on-accent)" : "var(--mb-accent)",
}}
/>
) : null}
<strong className="mb-text-headline">
{message.unread ? <span className="mb-visually-hidden">Unread, </span> : null}
{message.from}
</strong>
<span className="mb-text-callout" style={{ opacity: 0.7 }}>{message.time}</span>
</span>
<span className="mb-text-callout">{message.subject}</span>
<span className="mb-text-callout" style={{ opacity: 0.7 }}>{message.preview}</span>
</li>
))}
</ul>
</SplitViewPane>
<SplitViewDivider aria-label="Resize message list" />
<SplitViewPane minSize={30}>
<article style={{ padding: "20px 24px", display: "grid", gap: 12 }}>
<div style={{ display: "grid", gap: 2 }}>
<span className="mb-text-title-3">Launch checklist</span>
<span className="mb-text-callout mb-label-secondary">Inès Moreau · To: Design team · 9:41 AM</span>
</div>
<p style={{ margin: 0 }}>
Everything is green except the App Store screenshots. Kofi is reshooting them on the new devices this
afternoon, so we should be ready to submit tomorrow morning.
</p>
<p style={{ margin: 0 }}>Thanks, Inès</p>
</article>
</SplitViewPane>
</SplitView>
</WindowBody>
</Window>
)
}When to use it
Use a split view when people move through a hierarchy and want to keep their place: mailboxes, then messages, then the message itself. Each column narrows the one after it, so the leading column is the broadest and the trailing one the most specific.
Keep it to two or three columns. Let people resize the panes and collapse the ones they don't need, and give each pane a sensible minimum so nothing gets crushed. An inspector belongs on the trailing side and shows details about the selection in the content.
Usage
import { SplitView, SplitViewDivider, SplitViewPane } from "macbetter/split-view"Sidebar, content and detail
Put a SplitViewDivider between each pair of panes and name it with aria-label. Sizes are percentages as numbers or strings with px; a pane without defaultSize takes what's left. variant="sidebar" gives a pane the translucent sidebar material. Drag the mailboxes closed: the pane is collapsible.
- Unread, Inès Moreau9:41 AMLaunch checklistEverything is green except the App Store screenshots.
- Unread, Kofi Mensah8:12 AMQ3 dashboardI added the retention chart you asked for on Friday.
- Wren TakahashiYesterdayRe: OffsiteThursday works for the whole team. I booked the room.
- Hollis GrantYesterdayInvoice #4189Attached is the invoice for the October retainer.
Everything is green except the App Store screenshots. Kofi is reshooting them on the new devices this afternoon, so we should be ready to submit tomorrow morning.
Thanks, Inès
import { Sidebar, SidebarItem, SidebarSection } from "macbetter/sidebar"
import { SplitView, SplitViewDivider, SplitViewPane } from "macbetter/split-view"
import { Window, WindowBody, WindowTitle, WindowTitlebar, WindowTrafficLights } from "macbetter/window"
import { ArchiveIcon, FileTextIcon, InboxIcon, SendIcon, Trash2Icon } from "lucide-react"
const mailboxes = [
{ name: "Inbox", icon: InboxIcon, count: 4 },
{ name: "Drafts", icon: FileTextIcon },
{ name: "Sent", icon: SendIcon },
{ name: "Archive", icon: ArchiveIcon },
{ name: "Trash", icon: Trash2Icon },
]
const messages = [
{ from: "Inès Moreau", subject: "Launch checklist", preview: "Everything is green except the App Store screenshots.", time: "9:41 AM", unread: true },
{ from: "Kofi Mensah", subject: "Q3 dashboard", preview: "I added the retention chart you asked for on Friday.", time: "8:12 AM", unread: true },
{ from: "Wren Takahashi", subject: "Re: Offsite", preview: "Thursday works for the whole team. I booked the room.", time: "Yesterday" },
{ from: "Hollis Grant", subject: "Invoice #4189", preview: "Attached is the invoice for the October retainer.", time: "Yesterday" },
]
export default function Example() {
return (
<Window aria-label="Mail" style={{ height: 440 }}>
<WindowTitlebar>
<WindowTrafficLights />
<WindowTitle subtitle="4 unread">Inbox</WindowTitle>
</WindowTitlebar>
<WindowBody>
<SplitView>
<SplitViewPane variant="sidebar" defaultSize="180px" minSize="150px" maxSize="260px" collapsible>
<Sidebar aria-label="Mailboxes" selection="gray" style={{ height: "100%" }}>
<SidebarSection>
{mailboxes.map(({ name, icon: Icon, count }) => (
<SidebarItem key={name} href={`#${name.toLowerCase()}`} icon={<Icon />} badge={count} current={name === "Inbox"}>
{name}
</SidebarItem>
))}
</SidebarSection>
</Sidebar>
</SplitViewPane>
<SplitViewDivider aria-label="Resize mailboxes" />
<SplitViewPane defaultSize={38} minSize={25}>
<ul aria-label="Messages" style={{ listStyle: "none", margin: 0, padding: "8px 0" }}>
{messages.map((message, index) => (
<li
key={message.subject}
style={{
display: "grid",
gap: 2,
margin: "0 8px",
padding: "10px 12px 10px 18px",
borderRadius: "var(--mb-row-radius)",
position: "relative",
backgroundColor: index === 0 ? "var(--mb-accent)" : undefined,
color: index === 0 ? "var(--mb-on-accent)" : undefined,
backgroundImage: index === 0 ? undefined : "linear-gradient(var(--mb-separator), var(--mb-separator))",
backgroundSize: "calc(100% - 24px) 1px",
backgroundPosition: "bottom",
backgroundRepeat: "no-repeat",
}}
>
<span style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
{message.unread ? (
<span
aria-hidden="true"
style={{
position: "absolute",
left: 6,
width: 7,
height: 7,
borderRadius: 9,
background: index === 0 ? "var(--mb-on-accent)" : "var(--mb-accent)",
}}
/>
) : null}
<strong className="mb-text-headline">
{message.unread ? <span className="mb-visually-hidden">Unread, </span> : null}
{message.from}
</strong>
<span className="mb-text-callout" style={{ opacity: 0.7 }}>{message.time}</span>
</span>
<span className="mb-text-callout">{message.subject}</span>
<span className="mb-text-callout" style={{ opacity: 0.7 }}>{message.preview}</span>
</li>
))}
</ul>
</SplitViewPane>
<SplitViewDivider aria-label="Resize message list" />
<SplitViewPane minSize={30}>
<article style={{ padding: "20px 24px", display: "grid", gap: 12 }}>
<div style={{ display: "grid", gap: 2 }}>
<span className="mb-text-title-3">Launch checklist</span>
<span className="mb-text-callout mb-label-secondary">Inès Moreau · To: Design team · 9:41 AM</span>
</div>
<p style={{ margin: 0 }}>
Everything is green except the App Store screenshots. Kofi is reshooting them on the new devices this
afternoon, so we should be ready to submit tomorrow morning.
</p>
<p style={{ margin: 0 }}>Thanks, Inès</p>
</article>
</SplitViewPane>
</SplitView>
</WindowBody>
</Window>
)
}Inspector
variant="inspector" gives the trailing pane the secondary background. Pixel bounds hold as the window resizes, so the inspector stays between 200 and 320 pixels wide.
- Arrange
- Width
- 1280 px
- Height
- 720 px
- Corner radius
- 24 px
- Opacity
- 100%
import { SplitView, SplitViewDivider, SplitViewPane } from "macbetter/split-view"
import { Window, WindowBody, WindowTitle, WindowTitlebar, WindowTrafficLights } from "macbetter/window"
const fields = [
["Width", "1280 px"],
["Height", "720 px"],
["Corner radius", "24 px"],
["Opacity", "100%"],
]
export default function Example() {
return (
<Window aria-label="Keynote" style={{ height: 360 }}>
<WindowTitlebar>
<WindowTrafficLights />
<WindowTitle>Quarterly Review</WindowTitle>
</WindowTitlebar>
<WindowBody>
<SplitView>
<SplitViewPane minSize={40} style={{ display: "grid", placeItems: "center", padding: 24 }}>
<div
style={{
width: "100%",
maxWidth: 360,
aspectRatio: "16 / 9",
borderRadius: 10,
background: "linear-gradient(140deg, var(--mb-accent), var(--mb-accent-secondary))",
boxShadow: "var(--mb-shadow-3)",
}}
/>
</SplitViewPane>
<SplitViewDivider aria-label="Resize inspector" />
<SplitViewPane variant="inspector" defaultSize="230px" minSize="200px" maxSize="320px" collapsible>
<dl style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: "10px 12px", margin: 0, padding: 16 }}>
<dt className="mb-text-headline" style={{ gridColumn: "1 / -1" }}>Arrange</dt>
{fields.map(([label, value]) => (
<div key={label} style={{ display: "contents" }}>
<dt className="mb-label-secondary">{label}</dt>
<dd style={{ margin: 0, fontVariantNumeric: "tabular-nums" }}>{value}</dd>
</div>
))}
</dl>
</SplitViewPane>
</SplitView>
</WindowBody>
</Window>
)
}Platform behavior
- macOS: like
NSSplitView, a 1px divider in the separator color, with a resize cursor while it's hovered or dragged. - iPadOS: like
UISplitViewController, a hairline between columns, with a 24pt touch area around it so a finger can catch it. - Under a pointer the hit area is 10px wide; ui-sdk extends it without changing how the line looks.
- A focused or dragged divider turns the accent color so you can see which one the keys move.
- A Sidebar inside a
variant="sidebar"pane drops its own material, so glass never sits on glass.
Accessibility
- Each divider is a focusable
separatorwitharia-valuenow, following the WAI-ARIA window splitter pattern. Name it witharia-label, like "Resize mailboxes". - Arrow keys along the split move the divider, Shift moves it farther, and Home and End go as far as the panes allow.
- Enter, or a double-click, collapses a
collapsiblepane and restores it. A pane collapsed to 0 leaves the tab order and the accessibility tree.
API
SplitView
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Panes side by side, or stacked. |
defaultLayout | number[] | — | Every pane's starting size, in percent. |
layout | number[] | — | Every pane's size, to control the layout. |
onLayoutChange | (layout, details) => void | — | Runs on every frame of a drag and every key. |
onLayoutCommit | (layout, details) => void | — | Runs once a drag or key press ends, for saving. |
autoSaveId | string | — | Saves the layout and restores it on the next visit. |
controller | ResizableController | — | Collapses, expands and resizes panes from anywhere. |
SplitViewPane
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "sidebar" | "content" | "inspector" | "content" | The pane's background: sidebar material, the window background, or the secondary background. |
defaultSize | number | string | — | Starting size, like `30` or `"240px"`. |
minSize | number | string | — | The smallest the pane gets while open. |
maxSize | number | string | — | The largest the pane gets. |
collapsible | boolean | — | Lets the pane collapse below its minimum. |
collapsedSize | number | string | 0 | The size it collapses to. |
SplitViewDivider
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | — | Stops the divider: no drags, no keys, out of the tab order. |
Data attributes and CSS variables
| Attribute or variable | On | Meaning |
|---|---|---|
data-variant | .mb-split-view-pane | sidebar, content or inspector |
data-collapsed | .mb-split-view-pane | The pane is collapsed |
data-orientation | every part | horizontal or vertical |
data-dragging | .mb-split-view, .mb-split-view-divider | A divider is being dragged |
data-hovering | .mb-split-view-divider | The pointer is within the divider's hit area |
--resizable-panel-size | .mb-split-view-pane | The pane's share of the group, from ui-sdk |