macbetter

Navigation

Split View

Show a sidebar, content and an inspector side by side, with dividers people can drag.

Inbox4 unread
  • 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.
Launch checklistInès Moreau · To: Design team · 9:41 AM

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"

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.

Inbox4 unread
  • 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.
Launch checklistInès Moreau · To: Design team · 9:41 AM

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.

Quarterly Review
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 separator with aria-valuenow, following the WAI-ARIA window splitter pattern. Name it with aria-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 collapsible pane and restores it. A pane collapsed to 0 leaves the tab order and the accessibility tree.

API

SplitView

PropTypeDefaultDescription
orientation"horizontal" | "vertical""horizontal"Panes side by side, or stacked.
defaultLayoutnumber[]—Every pane's starting size, in percent.
layoutnumber[]—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.
autoSaveIdstring—Saves the layout and restores it on the next visit.
controllerResizableController—Collapses, expands and resizes panes from anywhere.

SplitViewPane

PropTypeDefaultDescription
variant"sidebar" | "content" | "inspector""content"The pane's background: sidebar material, the window background, or the secondary background.
defaultSizenumber | string—Starting size, like `30` or `"240px"`.
minSizenumber | string—The smallest the pane gets while open.
maxSizenumber | string—The largest the pane gets.
collapsibleboolean—Lets the pane collapse below its minimum.
collapsedSizenumber | string0The size it collapses to.

SplitViewDivider

PropTypeDefaultDescription
disabledboolean—Stops the divider: no drags, no keys, out of the tab order.

Data attributes and CSS variables

Attribute or variableOnMeaning
data-variant.mb-split-view-panesidebar, content or inspector
data-collapsed.mb-split-view-paneThe pane is collapsed
data-orientationevery parthorizontal or vertical
data-dragging.mb-split-view, .mb-split-view-dividerA divider is being dragged
data-hovering.mb-split-view-dividerThe pointer is within the divider's hit area
--resizable-panel-size.mb-split-view-paneThe pane's share of the group, from ui-sdk