# Split View

A two- or three-column sidebar, list and detail layout that becomes a push navigation stack on mobile.

Split View is an open-source pattern from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/patterns/split-view

**React**

**Example**

```tsx
import * as React from "react"
import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
  SplitViewSidebar,
} from "@/components/patterns/split-view"

const mailboxes = ["Inbox", "Sent", "Drafts", "Archive"]

const messages = [
  {
    id: "order",
    from: "Acme Store",
    time: "09:12",
    subject: "Your order has shipped",
    body: "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42.",
  },
  {
    id: "meeting",
    from: "Jordan Lee",
    time: "Yesterday",
    subject: "Tomorrow's meeting",
    body: "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan.",
  },
  {
    id: "storage",
    from: "Cloud Drive",
    time: "Mon",
    subject: "Your storage is almost full",
    body: "Your cloud storage is 90% full. Upgrade your plan to get more space.",
  },
] as const

function MailExample({
  withSidebar = true,
  defaultColumn,
}: {
  withSidebar?: boolean
  defaultColumn?: "sidebar" | "list" | "detail"
}) {
  const [mailbox, setMailbox] = React.useState("Inbox")
  const [messageId, setMessageId] = React.useState("order")
  const message = messages.find((item) => item.id === messageId) ?? messages[0]

  return (
    <SplitView
      defaultColumn={defaultColumn}
      className="h-[30rem] max-md:h-auto"
    >
      {withSidebar && (
        <SplitViewSidebar title="Mailboxes">
          {mailboxes.map((name) => (
            <SplitViewItem
              key={name}
              isActive={mailbox === name}
              onClick={() => setMailbox(name)}
            >
              {name}
            </SplitViewItem>
          ))}
        </SplitViewSidebar>
      )}
      <SplitViewList title={mailbox}>
        {messages.map((item) => (
          <SplitViewItem
            key={item.id}
            isActive={item.id === messageId}
            onClick={() => setMessageId(item.id)}
          >
            <span className="flex items-baseline justify-between gap-2">
              <span className="truncate font-semibold">{item.from}</span>
              <span className="shrink-0 text-sm text-muted-foreground">
                {item.time}
              </span>
            </span>
            <span className="truncate">{item.subject}</span>
            <span className="truncate text-sm text-muted-foreground">
              {item.body}
            </span>
          </SplitViewItem>
        ))}
      </SplitViewList>
      <SplitViewDetail title={message.subject}>
        <p className="text-sm text-muted-foreground">
          {message.from} · {message.time}
        </p>
        <p className="mt-6 text-base">{message.body}</p>
      </SplitViewDetail>
    </SplitView>
  )
}

export default function SplitViewDemo() {
  return <MailExample />
}
```

`SplitView` arranges content in up to three columns, like Mail: a sidebar, a list and a detail pane. On desktop it places the panes in a horizontal suiss/ui `ResizablePanelGroup` with resize handles between them. The panes default to 20%, 32% and 48% of the width, with minimum sizes of 15%, 22% and 30%.

On mobile (below `48rem`, via `useIsMobile`) only one column shows at a time. Tapping a `SplitViewItem` pushes the next column, and a back button labelled with the previous pane's title returns to it. After navigation, focus moves to the new pane's heading and the view scrolls into place. Use it for master-detail interfaces such as mail, settings or file browsers.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/split-view
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add patterns split-view
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @base-ui/react @phosphor-icons/react
```

2. Add the pieces it builds on: [Resizable](https://ui.suiss.app/docs/components/resizable), `use-mobile`.

3. Copy the source into `components/patterns/split-view.tsx`:

```tsx title="components/patterns/split-view.tsx"
"use client"

import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react"
import * as React from "react"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"

type SplitViewColumn = "sidebar" | "list" | "detail"

type SplitViewContextProps = {
  isMobile: boolean
  column: SplitViewColumn
  setColumn: (column: SplitViewColumn) => void
  titles: Partial<Record<SplitViewColumn, React.ReactNode>>
}

const SplitViewContext = React.createContext<SplitViewContextProps | null>(null)

const SplitViewColumnContext = React.createContext<SplitViewColumn | null>(null)

function useSplitView() {
  const context = React.useContext(SplitViewContext)

  if (!context) {
    throw new Error("useSplitView must be used within a <SplitView />")
  }

  return context
}

const TITLE_CLASS: Record<SplitViewColumn, string> = {
  sidebar: "px-3 pt-3 pb-2 text-xs font-semibold text-muted-foreground",
  list: "sr-only",
  detail: "pb-1 text-2xl font-semibold",
}

const NEXT: Record<SplitViewColumn, SplitViewColumn> = {
  sidebar: "list",
  list: "detail",
  detail: "detail",
}

const PREVIOUS: Record<SplitViewColumn, SplitViewColumn | null> = {
  sidebar: null,
  list: "sidebar",
  detail: "list",
}

const SCROLLABLE = /auto|scroll/

function scrollParent(element: HTMLElement) {
  for (let node = element.parentElement; node; node = node.parentElement) {
    const { overflowY } = getComputedStyle(node)
    if (SCROLLABLE.test(overflowY) && node.scrollHeight > node.clientHeight) {
      return node
    }
  }
  return null
}

function revealTop(element: HTMLElement) {
  const parent = scrollParent(element)
  const top = element.getBoundingClientRect().top
  if (parent) {
    const offset = top - parent.getBoundingClientRect().top
    if (offset < 0) parent.scrollTop += offset
  } else if (top < 0) {
    window.scrollBy({ top })
  }
}

function SplitView({
  column: columnProp,
  defaultColumn = "list",
  onColumnChange,
  className,
  children,
  ref: forwardedRef,
  ...props
}: React.ComponentProps<"div"> & {
  column?: SplitViewColumn
  defaultColumn?: SplitViewColumn
  onColumnChange?: (column: SplitViewColumn) => void
}) {
  const isMobile = useIsMobile()
  const [uncontrolledColumn, setUncontrolledColumn] =
    React.useState(defaultColumn)
  const column = columnProp ?? uncontrolledColumn
  const ref = React.useRef<HTMLDivElement>(null)
  const navigatedRef = React.useRef(false)

  const panes = React.Children.toArray(children).filter(React.isValidElement)
  const titles: SplitViewContextProps["titles"] = {}
  for (const pane of panes) {
    const kind = PANES.get(pane.type)
    if (kind) titles[kind] = (pane.props as { title?: React.ReactNode }).title
  }

  const setColumn = React.useCallback(
    (next: SplitViewColumn) => {
      navigatedRef.current = true
      if (columnProp === undefined) setUncontrolledColumn(next)
      onColumnChange?.(next)
    },
    [columnProp, onColumnChange]
  )

  React.useEffect(() => {
    if (!isMobile || !navigatedRef.current) return
    navigatedRef.current = false
    const pane = ref.current?.querySelector(`[data-slot=split-view-${column}]`)
    pane
      ?.querySelector<HTMLElement>("[data-split-view-heading]")
      ?.focus({ preventScroll: true })
    if (ref.current) revealTop(ref.current)
  }, [isMobile, column])

  const contextValue = { isMobile, column, setColumn, titles }

  if (isMobile) {
    return (
      <SplitViewContext.Provider value={contextValue}>
        <div
          ref={(node) => {
            ref.current = node
            if (typeof forwardedRef === "function") return forwardedRef(node)
            if (forwardedRef) forwardedRef.current = node
          }}
          data-slot="split-view"
          data-column={column}
          className={cn("flex flex-col", className)}
          {...props}
        >
          {children}
        </div>
      </SplitViewContext.Provider>
    )
  }

  return (
    <SplitViewContext.Provider value={contextValue}>
      <div
        ref={(node) => {
          ref.current = node
          if (typeof forwardedRef === "function") return forwardedRef(node)
          if (forwardedRef) forwardedRef.current = node
        }}
        data-slot="split-view"
        className={cn("overflow-hidden rounded-2xl border", className)}
        {...props}
      >
        <ResizablePanelGroup orientation="horizontal">
          {panes.map((pane, index) => (
            <React.Fragment key={pane.key ?? index}>
              {index > 0 && <ResizableHandle />}
              {pane}
            </React.Fragment>
          ))}
        </ResizablePanelGroup>
      </div>
    </SplitViewContext.Provider>
  )
}

function SplitViewPane({
  kind,
  title,
  defaultSize,
  minSize,
  className,
  children,
  ...props
}: React.ComponentProps<"section"> & {
  kind: SplitViewColumn
  title?: React.ReactNode
  defaultSize: string
  minSize: string
}) {
  const { isMobile, column, setColumn, titles } = useSplitView()
  const titleId = React.useId()
  const previous = PREVIOUS[kind]
  const backColumn =
    previous === "sidebar" && titles.sidebar === undefined ? null : previous

  const content = (
    <section
      data-slot={`split-view-${kind}`}
      aria-labelledby={title ? titleId : undefined}
      className={cn("flex min-h-0 flex-col", className)}
      {...props}
    >
      {isMobile && backColumn && (
        <button
          type="button"
          data-slot="split-view-back"
          onClick={() => setColumn(backColumn)}
          className="-ms-1.5 inline-flex min-h-11 items-center gap-0.5 self-start rounded-md pe-1.5 text-base text-primary outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
        >
          <CaretLeftIcon
            className="size-4.5 shrink-0 rtl:rotate-180"
            aria-hidden
          />
          {titles[backColumn]}
        </button>
      )}
      {title && (
        <h2
          id={titleId}
          tabIndex={-1}
          data-split-view-heading=""
          className={cn(
            "outline-none",
            isMobile
              ? "pb-2 text-3xl font-bold tracking-tight"
              : TITLE_CLASS[kind]
          )}
        >
          {title}
        </h2>
      )}
      <SplitViewColumnContext.Provider value={kind}>
        {children}
      </SplitViewColumnContext.Provider>
    </section>
  )

  if (isMobile) return column === kind ? content : null

  return (
    <ResizablePanel
      data-slot={`split-view-${kind}-panel`}
      defaultSize={defaultSize}
      minSize={minSize}
      className="flex min-h-0 flex-col overflow-y-auto"
    >
      {content}
    </ResizablePanel>
  )
}

function SplitViewSidebar({
  className,
  ...props
}: React.ComponentProps<"section"> & { title?: React.ReactNode }) {
  return (
    <SplitViewPane
      kind="sidebar"
      defaultSize="20%"
      minSize="15%"
      className={cn("md:p-3 md:pt-1", className)}
      {...props}
    />
  )
}

function SplitViewList({
  className,
  ...props
}: React.ComponentProps<"section"> & { title?: React.ReactNode }) {
  return (
    <SplitViewPane
      kind="list"
      defaultSize="32%"
      minSize="22%"
      className={cn("md:p-3", className)}
      {...props}
    />
  )
}

function SplitViewDetail({
  className,
  ...props
}: React.ComponentProps<"section"> & { title?: React.ReactNode }) {
  return (
    <SplitViewPane
      kind="detail"
      defaultSize="48%"
      minSize="30%"
      className={cn("md:p-8", className)}
      {...props}
    />
  )
}

const PANES = new Map<unknown, SplitViewColumn>([
  [SplitViewSidebar, "sidebar"],
  [SplitViewList, "list"],
  [SplitViewDetail, "detail"],
])

function SplitViewItem({
  render,
  isActive = false,
  className,
  children,
  ...props
}: useRender.ComponentProps<"button"> &
  React.ComponentProps<"button"> & {
    isActive?: boolean
  }) {
  const { isMobile, setColumn } = useSplitView()
  const kind = React.useContext(SplitViewColumnContext) ?? "list"

  return useRender({
    defaultTagName: "button",
    render,
    props: mergeProps<"button">(
      {
        type: render ? undefined : "button",
        "aria-current": isActive ? "true" : undefined,
        onClick: () => setColumn(NEXT[kind]),
        className: cn(
          "flex w-full items-center gap-3 text-start text-base outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50",
          isMobile
            ? "min-h-11 border-border py-3 [[data-slot=split-view-item]+&]:border-t"
            : "rounded-lg px-3 py-2 hover:bg-accent data-active:bg-muted hover:text-accent-foreground",
          !isMobile && kind === "list" && "rounded-xl py-3",
          className
        ),
        children: (
          <>
            <span className="flex min-w-0 flex-1 flex-col gap-0.5">
              {children}
            </span>
            {isMobile && kind !== "detail" && (
              <CaretRightIcon
                className="size-4 shrink-0 text-muted-foreground rtl:rotate-180"
                aria-hidden
              />
            )}
          </>
        ),
      },
      props
    ),
    state: {
      slot: "split-view-item",
      active: isActive,
    },
  })
}

export {
  SplitView,
  type SplitViewColumn,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
  SplitViewSidebar,
  useSplitView,
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { SplitView, SplitViewSidebar, SplitViewList, SplitViewDetail, SplitViewItem } from "@/components/patterns/split-view"
```

```tsx
<MailExample />
```

- Place `SplitViewSidebar`, `SplitViewList` and `SplitViewDetail` as direct children of `SplitView`. Leave out the sidebar for a two-column layout.
- The mobile column is uncontrolled by default and starts at `defaultColumn`. Use `column` with `onColumnChange` to control it.
- `SplitViewItem` advances to the next column on click and runs your own `onClick` too. Set `isActive` on the current item.
- The list pane shows a back button to the sidebar only when the sidebar has a `title`.
- `useSplitView` returns `isMobile`, `column` and `setColumn` for custom navigation inside the view.

## Examples

### Two columns

Leaves out the sidebar for a list and detail layout.

**Example**

```tsx
import * as React from "react"
import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
  SplitViewSidebar,
} from "@/components/patterns/split-view"

const mailboxes = ["Inbox", "Sent", "Drafts", "Archive"]

const messages = [
  {
    id: "order",
    from: "Acme Store",
    time: "09:12",
    subject: "Your order has shipped",
    body: "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42.",
  },
  {
    id: "meeting",
    from: "Jordan Lee",
    time: "Yesterday",
    subject: "Tomorrow's meeting",
    body: "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan.",
  },
  {
    id: "storage",
    from: "Cloud Drive",
    time: "Mon",
    subject: "Your storage is almost full",
    body: "Your cloud storage is 90% full. Upgrade your plan to get more space.",
  },
] as const

function MailExample({
  withSidebar = true,
  defaultColumn,
}: {
  withSidebar?: boolean
  defaultColumn?: "sidebar" | "list" | "detail"
}) {
  const [mailbox, setMailbox] = React.useState("Inbox")
  const [messageId, setMessageId] = React.useState("order")
  const message = messages.find((item) => item.id === messageId) ?? messages[0]

  return (
    <SplitView
      defaultColumn={defaultColumn}
      className="h-[30rem] max-md:h-auto"
    >
      {withSidebar && (
        <SplitViewSidebar title="Mailboxes">
          {mailboxes.map((name) => (
            <SplitViewItem
              key={name}
              isActive={mailbox === name}
              onClick={() => setMailbox(name)}
            >
              {name}
            </SplitViewItem>
          ))}
        </SplitViewSidebar>
      )}
      <SplitViewList title={mailbox}>
        {messages.map((item) => (
          <SplitViewItem
            key={item.id}
            isActive={item.id === messageId}
            onClick={() => setMessageId(item.id)}
          >
            <span className="flex items-baseline justify-between gap-2">
              <span className="truncate font-semibold">{item.from}</span>
              <span className="shrink-0 text-sm text-muted-foreground">
                {item.time}
              </span>
            </span>
            <span className="truncate">{item.subject}</span>
            <span className="truncate text-sm text-muted-foreground">
              {item.body}
            </span>
          </SplitViewItem>
        ))}
      </SplitViewList>
      <SplitViewDetail title={message.subject}>
        <p className="text-sm text-muted-foreground">
          {message.from} · {message.time}
        </p>
        <p className="mt-6 text-base">{message.body}</p>
      </SplitViewDetail>
    </SplitView>
  )
}

export default function SplitViewTwoColumns() {
  return <MailExample withSidebar={false} />
}
```

### Start on sidebar

Starts on the sidebar column on mobile through `defaultColumn`.

**Example**

```tsx
import * as React from "react"
import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
  SplitViewSidebar,
} from "@/components/patterns/split-view"

const mailboxes = ["Inbox", "Sent", "Drafts", "Archive"]

const messages = [
  {
    id: "order",
    from: "Acme Store",
    time: "09:12",
    subject: "Your order has shipped",
    body: "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42.",
  },
  {
    id: "meeting",
    from: "Jordan Lee",
    time: "Yesterday",
    subject: "Tomorrow's meeting",
    body: "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan.",
  },
  {
    id: "storage",
    from: "Cloud Drive",
    time: "Mon",
    subject: "Your storage is almost full",
    body: "Your cloud storage is 90% full. Upgrade your plan to get more space.",
  },
] as const

function MailExample({
  withSidebar = true,
  defaultColumn,
}: {
  withSidebar?: boolean
  defaultColumn?: "sidebar" | "list" | "detail"
}) {
  const [mailbox, setMailbox] = React.useState("Inbox")
  const [messageId, setMessageId] = React.useState("order")
  const message = messages.find((item) => item.id === messageId) ?? messages[0]

  return (
    <SplitView
      defaultColumn={defaultColumn}
      className="h-[30rem] max-md:h-auto"
    >
      {withSidebar && (
        <SplitViewSidebar title="Mailboxes">
          {mailboxes.map((name) => (
            <SplitViewItem
              key={name}
              isActive={mailbox === name}
              onClick={() => setMailbox(name)}
            >
              {name}
            </SplitViewItem>
          ))}
        </SplitViewSidebar>
      )}
      <SplitViewList title={mailbox}>
        {messages.map((item) => (
          <SplitViewItem
            key={item.id}
            isActive={item.id === messageId}
            onClick={() => setMessageId(item.id)}
          >
            <span className="flex items-baseline justify-between gap-2">
              <span className="truncate font-semibold">{item.from}</span>
              <span className="shrink-0 text-sm text-muted-foreground">
                {item.time}
              </span>
            </span>
            <span className="truncate">{item.subject}</span>
            <span className="truncate text-sm text-muted-foreground">
              {item.body}
            </span>
          </SplitViewItem>
        ))}
      </SplitViewList>
      <SplitViewDetail title={message.subject}>
        <p className="text-sm text-muted-foreground">
          {message.from} · {message.time}
        </p>
        <p className="mt-6 text-base">{message.body}</p>
      </SplitViewDetail>
    </SplitView>
  )
}

export default function SplitViewStartOnSidebar() {
  return <MailExample defaultColumn="sidebar" />
}
```

## API Reference

### SplitView

The root that lays out the panes and tracks the visible mobile column.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `column` | `SplitViewColumn` | - | The visible mobile column when controlled: `sidebar`, `list` or `detail`. |
| `defaultColumn` | `SplitViewColumn` | `"list"` | The mobile column shown first when uncontrolled. |
| `onColumnChange` | `(column: SplitViewColumn) => void` | - | Called with the new column when navigation changes it. |

Also accepts the props of `React.ComponentProps<"div">`.

### SplitViewSidebar

The leading pane, usually for sources or categories.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `React.ReactNode` | - | The pane heading and accessible name; also labels the back button in the list pane. |

Also accepts the props of `React.ComponentProps<"section">`.

### SplitViewList

The middle pane, usually a list of items.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `React.ReactNode` | - | The pane heading and accessible name; visually hidden on desktop and used for the back button in the detail pane. |

Also accepts the props of `React.ComponentProps<"section">`.

### SplitViewDetail

The trailing pane that shows the selected item.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `React.ReactNode` | - | The pane heading and accessible name. |

Also accepts the props of `React.ComponentProps<"section">`.

### SplitViewItem

A button row that marks the current selection and advances to the next column on mobile.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `isActive` | `boolean` | `false` | Marks the item as the current selection with `aria-current` and `data-active`. |

Also accepts the props of `useRender.ComponentProps<"button">`, `React.ComponentProps<"button">`.

## Accessibility

- Each pane is a `section` labelled by its `title` heading, so titled panes are exposed as regions.
- The active item has `aria-current="true"`.
- On mobile, navigating moves focus to the new pane's heading. The back button's chevron is hidden from assistive technology.

## Notes

Styling hooks: `data-slot="split-view"`, `split-view-sidebar`, `split-view-list`, `split-view-detail`, `split-view-back` and `split-view-item`; desktop panels use `split-view-sidebar-panel`, `split-view-list-panel` and `split-view-detail-panel`. On mobile the root has `data-column` set to the visible column.

Use the `render` prop on `SplitViewItem` to render it as a link.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/patterns-split-view--default.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { SplitView, type SplitViewColumn, SplitViewDetail, SplitViewItem, SplitViewList, SplitViewSidebar } from "@/components/patterns/split-view"
import { Text } from "@/components/ui/text"

const mailboxes = ["Inbox", "Sent", "Drafts", "Archive"]

const messages = [
  {
    id: "order",
    from: "Acme Store",
    time: "09:12",
    subject: "Your order has shipped",
    body: "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42.",
  },
  {
    id: "meeting",
    from: "Jordan Lee",
    time: "Yesterday",
    subject: "Tomorrow's meeting",
    body: "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan.",
  },
  {
    id: "storage",
    from: "Cloud Drive",
    time: "Mon",
    subject: "Your storage is almost full",
    body: "Your cloud storage is 90% full. Upgrade your plan to get more space.",
  },
] as const

function MailExample({
  withSidebar = true,
  defaultColumn,
}: {
  withSidebar?: boolean
  defaultColumn?: SplitViewColumn
}) {
  const [mailbox, setMailbox] = React.useState("Inbox")
  const [messageId, setMessageId] = React.useState<string>("order")
  const message = messages.find((item) => item.id === messageId) ?? messages[0]

  return (
    <SplitView defaultColumn={defaultColumn}>
      {withSidebar ? (
        <SplitViewSidebar title="Mailboxes">
          {mailboxes.map((name) => (
            <SplitViewItem
              key={name}
              isActive={mailbox === name}
              onPress={() => setMailbox(name)}
            >
              {name}
            </SplitViewItem>
          ))}
        </SplitViewSidebar>
      ) : null}
      <SplitViewList title={mailbox}>
        {messages.map((item) => (
          <SplitViewItem
            key={item.id}
            isActive={item.id === messageId}
            onPress={() => setMessageId(item.id)}
          >
            <View className="flex-row items-baseline justify-between gap-2">
              <Text className="shrink font-semibold" numberOfLines={1}>
                {item.from}
              </Text>
              <Text className="text-sm text-muted-foreground">{item.time}</Text>
            </View>
            <Text numberOfLines={1}>{item.subject}</Text>
            <Text className="text-sm text-muted-foreground" numberOfLines={1}>
              {item.body}
            </Text>
          </SplitViewItem>
        ))}
      </SplitViewList>
      <SplitViewDetail title={message.subject}>
        <Text className="text-sm text-muted-foreground">
          {message.from} · {message.time}
        </Text>
        <Text className="mt-6">{message.body}</Text>
      </SplitViewDetail>
    </SplitView>
  )
}

export function SplitViewExample() {
  return (
    <MailExample />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SplitViewMessage: Identifiable {
  let id: String
  let from: String
  let time: String
  let subject: String
  let body: String
}

let splitViewMailboxes = ["Inbox", "Sent", "Drafts", "Archive"]

let splitViewMessages = [
  SplitViewMessage(
    id: "order",
    from: "Acme Store",
    time: "09:12",
    subject: "Your order has shipped",
    body: "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42."
  ),
  SplitViewMessage(
    id: "meeting",
    from: "Jordan Lee",
    time: "Yesterday",
    subject: "Tomorrow's meeting",
    body: "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan."
  ),
  SplitViewMessage(
    id: "storage",
    from: "Cloud Drive",
    time: "Mon",
    subject: "Your storage is almost full",
    body: "Your cloud storage is 90% full. Upgrade your plan to get more space."
  ),
]

struct SplitViewMail: View {
  var withSidebar = true

  @State private var column: SplitViewColumn
  @State private var mailbox = "Inbox"
  @State private var messageID = "order"

  init(column: SplitViewColumn = .list, withSidebar: Bool = true) {
    _column = State(initialValue: column)
    self.withSidebar = withSidebar
  }

  var message: SplitViewMessage {
    splitViewMessages.first { $0.id == messageID } ?? splitViewMessages[0]
  }

  var body: some View {
    SplitView(column: $column) {
      if withSidebar {
        SplitViewSidebar("Mailboxes") {
          ForEach(splitViewMailboxes, id: \.self) { name in
            SplitViewItem(isActive: mailbox == name, action: { mailbox = name }) {
              Text(verbatim: name)
            }
          }
        }
      }
      SplitViewList(verbatim: mailbox) {
        ForEach(splitViewMessages) { item in
          SplitViewItem(isActive: item.id == messageID, action: { messageID = item.id }) {
            HStack(alignment: .firstTextBaseline, spacing: 8) {
              Text(verbatim: item.from)
                .fontWeight(.semibold)
                .lineLimit(1)
                .frame(maxWidth: .infinity, alignment: .leading)
              Text(verbatim: item.time)
                .textStyle(.sm)
                .foregroundStyle(EsuissColor.mutedForeground)
            }
            Text(verbatim: item.subject)
              .lineLimit(1)
            Text(verbatim: item.body)
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
              .lineLimit(1)
          }
        }
      }
      SplitViewDetail(verbatim: message.subject) {
        Text(verbatim: "\(message.from) · \(message.time)")
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
        Text(verbatim: message.body)
          .foregroundStyle(EsuissColor.foreground)
          .fixedSize(horizontal: false, vertical: true)
          .padding(.top, 24)
      }
    }
    .containerRelativeFrame(.vertical) { height, _ in height - 32 }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.patterns.SplitView
import com.esuiss.ui.patterns.SplitViewColumn
import com.esuiss.ui.patterns.SplitViewItem
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

class SplitViewMessage(
    val id: String,
    val from: String,
    val time: String,
    val subject: String,
    val body: String,
)

val splitViewMailboxes = listOf("Inbox", "Sent", "Drafts", "Archive")

val splitViewMessages = listOf(
    SplitViewMessage(
        "order",
        "Acme Store",
        "09:12",
        "Your order has shipped",
        "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42.",
    ),
    SplitViewMessage(
        "meeting",
        "Jordan Lee",
        "Yesterday",
        "Tomorrow's meeting",
        "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan.",
    ),
    SplitViewMessage(
        "storage",
        "Cloud Drive",
        "Mon",
        "Your storage is almost full",
        "Your cloud storage is 90% full. Upgrade your plan to get more space.",
    ),
)

@Composable
fun SplitViewMail(withSidebar: Boolean = true, defaultColumn: SplitViewColumn = SplitViewColumn.List) {
    var mailbox by rememberSaveable { mutableStateOf("Inbox") }
    var messageId by rememberSaveable { mutableStateOf("order") }
    val message = splitViewMessages.firstOrNull { it.id == messageId } ?: splitViewMessages.first()
    val colors = EsuissTheme.colors
    SplitView(modifier = Modifier.fillMaxWidth().height(640.dp), defaultColumn = defaultColumn) {
        if (withSidebar) {
            SplitViewSidebar(title = "Mailboxes") {
                splitViewMailboxes.forEach { name ->
                    SplitViewItem(isActive = mailbox == name, onClick = { mailbox = name }) {
                        Text(name)
                    }
                }
            }
        }
        SplitViewList(title = mailbox) {
            splitViewMessages.forEach { item ->
                SplitViewItem(isActive = item.id == messageId, onClick = { messageId = item.id }) {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(8.dp),
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        Text(
                            item.from,
                            modifier = Modifier.weight(1f),
                            style = Typography.base.copy(fontWeight = FontWeight.SemiBold),
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis,
                        )
                        Text(item.time, style = Typography.sm.copy(color = colors.mutedForeground), maxLines = 1)
                    }
                    Text(item.subject, maxLines = 1, overflow = TextOverflow.Ellipsis)
                    Text(
                        item.body,
                        style = Typography.sm.copy(color = colors.mutedForeground),
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                    )
                }
            }
        }
        SplitViewDetail(title = message.subject) {
            Text("${message.from} · ${message.time}", style = Typography.sm.copy(color = colors.mutedForeground))
            Text(message.body, modifier = Modifier.padding(top = 24.dp))
        }
    }
}
```

`SplitView` lays out up to three panes: `SplitViewSidebar`, `SplitViewList` and `SplitViewDetail`. When the window is at least `breakpoint` wide (768 by default), they sit side by side in a bordered row, sized 20, 32 and 48 parts.

Below the breakpoint only the current column is shown, full screen and scrollable, with a large title. Pressing a `SplitViewItem` moves to the next column, and a back button titled with the previous pane returns to it. On Android the hardware back button also goes back. Use it for mail, notes or settings screens that drill down.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/split-view
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add split-view
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add split-view --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add split-view --platform kotlin
```

The CLI also adds what it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/hooks.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/patterns/split-view.tsx`:

```tsx title="uniwind/components/patterns/split-view.tsx"
import { cn } from "cn"
import { CaretLeftIcon, CaretRightIcon } from "phosphor-react-native"
import * as React from "react"
import {
  AccessibilityInfo,
  I18nManager,
  Pressable,
  type Text as RNText,
  ScrollView,
  useWindowDimensions,
  View,
  type ViewProps,
} from "react-native"
import { FadeIn, ReduceMotion } from "react-native-reanimated"
import { useBackHandler } from "@/components/primitives/hooks"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { Text, TextClassContext } from "@/components/ui/text"

type SplitViewColumn = "sidebar" | "list" | "detail"

type SplitViewContextProps = {
  isCompact: boolean
  column: SplitViewColumn
  setColumn: (column: SplitViewColumn) => void
  titles: Partial<Record<SplitViewColumn, React.ReactNode>>
  kinds: SplitViewColumn[]
}

const SplitViewContext = React.createContext<SplitViewContextProps | null>(null)

const SplitViewColumnContext = React.createContext<SplitViewColumn | null>(null)

function useSplitView() {
  const context = React.useContext(SplitViewContext)
  if (!context) {
    throw new Error("useSplitView must be used within a <SplitView />")
  }
  return context
}

const NEXT: Record<SplitViewColumn, SplitViewColumn> = {
  sidebar: "list",
  list: "detail",
  detail: "detail",
}

const PREVIOUS: Record<SplitViewColumn, SplitViewColumn | null> = {
  sidebar: null,
  list: "sidebar",
  detail: "list",
}

const RATIO: Record<SplitViewColumn, number> = {
  sidebar: 20,
  list: 32,
  detail: 48,
}

const TITLE_CLASS: Record<SplitViewColumn, string> = {
  sidebar: "px-3 pt-3 pb-2 text-xs font-semibold text-muted-foreground",
  list: "",
  detail: "pb-1 text-2xl font-semibold",
}

const PANE_CLASS: Record<SplitViewColumn, string> = {
  sidebar: "p-3 pt-1",
  list: "p-3",
  detail: "p-8",
}

type SplitViewPaneProps = ViewProps & {
  title?: React.ReactNode
}

function SplitView({
  column: columnProp,
  defaultColumn = "list",
  onColumnChange,
  breakpoint = 768,
  className,
  children,
  ...props
}: ViewProps & {
  column?: SplitViewColumn
  defaultColumn?: SplitViewColumn
  onColumnChange?: (column: SplitViewColumn) => void
  breakpoint?: number
}) {
  const { width } = useWindowDimensions()
  const isCompact = width < breakpoint
  const [uncontrolledColumn, setUncontrolledColumn] =
    React.useState(defaultColumn)
  const column = columnProp ?? uncontrolledColumn

  const panes = React.Children.toArray(children).filter(React.isValidElement)
  const titles: SplitViewContextProps["titles"] = {}
  const kinds: SplitViewColumn[] = []
  for (const pane of panes) {
    const kind = PANES.get(pane.type)
    if (!kind) continue
    kinds.push(kind)
    titles[kind] = (pane.props as { title?: React.ReactNode }).title
  }

  const setColumn = React.useCallback(
    (next: SplitViewColumn) => {
      if (columnProp === undefined) setUncontrolledColumn(next)
      onColumnChange?.(next)
    },
    [columnProp, onColumnChange]
  )

  const previous = PREVIOUS[column]
  const backColumn =
    previous && kinds.includes(previous) && titles[previous] !== undefined
      ? previous
      : null

  useBackHandler(isCompact && backColumn !== null, () => {
    if (backColumn) setColumn(backColumn)
  })

  const contextValue = { isCompact, column, setColumn, titles, kinds }

  return (
    <SplitViewContext.Provider value={contextValue}>
      <View
        className={cn(
          "flex-1",
          !isCompact &&
            "flex-row overflow-hidden rounded-2xl border border-border",
          className
        )}
        {...props}
      >
        {children}
      </View>
    </SplitViewContext.Provider>
  )
}

function SplitViewPane({
  kind,
  title,
  className,
  style,
  children,
  ...props
}: SplitViewPaneProps & {
  kind: SplitViewColumn
}) {
  const { isCompact, column, setColumn, titles, kinds } = useSplitView()
  const headingRef = React.useRef<React.ComponentRef<typeof RNText>>(null)
  const mountedRef = React.useRef(false)
  const visible = !isCompact || column === kind
  const previous = PREVIOUS[kind]
  const backColumn =
    previous && kinds.includes(previous) && titles[previous] !== undefined
      ? previous
      : null
  const total = kinds.reduce((sum, item) => sum + RATIO[item], 0) || 1

  React.useEffect(() => {
    if (!isCompact || !visible) return
    if (!mountedRef.current) {
      mountedRef.current = true
      return
    }
    const node = headingRef.current
    if (!node) return
    const timeout = setTimeout(() => {
      AccessibilityInfo.sendAccessibilityEvent(node, "focus")
    }, 50)
    return () => clearTimeout(timeout)
  }, [isCompact, visible])

  if (!visible) return null

  const content = (
    <SplitViewColumnContext.Provider value={kind}>
      {isCompact && backColumn ? (
        <Pressable
          role="button"
          onPress={() => setColumn(backColumn)}
          hitSlop={8}
          className="-ms-1.5 min-h-11 flex-row items-center gap-0.5 self-start pe-1.5 active:opacity-60"
        >
          <Icon
            as={CaretLeftIcon}
            weight="bold"
            mirrored={I18nManager.isRTL}
            className="size-4.5 text-primary"
          />
          <TextClassContext.Provider value="text-base text-primary">
            {typeof titles[backColumn] === "string" ? (
              <Text>{titles[backColumn]}</Text>
            ) : (
              titles[backColumn]
            )}
          </TextClassContext.Provider>
        </Pressable>
      ) : null}
      {title !== undefined && (isCompact || kind !== "list") ? (
        <Text
          ref={headingRef}
          role="heading"
          className={isCompact ? "pb-2 text-3xl font-bold" : TITLE_CLASS[kind]}
        >
          {title}
        </Text>
      ) : null}
      {children}
    </SplitViewColumnContext.Provider>
  )

  if (isCompact) {
    return (
      <AnimatedView
        key={kind}
        entering={FadeIn.duration(200).reduceMotion(ReduceMotion.System)}
        className="flex-1"
        accessibilityLabel={typeof title === "string" ? title : undefined}
        style={style}
        {...props}
      >
        <ScrollView className="flex-1" contentContainerClassName={className}>
          {content}
        </ScrollView>
      </AnimatedView>
    )
  }

  return (
    <View
      className={cn("min-h-0", kinds[0] !== kind && "border-s border-border")}
      accessibilityLabel={typeof title === "string" ? title : undefined}
      {...props}
      style={[{ flex: RATIO[kind] / total }, style]}
    >
      <ScrollView
        className="flex-1"
        contentContainerClassName={cn(PANE_CLASS[kind], className)}
      >
        {content}
      </ScrollView>
    </View>
  )
}

function SplitViewSidebar(props: SplitViewPaneProps) {
  return <SplitViewPane kind="sidebar" {...props} />
}

function SplitViewList(props: SplitViewPaneProps) {
  return <SplitViewPane kind="list" {...props} />
}

function SplitViewDetail(props: SplitViewPaneProps) {
  return <SplitViewPane kind="detail" {...props} />
}

const PANES = new Map<unknown, SplitViewColumn>([
  [SplitViewSidebar, "sidebar"],
  [SplitViewList, "list"],
  [SplitViewDetail, "detail"],
])

function SplitViewItem({
  isActive = false,
  className,
  children,
  onPress,
  accessibilityState,
  ...props
}: React.ComponentProps<typeof Pressable> & {
  isActive?: boolean
  children?: React.ReactNode
}) {
  const { isCompact, setColumn } = useSplitView()
  const kind = React.useContext(SplitViewColumnContext) ?? "list"
  return (
    <Pressable
      role="button"
      accessibilityState={{ ...accessibilityState, selected: isActive }}
      onPress={(event) => {
        onPress?.(event)
        setColumn(NEXT[kind])
      }}
      className={cn(
        "flex-row items-center gap-3",
        isCompact
          ? "min-h-11 border-b border-border py-3 active:opacity-80"
          : cn(
              "rounded-lg px-3 py-2",
              isActive ? "bg-muted" : "active:opacity-80",
              kind === "list" && "rounded-xl py-3"
            ),
        typeof className === "string" ? className : undefined
      )}
      {...props}
    >
      <View className="min-w-0 flex-1 gap-0.5">
        {typeof children === "string" || typeof children === "number" ? (
          <Text>{children}</Text>
        ) : (
          children
        )}
      </View>
      {isCompact && kind !== "detail" ? (
        <Icon
          as={CaretRightIcon}
          mirrored={I18nManager.isRTL}
          className="size-4 text-muted-foreground"
        />
      ) : null}
    </Pressable>
  )
}

export {
  SplitView,
  type SplitViewColumn,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
  SplitViewSidebar,
  useSplitView,
}
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/hooks.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/patterns/split-view.tsx`:

```tsx title="nativewind/components/patterns/split-view.tsx"
import { cn } from "cn"
import { CaretLeftIcon, CaretRightIcon } from "phosphor-react-native"
import * as React from "react"
import {
  AccessibilityInfo,
  I18nManager,
  Pressable,
  type Text as RNText,
  ScrollView,
  useWindowDimensions,
  View,
  type ViewProps,
} from "react-native"
import { FadeIn, ReduceMotion } from "react-native-reanimated"
import { useBackHandler } from "@/components/primitives/hooks"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { Text, TextClassContext } from "@/components/ui/text"

type SplitViewColumn = "sidebar" | "list" | "detail"

type SplitViewContextProps = {
  isCompact: boolean
  column: SplitViewColumn
  setColumn: (column: SplitViewColumn) => void
  titles: Partial<Record<SplitViewColumn, React.ReactNode>>
  kinds: SplitViewColumn[]
}

const SplitViewContext = React.createContext<SplitViewContextProps | null>(null)

const SplitViewColumnContext = React.createContext<SplitViewColumn | null>(null)

function useSplitView() {
  const context = React.useContext(SplitViewContext)
  if (!context) {
    throw new Error("useSplitView must be used within a <SplitView />")
  }
  return context
}

const NEXT: Record<SplitViewColumn, SplitViewColumn> = {
  sidebar: "list",
  list: "detail",
  detail: "detail",
}

const PREVIOUS: Record<SplitViewColumn, SplitViewColumn | null> = {
  sidebar: null,
  list: "sidebar",
  detail: "list",
}

const RATIO: Record<SplitViewColumn, number> = {
  sidebar: 20,
  list: 32,
  detail: 48,
}

const TITLE_CLASS: Record<SplitViewColumn, string> = {
  sidebar: "px-3 pt-3 pb-2 text-xs font-semibold text-muted-foreground",
  list: "",
  detail: "pb-1 text-2xl font-semibold",
}

const PANE_CLASS: Record<SplitViewColumn, string> = {
  sidebar: "p-3 pt-1",
  list: "p-3",
  detail: "p-8",
}

type SplitViewPaneProps = ViewProps & {
  title?: React.ReactNode
}

function SplitView({
  column: columnProp,
  defaultColumn = "list",
  onColumnChange,
  breakpoint = 768,
  className,
  children,
  ...props
}: ViewProps & {
  column?: SplitViewColumn
  defaultColumn?: SplitViewColumn
  onColumnChange?: (column: SplitViewColumn) => void
  breakpoint?: number
}) {
  const { width } = useWindowDimensions()
  const isCompact = width < breakpoint
  const [uncontrolledColumn, setUncontrolledColumn] =
    React.useState(defaultColumn)
  const column = columnProp ?? uncontrolledColumn

  const panes = React.Children.toArray(children).filter(React.isValidElement)
  const titles: SplitViewContextProps["titles"] = {}
  const kinds: SplitViewColumn[] = []
  for (const pane of panes) {
    const kind = PANES.get(pane.type)
    if (!kind) continue
    kinds.push(kind)
    titles[kind] = (pane.props as { title?: React.ReactNode }).title
  }

  const setColumn = React.useCallback(
    (next: SplitViewColumn) => {
      if (columnProp === undefined) setUncontrolledColumn(next)
      onColumnChange?.(next)
    },
    [columnProp, onColumnChange]
  )

  const previous = PREVIOUS[column]
  const backColumn =
    previous && kinds.includes(previous) && titles[previous] !== undefined
      ? previous
      : null

  useBackHandler(isCompact && backColumn !== null, () => {
    if (backColumn) setColumn(backColumn)
  })

  const contextValue = { isCompact, column, setColumn, titles, kinds }

  return (
    <SplitViewContext.Provider value={contextValue}>
      <View
        className={cn(
          "flex-1",
          !isCompact &&
            "flex-row overflow-hidden rounded-2xl border border-border",
          className
        )}
        {...props}
      >
        {children}
      </View>
    </SplitViewContext.Provider>
  )
}

function SplitViewPane({
  kind,
  title,
  className,
  style,
  children,
  ...props
}: SplitViewPaneProps & {
  kind: SplitViewColumn
}) {
  const { isCompact, column, setColumn, titles, kinds } = useSplitView()
  const headingRef = React.useRef<React.ComponentRef<typeof RNText>>(null)
  const mountedRef = React.useRef(false)
  const visible = !isCompact || column === kind
  const previous = PREVIOUS[kind]
  const backColumn =
    previous && kinds.includes(previous) && titles[previous] !== undefined
      ? previous
      : null
  const total = kinds.reduce((sum, item) => sum + RATIO[item], 0) || 1

  React.useEffect(() => {
    if (!isCompact || !visible) return
    if (!mountedRef.current) {
      mountedRef.current = true
      return
    }
    const node = headingRef.current
    if (!node) return
    const timeout = setTimeout(() => {
      AccessibilityInfo.sendAccessibilityEvent(node, "focus")
    }, 50)
    return () => clearTimeout(timeout)
  }, [isCompact, visible])

  if (!visible) return null

  const content = (
    <SplitViewColumnContext.Provider value={kind}>
      {isCompact && backColumn ? (
        <Pressable
          role="button"
          onPress={() => setColumn(backColumn)}
          hitSlop={8}
          className="-ms-1.5 min-h-11 flex-row items-center gap-0.5 self-start pe-1.5 active:opacity-60"
        >
          <Icon
            as={CaretLeftIcon}
            weight="bold"
            mirrored={I18nManager.isRTL}
            className="size-[1.125rem] text-primary"
          />
          <TextClassContext.Provider value="text-base text-primary">
            {typeof titles[backColumn] === "string" ? (
              <Text>{titles[backColumn]}</Text>
            ) : (
              titles[backColumn]
            )}
          </TextClassContext.Provider>
        </Pressable>
      ) : null}
      {title !== undefined && (isCompact || kind !== "list") ? (
        <Text
          ref={headingRef}
          role="heading"
          className={isCompact ? "pb-2 text-3xl font-bold" : TITLE_CLASS[kind]}
        >
          {title}
        </Text>
      ) : null}
      {children}
    </SplitViewColumnContext.Provider>
  )

  if (isCompact) {
    return (
      <AnimatedView
        key={kind}
        entering={FadeIn.duration(200).reduceMotion(ReduceMotion.System)}
        className="flex-1"
        accessibilityLabel={typeof title === "string" ? title : undefined}
        style={style}
        {...props}
      >
        <ScrollView className="flex-1" contentContainerClassName={className}>
          {content}
        </ScrollView>
      </AnimatedView>
    )
  }

  return (
    <View
      className={cn("min-h-0", kinds[0] !== kind && "border-s border-border")}
      accessibilityLabel={typeof title === "string" ? title : undefined}
      {...props}
      style={[{ flex: RATIO[kind] / total }, style]}
    >
      <ScrollView
        className="flex-1"
        contentContainerClassName={cn(PANE_CLASS[kind], className)}
      >
        {content}
      </ScrollView>
    </View>
  )
}

function SplitViewSidebar(props: SplitViewPaneProps) {
  return <SplitViewPane kind="sidebar" {...props} />
}

function SplitViewList(props: SplitViewPaneProps) {
  return <SplitViewPane kind="list" {...props} />
}

function SplitViewDetail(props: SplitViewPaneProps) {
  return <SplitViewPane kind="detail" {...props} />
}

const PANES = new Map<unknown, SplitViewColumn>([
  [SplitViewSidebar, "sidebar"],
  [SplitViewList, "list"],
  [SplitViewDetail, "detail"],
])

function SplitViewItem({
  isActive = false,
  className,
  children,
  onPress,
  accessibilityState,
  ...props
}: React.ComponentProps<typeof Pressable> & {
  isActive?: boolean
  children?: React.ReactNode
}) {
  const { isCompact, setColumn } = useSplitView()
  const kind = React.useContext(SplitViewColumnContext) ?? "list"
  return (
    <Pressable
      role="button"
      accessibilityState={{ ...accessibilityState, selected: isActive }}
      onPress={(event) => {
        onPress?.(event)
        setColumn(NEXT[kind])
      }}
      className={cn(
        "flex-row items-center gap-3",
        isCompact
          ? "min-h-11 border-b border-border py-3 active:opacity-80"
          : cn(
              "rounded-lg px-3 py-2",
              isActive ? "bg-muted" : "active:opacity-80",
              kind === "list" && "rounded-xl py-3"
            ),
        typeof className === "string" ? className : undefined
      )}
      {...props}
    >
      <View className="min-w-0 flex-1 gap-0.5">
        {typeof children === "string" || typeof children === "number" ? (
          <Text>{children}</Text>
        ) : (
          children
        )}
      </View>
      {isCompact && kind !== "detail" ? (
        <Icon
          as={CaretRightIcon}
          mirrored={I18nManager.isRTL}
          className="size-4 text-muted-foreground"
        />
      ) : null}
    </Pressable>
  )
}

export {
  SplitView,
  type SplitViewColumn,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
  SplitViewSidebar,
  useSplitView,
}
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/hooks.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/patterns/split-view.tsx`:

```tsx title="stylesheet/components/patterns/split-view.tsx"
import { CaretLeftIcon, CaretRightIcon } from "phosphor-react-native"
import * as React from "react"
import {
  AccessibilityInfo,
  I18nManager,
  Pressable,
  type Text as RNText,
  ScrollView,
  StyleSheet,
  type TextStyle,
  useWindowDimensions,
  View,
  type ViewProps,
  type ViewStyle,
} from "react-native"
import { FadeIn, ReduceMotion } from "react-native-reanimated"
import { useBackHandler } from "@/components/primitives/hooks"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { Text, TextStyleContext } from "@/components/ui/text"
import {
  fontSize,
  radius,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

type SplitViewColumn = "sidebar" | "list" | "detail"

type SplitViewContextProps = {
  isCompact: boolean
  column: SplitViewColumn
  setColumn: (column: SplitViewColumn) => void
  titles: Partial<Record<SplitViewColumn, React.ReactNode>>
  kinds: SplitViewColumn[]
}

const SplitViewContext = React.createContext<SplitViewContextProps | null>(null)

const SplitViewColumnContext = React.createContext<SplitViewColumn | null>(null)

function useSplitView() {
  const context = React.useContext(SplitViewContext)
  if (!context) {
    throw new Error("useSplitView must be used within a <SplitView />")
  }
  return context
}

const NEXT: Record<SplitViewColumn, SplitViewColumn> = {
  sidebar: "list",
  list: "detail",
  detail: "detail",
}

const PREVIOUS: Record<SplitViewColumn, SplitViewColumn | null> = {
  sidebar: null,
  list: "sidebar",
  detail: "list",
}

const RATIO: Record<SplitViewColumn, number> = {
  sidebar: 20,
  list: 32,
  detail: 48,
}

const styles = StyleSheet.create({
  root: { flex: 1 },
  rootWide: {
    flexDirection: "row",
    overflow: "hidden",
    borderRadius: radius["2xl"],
    borderWidth: 1,
  },
  back: {
    marginStart: -6,
    minHeight: 44,
    flexDirection: "row",
    alignItems: "center",
    gap: 2,
    alignSelf: "flex-start",
    paddingEnd: 6,
  },
  pressed: { opacity: 0.6 },
  compactTitle: { paddingBottom: 8, ...fontSize["3xl"], fontWeight: "700" },
  sidebarTitle: {
    paddingHorizontal: 12,
    paddingTop: 12,
    paddingBottom: 8,
    ...fontSize.xs,
    fontWeight: "600",
  },
  detailTitle: { paddingBottom: 4, ...fontSize["2xl"], fontWeight: "600" },
  fill: { flex: 1 },
  pane: { minHeight: 0 },
  paneDivider: { borderStartWidth: 1 },
  sidebarPane: { padding: 12, paddingTop: 4 },
  listPane: { padding: 12 },
  detailPane: { padding: 32 },
  item: { flexDirection: "row", alignItems: "center", gap: 12 },
  itemCompact: { minHeight: 44, borderBottomWidth: 1, paddingVertical: 12 },
  itemWide: {
    borderRadius: radius.lg,
    paddingHorizontal: 12,
    paddingVertical: 8,
  },
  itemWideList: { borderRadius: radius.xl, paddingVertical: 12 },
  itemBody: { minWidth: 0, flex: 1, gap: 2 },
})

const PANE_STYLE: Record<SplitViewColumn, ViewStyle> = {
  sidebar: styles.sidebarPane,
  list: styles.listPane,
  detail: styles.detailPane,
}

function titleStyle(
  kind: SplitViewColumn,
  isCompact: boolean,
  colors: ThemeColors
): TextStyle {
  if (isCompact) return styles.compactTitle
  if (kind === "sidebar") {
    return { ...styles.sidebarTitle, color: colors.mutedForeground }
  }
  return styles.detailTitle
}

type SplitViewPaneProps = ViewProps & {
  title?: React.ReactNode
}

function SplitView({
  column: columnProp,
  defaultColumn = "list",
  onColumnChange,
  breakpoint = 768,
  style,
  children,
  ...props
}: ViewProps & {
  column?: SplitViewColumn
  defaultColumn?: SplitViewColumn
  onColumnChange?: (column: SplitViewColumn) => void
  breakpoint?: number
}) {
  const { colors } = useTheme()
  const { width } = useWindowDimensions()
  const isCompact = width < breakpoint
  const [uncontrolledColumn, setUncontrolledColumn] =
    React.useState(defaultColumn)
  const column = columnProp ?? uncontrolledColumn

  const panes = React.Children.toArray(children).filter(React.isValidElement)
  const titles: SplitViewContextProps["titles"] = {}
  const kinds: SplitViewColumn[] = []
  for (const pane of panes) {
    const kind = PANES.get(pane.type)
    if (!kind) continue
    kinds.push(kind)
    titles[kind] = (pane.props as { title?: React.ReactNode }).title
  }

  const setColumn = React.useCallback(
    (next: SplitViewColumn) => {
      if (columnProp === undefined) setUncontrolledColumn(next)
      onColumnChange?.(next)
    },
    [columnProp, onColumnChange]
  )

  const previous = PREVIOUS[column]
  const backColumn =
    previous && kinds.includes(previous) && titles[previous] !== undefined
      ? previous
      : null

  useBackHandler(isCompact && backColumn !== null, () => {
    if (backColumn) setColumn(backColumn)
  })

  const contextValue = { isCompact, column, setColumn, titles, kinds }

  return (
    <SplitViewContext.Provider value={contextValue}>
      <View
        style={[
          styles.root,
          !isCompact && [styles.rootWide, { borderColor: colors.border }],
          style,
        ]}
        {...props}
      >
        {children}
      </View>
    </SplitViewContext.Provider>
  )
}

function SplitViewPane({
  kind,
  title,
  style,
  children,
  ...props
}: SplitViewPaneProps & {
  kind: SplitViewColumn
}) {
  const { colors } = useTheme()
  const { isCompact, column, setColumn, titles, kinds } = useSplitView()
  const headingRef = React.useRef<React.ComponentRef<typeof RNText>>(null)
  const mountedRef = React.useRef(false)
  const visible = !isCompact || column === kind
  const previous = PREVIOUS[kind]
  const backColumn =
    previous && kinds.includes(previous) && titles[previous] !== undefined
      ? previous
      : null
  const total = kinds.reduce((sum, item) => sum + RATIO[item], 0) || 1

  React.useEffect(() => {
    if (!isCompact || !visible) return
    if (!mountedRef.current) {
      mountedRef.current = true
      return
    }
    const node = headingRef.current
    if (!node) return
    const timeout = setTimeout(() => {
      AccessibilityInfo.sendAccessibilityEvent(node, "focus")
    }, 50)
    return () => clearTimeout(timeout)
  }, [isCompact, visible])

  if (!visible) return null

  const content = (
    <SplitViewColumnContext.Provider value={kind}>
      {isCompact && backColumn ? (
        <Pressable
          role="button"
          onPress={() => setColumn(backColumn)}
          hitSlop={8}
          style={({ pressed }) => [styles.back, pressed && styles.pressed]}
        >
          <Icon
            as={CaretLeftIcon}
            weight="bold"
            mirrored={I18nManager.isRTL}
            size={18}
            color={colors.primary}
          />
          <TextStyleContext.Provider
            value={{ ...fontSize.base, color: colors.primary }}
          >
            {typeof titles[backColumn] === "string" ? (
              <Text>{titles[backColumn]}</Text>
            ) : (
              titles[backColumn]
            )}
          </TextStyleContext.Provider>
        </Pressable>
      ) : null}
      {title !== undefined && (isCompact || kind !== "list") ? (
        <Text
          ref={headingRef}
          role="heading"
          style={titleStyle(kind, isCompact, colors)}
        >
          {title}
        </Text>
      ) : null}
      {children}
    </SplitViewColumnContext.Provider>
  )

  if (isCompact) {
    return (
      <AnimatedView
        key={kind}
        entering={FadeIn.duration(200).reduceMotion(ReduceMotion.System)}
        style={styles.fill}
        accessibilityLabel={typeof title === "string" ? title : undefined}
        {...props}
      >
        <ScrollView style={styles.fill} contentContainerStyle={style}>
          {content}
        </ScrollView>
      </AnimatedView>
    )
  }

  return (
    <View
      style={[
        styles.pane,
        kinds[0] !== kind && [
          styles.paneDivider,
          { borderColor: colors.border },
        ],
        { flex: RATIO[kind] / total },
      ]}
      accessibilityLabel={typeof title === "string" ? title : undefined}
      {...props}
    >
      <ScrollView
        style={styles.fill}
        contentContainerStyle={[PANE_STYLE[kind], style]}
      >
        {content}
      </ScrollView>
    </View>
  )
}

function SplitViewSidebar(props: SplitViewPaneProps) {
  return <SplitViewPane kind="sidebar" {...props} />
}

function SplitViewList(props: SplitViewPaneProps) {
  return <SplitViewPane kind="list" {...props} />
}

function SplitViewDetail(props: SplitViewPaneProps) {
  return <SplitViewPane kind="detail" {...props} />
}

const PANES = new Map<unknown, SplitViewColumn>([
  [SplitViewSidebar, "sidebar"],
  [SplitViewList, "list"],
  [SplitViewDetail, "detail"],
])

function SplitViewItem({
  isActive = false,
  style,
  children,
  onPress,
  accessibilityState,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "style"> & {
  isActive?: boolean
  children?: React.ReactNode
  style?: ViewStyle
}) {
  const { colors } = useTheme()
  const { isCompact, setColumn } = useSplitView()
  const kind = React.useContext(SplitViewColumnContext) ?? "list"
  return (
    <Pressable
      role="button"
      accessibilityState={{ ...accessibilityState, selected: isActive }}
      onPress={(event) => {
        onPress?.(event)
        setColumn(NEXT[kind])
      }}
      style={({ pressed }) => [
        styles.item,
        isCompact
          ? [styles.itemCompact, { borderColor: colors.border }]
          : [styles.itemWide, kind === "list" && styles.itemWideList],
        !isCompact && isActive && { backgroundColor: colors.muted },
        pressed && (isCompact || !isActive) && { opacity: 0.8 },
        style,
      ]}
      {...props}
    >
      <View style={styles.itemBody}>
        {typeof children === "string" || typeof children === "number" ? (
          <Text>{children}</Text>
        ) : (
          children
        )}
      </View>
      {isCompact && kind !== "detail" ? (
        <Icon
          as={CaretRightIcon}
          mirrored={I18nManager.isRTL}
          size={16}
          color={colors.mutedForeground}
        />
      ) : null}
    </Pressable>
  )
}

export {
  SplitView,
  type SplitViewColumn,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
  SplitViewSidebar,
  useSplitView,
}
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Patterns/SplitView.swift"
import PhosphorSwift
import SwiftUI

public enum SplitViewColumn: CaseIterable, Sendable {
  case sidebar, list, detail

  var next: SplitViewColumn {
    switch self {
    case .sidebar: .list
    case .list, .detail: .detail
    }
  }

  var previous: SplitViewColumn? {
    switch self {
    case .sidebar: nil
    case .list: .sidebar
    case .detail: .list
    }
  }

  var order: Int {
    switch self {
    case .sidebar: 0
    case .list: 1
    case .detail: 2
    }
  }

  var weight: CGFloat {
    switch self {
    case .sidebar: 20
    case .list: 32
    case .detail: 48
    }
  }
}

enum SplitViewMath {
  static let wideWidth: CGFloat = 768

  static func isWide(_ width: CGFloat) -> Bool {
    width >= wideWidth
  }

  static func backColumn(for kind: SplitViewColumn, present: [SplitViewColumn]) -> SplitViewColumn? {
    guard let previous = kind.previous else { return nil }
    return present.contains(previous) ? previous : nil
  }

  static func widths(total: CGFloat, present: [SplitViewColumn]) -> [SplitViewColumn: CGFloat] {
    let available = max(0, total - CGFloat(max(present.count - 1, 0)))
    let sum = present.reduce(0) { $0 + $1.weight }
    guard sum > 0 else { return [:] }
    var result: [SplitViewColumn: CGFloat] = [:]
    for column in present {
      result[column] = (available * column.weight / sum).rounded(.down)
    }
    return result
  }
}

struct SplitViewColumnKey: LayoutValueKey {
  static let defaultValue: SplitViewColumn? = nil
}

struct SplitViewColumnsLayout: Layout {
  func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    let width = proposal.width ?? SplitViewMath.wideWidth
    if let height = proposal.height { return CGSize(width: width, height: height) }
    let widths = columnWidths(total: width, subviews: subviews)
    let height = zip(subviews, widths).map { $0.sizeThatFits(ProposedViewSize(width: $1, height: nil)).height }.max() ?? 0
    return CGSize(width: width, height: height)
  }

  func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    let widths = columnWidths(total: bounds.width, subviews: subviews)
    var x = bounds.minX
    for (subview, width) in zip(subviews, widths) {
      subview.place(
        at: CGPoint(x: x, y: bounds.minY),
        anchor: .topLeading,
        proposal: ProposedViewSize(width: width, height: bounds.height)
      )
      x += width + 1
    }
  }

  private func columnWidths(total: CGFloat, subviews: Subviews) -> [CGFloat] {
    let kinds = subviews.map { $0[SplitViewColumnKey.self] ?? .detail }
    let widths = SplitViewMath.widths(total: total, present: kinds)
    var result = kinds.map { widths[$0] ?? 0 }
    if !result.isEmpty {
      let used = result.dropLast().reduce(0, +) + CGFloat(result.count - 1)
      result[result.count - 1] = max(0, total - used)
    }
    return result
  }
}

struct SplitViewPaneInfo: Equatable {
  let kind: SplitViewColumn
  let title: Text?
}

struct SplitViewPanesKey: PreferenceKey {
  static var defaultValue: [SplitViewPaneInfo] { [] }

  static func reduce(value: inout [SplitViewPaneInfo], nextValue: () -> [SplitViewPaneInfo]) {
    value.append(contentsOf: nextValue())
  }
}

struct SplitViewContext: @unchecked Sendable {
  var wide = false
  var column: SplitViewColumn = .list
  var panes: [SplitViewPaneInfo] = []
  var forward = true
  var select: @MainActor @Sendable (SplitViewColumn) -> Void = { _ in }

  func title(_ kind: SplitViewColumn) -> Text? {
    panes.first { $0.kind == kind }?.title
  }
}

extension EnvironmentValues {
  @Entry var splitView: SplitViewContext? = nil
  @Entry var splitViewColumn: SplitViewColumn = .list
}

public struct SplitView<Content: View>: View {
  let external: Binding<SplitViewColumn>?
  let content: Content

  @State private var local: SplitViewColumn
  @State private var width: CGFloat = 0
  @State private var panes: [SplitViewPaneInfo] = []
  @State private var forward = true
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(column: Binding<SplitViewColumn>, @ViewBuilder content: () -> Content) {
    self.external = column
    self._local = State(initialValue: column.wrappedValue)
    self.content = content()
  }

  public init(defaultColumn: SplitViewColumn = .list, @ViewBuilder content: () -> Content) {
    self.external = nil
    self._local = State(initialValue: defaultColumn)
    self.content = content()
  }

  public var body: some View {
    let binding = external ?? $local
    let current = binding.wrappedValue
    let wide = SplitViewMath.isWide(width)
    let animation: Animation? = reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.35)
    let context = SplitViewContext(
      wide: wide,
      column: current,
      panes: panes,
      forward: forward,
      select: { next in
        guard next != binding.wrappedValue else { return }
        forward = next.order > binding.wrappedValue.order
        if wide {
          binding.wrappedValue = next
        } else {
          withAnimation(animation) { binding.wrappedValue = next }
        }
      }
    )
    Group {
      if wide {
        SplitViewColumnsLayout { content }
      } else {
        ZStack(alignment: .topLeading) { content }
      }
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
    .clipped()
    .environment(\.splitView, context)
    .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 }
    .onPreferenceChange(SplitViewPanesKey.self) { value in panes = value }
  }
}

struct SplitViewPane<Content: View>: View {
  let kind: SplitViewColumn
  let title: Text?
  let content: Content

  @Environment(\.splitView) private var splitView
  @Environment(\.layoutDirection) private var layoutDirection

  var body: some View {
    let context = splitView ?? SplitViewContext()
    Group {
      if context.wide {
        pane(context)
          .frame(maxHeight: .infinity, alignment: .top)
          .overlay(alignment: .leading) {
            if context.panes.first?.kind != kind {
              EsuissColor.border.frame(width: 1).offset(x: -1)
            }
          }
          .layoutValue(key: SplitViewColumnKey.self, value: kind)
      } else if context.column == kind {
        pane(context)
          .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
          .transition(
            .asymmetric(
              insertion: .move(edge: context.forward ? .trailing : .leading),
              removal: .move(edge: context.forward ? .leading : .trailing)
            )
          )
      } else {
        Color.clear
          .frame(width: 0, height: 0)
          .accessibilityHidden(true)
      }
    }
    .preference(key: SplitViewPanesKey.self, value: [SplitViewPaneInfo(kind: kind, title: title)])
  }

  private func pane(_ context: SplitViewContext) -> some View {
    let back = context.wide ? nil : SplitViewMath.backColumn(for: kind, present: context.panes.map(\.kind))
    return ScrollView {
      VStack(alignment: .leading, spacing: 0) {
        if let back {
          Button {
            context.select(back)
          } label: {
            HStack(spacing: 2) {
              Ph.caretLeft.bold
                .frame(width: 18, height: 18)
                .flipsForRightToLeftLayoutDirection(true)
              if let title = context.title(back) {
                title.lineLimit(1)
              } else {
                Text("Back")
              }
            }
            .scaledFont(.base)
            .foregroundStyle(EsuissColor.primary)
            .frame(minHeight: 44)
            .contentShape(Rectangle())
          }
          .buttonStyle(SplitViewBackStyle())
          .padding(.leading, -6)
        }
        if let title {
          heading(title, wide: context.wide)
        }
        VStack(alignment: .leading, spacing: 0) {
          content
        }
        .padding(.top, context.wide ? 0 : -1)
        .clipped()
        .environment(\.splitViewColumn, kind)
      }
      .padding(padding(wide: context.wide))
      .frame(maxWidth: .infinity, alignment: .leading)
    }
  }

  @ViewBuilder
  private func heading(_ title: Text, wide: Bool) -> some View {
    if !wide {
      title
        .scaledFont(.xl3, weight: .bold)
        .foregroundStyle(EsuissColor.foreground)
        .padding(.bottom, 8)
        .accessibilityAddTraits(.isHeader)
    } else {
      switch kind {
      case .sidebar:
        title
          .scaledFont(.xs, weight: .semibold)
          .foregroundStyle(EsuissColor.mutedForeground)
          .padding(.horizontal, 12)
          .padding(.top, 12)
          .padding(.bottom, 8)
          .accessibilityAddTraits(.isHeader)
      case .list:
        title
          .frame(width: 0, height: 0)
          .opacity(0)
          .accessibilityAddTraits(.isHeader)
      case .detail:
        title
          .scaledFont(.xl, weight: .semibold)
          .foregroundStyle(EsuissColor.foreground)
          .padding(.bottom, 4)
          .accessibilityAddTraits(.isHeader)
      }
    }
  }

  private func padding(wide: Bool) -> EdgeInsets {
    guard wide else { return EdgeInsets(top: 0, leading: 16, bottom: 16, trailing: 16) }
    switch kind {
    case .sidebar: return EdgeInsets(top: 4, leading: 12, bottom: 12, trailing: 12)
    case .list: return EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)
    case .detail: return EdgeInsets(top: 32, leading: 32, bottom: 32, trailing: 32)
    }
  }
}

private struct SplitViewBackStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label.opacity(configuration.isPressed ? 0.8 : 1)
  }
}

public struct SplitViewSidebar<Content: View>: View {
  let title: Text?
  let content: Content

  public init(_ title: LocalizedStringKey? = nil, @ViewBuilder content: () -> Content) {
    self.title = title.map { Text($0) }
    self.content = content()
  }

  public init(verbatim title: String, @ViewBuilder content: () -> Content) {
    self.title = Text(verbatim: title)
    self.content = content()
  }

  public var body: some View {
    SplitViewPane(kind: .sidebar, title: title, content: content)
  }
}

public struct SplitViewList<Content: View>: View {
  let title: Text?
  let content: Content

  public init(_ title: LocalizedStringKey? = nil, @ViewBuilder content: () -> Content) {
    self.title = title.map { Text($0) }
    self.content = content()
  }

  public init(verbatim title: String, @ViewBuilder content: () -> Content) {
    self.title = Text(verbatim: title)
    self.content = content()
  }

  public var body: some View {
    SplitViewPane(kind: .list, title: title, content: content)
  }
}

public struct SplitViewDetail<Content: View>: View {
  let title: Text?
  let content: Content

  public init(_ title: LocalizedStringKey? = nil, @ViewBuilder content: () -> Content) {
    self.title = title.map { Text($0) }
    self.content = content()
  }

  public init(verbatim title: String, @ViewBuilder content: () -> Content) {
    self.title = Text(verbatim: title)
    self.content = content()
  }

  public var body: some View {
    SplitViewPane(kind: .detail, title: title, content: content)
  }
}

public struct SplitViewItem<Label: View>: View {
  let isActive: Bool
  let action: (() -> Void)?
  let label: Label

  @Environment(\.splitView) private var splitView
  @Environment(\.splitViewColumn) private var kind

  public init(isActive: Bool = false, action: (() -> Void)? = nil, @ViewBuilder label: () -> Label) {
    self.isActive = isActive
    self.action = action
    self.label = label()
  }

  public var body: some View {
    let wide = splitView?.wide ?? false
    Button {
      action?()
      splitView?.select(kind.next)
    } label: {
      HStack(spacing: 12) {
        VStack(alignment: .leading, spacing: 2) {
          label
        }
        .scaledFont(.base)
        .frame(maxWidth: .infinity, alignment: .leading)
        if !wide && kind != .detail {
          Ph.caretRight.regular
            .frame(width: 16, height: 16)
            .foregroundStyle(EsuissColor.mutedForeground)
            .flipsForRightToLeftLayoutDirection(true)
            .accessibilityHidden(true)
        }
      }
    }
    .buttonStyle(SplitViewItemStyle(wide: wide, kind: kind, active: isActive))
    .accessibilityAddTraits(isActive ? .isSelected : [])
  }
}

private struct SplitViewItemStyle: ButtonStyle {
  let wide: Bool
  let kind: SplitViewColumn
  let active: Bool

  @Environment(\.isEnabled) private var isEnabled

  func makeBody(configuration: Configuration) -> some View {
    let pressed = configuration.isPressed
    if wide {
      let shape = RoundedRectangle(cornerRadius: kind == .list ? Radius.xl : Radius.lg, style: .continuous)
      configuration.label
        .foregroundStyle(EsuissColor.foreground)
        .padding(.horizontal, 12)
        .padding(.vertical, kind == .list ? 12 : 8)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(active ? EsuissColor.accent : .clear, in: shape)
        .opacity(isEnabled ? (pressed ? 0.8 : 1) : 0.5)
        .contentShape(shape)
    } else {
      configuration.label
        .foregroundStyle(EsuissColor.foreground)
        .padding(.vertical, 12)
        .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
        .opacity(isEnabled ? (pressed ? 0.8 : 1) : 0.5)
        .overlay(alignment: .top) {
          EsuissColor.border.frame(height: 1)
        }
        .contentShape(Rectangle())
    }
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/patterns/SplitView.kt"
package com.esuiss.ui.patterns

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.CaretLeft
import com.adamglin.phosphoricons.regular.CaretRight
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentColor
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlin.math.abs

enum class SplitViewColumn { Sidebar, List, Detail }

private class SplitViewPane(
    val kind: SplitViewColumn,
    val title: String?,
    val modifier: Modifier,
    val content: @Composable ColumnScope.() -> Unit,
)

interface SplitViewScope {
    fun SplitViewSidebar(title: String? = null, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit)

    fun SplitViewList(title: String? = null, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit)

    fun SplitViewDetail(title: String? = null, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit)
}

private class SplitViewScopeImpl : SplitViewScope {
    val panes = mutableListOf<SplitViewPane>()

    override fun SplitViewSidebar(title: String?, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) {
        panes += SplitViewPane(SplitViewColumn.Sidebar, title, modifier, content)
    }

    override fun SplitViewList(title: String?, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) {
        panes += SplitViewPane(SplitViewColumn.List, title, modifier, content)
    }

    override fun SplitViewDetail(title: String?, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) {
        panes += SplitViewPane(SplitViewColumn.Detail, title, modifier, content)
    }
}

private class SplitViewItemEdges {
    val edges = mutableStateMapOf<Any, Pair<Float, Float>>()
}

@Immutable
private class SplitViewContext(
    val compact: Boolean,
    val kind: SplitViewColumn,
    val setColumn: (SplitViewColumn) -> Unit,
    val items: SplitViewItemEdges = SplitViewItemEdges(),
)

private val LocalSplitView = staticCompositionLocalOf<SplitViewContext?> { null }

private fun next(kind: SplitViewColumn): SplitViewColumn = when (kind) {
    SplitViewColumn.Sidebar -> SplitViewColumn.List
    SplitViewColumn.List, SplitViewColumn.Detail -> SplitViewColumn.Detail
}

private fun previous(kind: SplitViewColumn): SplitViewColumn? = when (kind) {
    SplitViewColumn.Sidebar -> null
    SplitViewColumn.List -> SplitViewColumn.Sidebar
    SplitViewColumn.Detail -> SplitViewColumn.List
}

private val WideBreakpoint = 768.dp

@Composable
fun SplitView(
    modifier: Modifier = Modifier,
    column: SplitViewColumn? = null,
    defaultColumn: SplitViewColumn = SplitViewColumn.List,
    onColumnChange: (SplitViewColumn) -> Unit = {},
    content: SplitViewScope.() -> Unit,
) {
    val panes = SplitViewScopeImpl().apply(content).panes.toList()
    var uncontrolled by rememberSaveable { mutableStateOf(defaultColumn) }
    val current = column ?: uncontrolled
    val setColumn: (SplitViewColumn) -> Unit = { nextColumn ->
        if (column == null) uncontrolled = nextColumn
        onColumnChange(nextColumn)
    }
    val titles = panes.associate { it.kind to it.title }
    BoxWithConstraints(modifier = modifier) {
        if (maxWidth >= WideBreakpoint) {
            WideSplitView(panes, setColumn)
        } else {
            AnimatedContent(
                targetState = current,
                transitionSpec = {
                    val forward = targetState.ordinal > initialState.ordinal
                    (slideInHorizontally(tween(300)) { if (forward) it / 3 else -it / 3 } + fadeIn(tween(300))) togetherWith
                        (slideOutHorizontally(tween(300)) { if (forward) -it / 3 else it / 3 } + fadeOut(tween(200)))
                },
                label = "splitView",
            ) { shown ->
                val pane = panes.firstOrNull { it.kind == shown }
                if (pane != null) {
                    val back = previous(pane.kind)?.takeIf { target ->
                        panes.any { it.kind == target } && !(target == SplitViewColumn.Sidebar && titles[target] == null)
                    }
                    CompactPane(pane, back, back?.let { titles[it] }, setColumn)
                }
            }
        }
    }
}

@Composable
private fun CompactPane(
    pane: SplitViewPane,
    back: SplitViewColumn?,
    backTitle: String?,
    setColumn: (SplitViewColumn) -> Unit,
) {
    val colors = EsuissTheme.colors
    CompositionLocalProvider(
        LocalSplitView provides SplitViewContext(compact = true, kind = pane.kind, setColumn = setColumn, items = remember { SplitViewItemEdges() }),
        LocalContentStyle provides Typography.base.copy(color = colors.foreground),
        LocalContentColor provides colors.foreground,
    ) {
        Column(
            modifier = pane.modifier
                .fillMaxSize()
                .verticalScroll(rememberScrollState())
                .padding(PaddingValues(horizontal = 16.dp, vertical = 8.dp)),
        ) {
            if (back != null) {
                val interaction = remember { MutableInteractionSource() }
                val pressed by interaction.collectIsPressedAsState()
                val tint = colors.primary
                Row(
                    modifier = Modifier
                        .heightIn(min = 44.dp)
                        .alpha(if (pressed) 0.8f else 1f)
                        .clickable(interactionSource = interaction, indication = null, role = Role.Button) { setColumn(back) },
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.spacedBy(2.dp),
                ) {
                    Icon(PhosphorIcons.Bold.CaretLeft, contentDescription = if (backTitle == null) "Back" else null, size = 18.dp, tint = tint)
                    if (backTitle != null) BasicText(backTitle, style = Typography.base.copy(color = tint))
                }
            }
            if (pane.title != null) {
                BasicText(
                    pane.title,
                    modifier = Modifier.padding(bottom = 8.dp).semantics { heading() },
                    style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.Bold),
                )
            }
            pane.content(this)
        }
    }
}

@Composable
private fun WideSplitView(panes: List<SplitViewPane>, setColumn: (SplitViewColumn) -> Unit) {
    val colors = EsuissTheme.colors
    val weights = panes.map {
        when (it.kind) {
            SplitViewColumn.Sidebar -> 0.20f
            SplitViewColumn.List -> 0.32f
            SplitViewColumn.Detail -> 0.48f
        }
    }
    Row(modifier = Modifier.fillMaxSize()) {
        panes.forEachIndexed { index, pane ->
            if (index > 0) Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(colors.border))
            CompositionLocalProvider(
                LocalSplitView provides SplitViewContext(compact = false, kind = pane.kind, setColumn = setColumn),
                LocalContentStyle provides Typography.base.copy(color = colors.foreground),
                LocalContentColor provides colors.foreground,
            ) {
                Column(
                    modifier = Modifier
                        .weight(weights[index])
                        .fillMaxHeight()
                        .verticalScroll(rememberScrollState())
                        .then(pane.modifier)
                        .padding(
                            when (pane.kind) {
                                SplitViewColumn.Sidebar -> PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 12.dp)
                                SplitViewColumn.List -> PaddingValues(12.dp)
                                SplitViewColumn.Detail -> PaddingValues(32.dp)
                            },
                        ),
                ) {
                    if (pane.title != null) {
                        when (pane.kind) {
                            SplitViewColumn.Sidebar -> BasicText(
                                pane.title,
                                modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 8.dp).semantics { heading() },
                                style = Typography.xs.copy(color = colors.mutedForeground, fontWeight = FontWeight.SemiBold),
                            )
                            SplitViewColumn.List -> Box(
                                modifier = Modifier.semantics {
                                    heading()
                                    contentDescription = pane.title
                                },
                            )
                            SplitViewColumn.Detail -> BasicText(
                                pane.title,
                                modifier = Modifier.padding(bottom = 4.dp).semantics { heading() },
                                style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                            )
                        }
                    }
                    pane.content(this)
                }
            }
        }
    }
}

@Composable
fun SplitViewItem(
    modifier: Modifier = Modifier,
    isActive: Boolean = false,
    onClick: () -> Unit = {},
    content: @Composable ColumnScope.() -> Unit,
) {
    val context = LocalSplitView.current ?: SplitViewContext(compact = true, kind = SplitViewColumn.List, setColumn = {})
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val id = remember { Any() }
    var top by remember { mutableFloatStateOf(Float.NaN) }
    val click = {
        onClick()
        context.setColumn(next(context.kind))
    }
    if (context.compact) {
        val separator = colors.border
        val edges = context.items.edges
        DisposableEffect(edges) { onDispose { edges.remove(id) } }
        val divided = edges.any { (key, edge) -> key !== id && abs(edge.second - top) < 1f }
        Row(
            modifier = modifier
                .fillMaxWidth()
                .heightIn(min = 44.dp)
                .onPlaced {
                    val y = it.positionInRoot().y
                    top = y
                    edges[id] = y to y + it.size.height
                }
                .alpha(if (pressed) 0.8f else 1f)
                .drawBehind { if (divided) drawRect(separator, Offset.Zero, Size(size.width, 1.dp.toPx())) }
                .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = click)
                .semantics { selected = isActive }
                .padding(vertical = 12.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp), content = content)
            if (context.kind != SplitViewColumn.Detail) {
                Icon(PhosphorIcons.Regular.CaretRight, contentDescription = null, size = 16.dp, tint = colors.mutedForeground)
            }
        }
    } else {
        val list = context.kind == SplitViewColumn.List
        val shape = RoundedCornerShape(if (list) Radius.xl else Radius.lg)
        Row(
            modifier = modifier
                .fillMaxWidth()
                .alpha(if (pressed) 0.8f else 1f)
                .clip(shape)
                .background(if (isActive) colors.muted else Color.Transparent, shape)
                .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = click)
                .semantics { selected = isActive }
                .padding(horizontal = 12.dp, vertical = if (list) 12.dp else 8.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp), content = content)
        }
    }
}
```

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { SplitView, SplitViewSidebar, SplitViewList, SplitViewDetail, SplitViewItem } from "@/components/patterns/split-view"
```

```tsx
<MailExample />
```

Give each pane a `title`; the back button only appears when the previous pane exists and has a title. Put `SplitViewItem`s in the sidebar and list panes, and use their `onPress` to set the selection in your own state.

The current column is uncontrolled with `defaultColumn` (`list` by default) or controlled with `column` and `onColumnChange`. `useSplitView` returns `isCompact`, `column` and `setColumn` for custom navigation.

## Examples

### Detail column

Starts on the detail column.

![Preview](https://ui.suiss.app/previews/patterns-split-view--detail-column.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { SplitView, type SplitViewColumn, SplitViewDetail, SplitViewItem, SplitViewList, SplitViewSidebar } from "@/components/patterns/split-view"
import { Text } from "@/components/ui/text"

const mailboxes = ["Inbox", "Sent", "Drafts", "Archive"]

const messages = [
  {
    id: "order",
    from: "Acme Store",
    time: "09:12",
    subject: "Your order has shipped",
    body: "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42.",
  },
  {
    id: "meeting",
    from: "Jordan Lee",
    time: "Yesterday",
    subject: "Tomorrow's meeting",
    body: "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan.",
  },
  {
    id: "storage",
    from: "Cloud Drive",
    time: "Mon",
    subject: "Your storage is almost full",
    body: "Your cloud storage is 90% full. Upgrade your plan to get more space.",
  },
] as const

function MailExample({
  withSidebar = true,
  defaultColumn,
}: {
  withSidebar?: boolean
  defaultColumn?: SplitViewColumn
}) {
  const [mailbox, setMailbox] = React.useState("Inbox")
  const [messageId, setMessageId] = React.useState<string>("order")
  const message = messages.find((item) => item.id === messageId) ?? messages[0]

  return (
    <SplitView defaultColumn={defaultColumn}>
      {withSidebar ? (
        <SplitViewSidebar title="Mailboxes">
          {mailboxes.map((name) => (
            <SplitViewItem
              key={name}
              isActive={mailbox === name}
              onPress={() => setMailbox(name)}
            >
              {name}
            </SplitViewItem>
          ))}
        </SplitViewSidebar>
      ) : null}
      <SplitViewList title={mailbox}>
        {messages.map((item) => (
          <SplitViewItem
            key={item.id}
            isActive={item.id === messageId}
            onPress={() => setMessageId(item.id)}
          >
            <View className="flex-row items-baseline justify-between gap-2">
              <Text className="shrink font-semibold" numberOfLines={1}>
                {item.from}
              </Text>
              <Text className="text-sm text-muted-foreground">{item.time}</Text>
            </View>
            <Text numberOfLines={1}>{item.subject}</Text>
            <Text className="text-sm text-muted-foreground" numberOfLines={1}>
              {item.body}
            </Text>
          </SplitViewItem>
        ))}
      </SplitViewList>
      <SplitViewDetail title={message.subject}>
        <Text className="text-sm text-muted-foreground">
          {message.from} · {message.time}
        </Text>
        <Text className="mt-6">{message.body}</Text>
      </SplitViewDetail>
    </SplitView>
  )
}

export function SplitViewDetailColumnExample() {
  return (
    <MailExample defaultColumn="detail" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SplitViewDetailColumnExample: View {
  var body: some View {
    SplitViewMail(column: .detail)
  }
}

struct SplitViewMessage: Identifiable {
  let id: String
  let from: String
  let time: String
  let subject: String
  let body: String
}

let splitViewMailboxes = ["Inbox", "Sent", "Drafts", "Archive"]

let splitViewMessages = [
  SplitViewMessage(
    id: "order",
    from: "Acme Store",
    time: "09:12",
    subject: "Your order has shipped",
    body: "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42."
  ),
  SplitViewMessage(
    id: "meeting",
    from: "Jordan Lee",
    time: "Yesterday",
    subject: "Tomorrow's meeting",
    body: "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan."
  ),
  SplitViewMessage(
    id: "storage",
    from: "Cloud Drive",
    time: "Mon",
    subject: "Your storage is almost full",
    body: "Your cloud storage is 90% full. Upgrade your plan to get more space."
  ),
]

struct SplitViewMail: View {
  var withSidebar = true

  @State private var column: SplitViewColumn
  @State private var mailbox = "Inbox"
  @State private var messageID = "order"

  init(column: SplitViewColumn = .list, withSidebar: Bool = true) {
    _column = State(initialValue: column)
    self.withSidebar = withSidebar
  }

  var message: SplitViewMessage {
    splitViewMessages.first { $0.id == messageID } ?? splitViewMessages[0]
  }

  var body: some View {
    SplitView(column: $column) {
      if withSidebar {
        SplitViewSidebar("Mailboxes") {
          ForEach(splitViewMailboxes, id: \.self) { name in
            SplitViewItem(isActive: mailbox == name, action: { mailbox = name }) {
              Text(verbatim: name)
            }
          }
        }
      }
      SplitViewList(verbatim: mailbox) {
        ForEach(splitViewMessages) { item in
          SplitViewItem(isActive: item.id == messageID, action: { messageID = item.id }) {
            HStack(alignment: .firstTextBaseline, spacing: 8) {
              Text(verbatim: item.from)
                .fontWeight(.semibold)
                .lineLimit(1)
                .frame(maxWidth: .infinity, alignment: .leading)
              Text(verbatim: item.time)
                .textStyle(.sm)
                .foregroundStyle(EsuissColor.mutedForeground)
            }
            Text(verbatim: item.subject)
              .lineLimit(1)
            Text(verbatim: item.body)
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
              .lineLimit(1)
          }
        }
      }
      SplitViewDetail(verbatim: message.subject) {
        Text(verbatim: "\(message.from) · \(message.time)")
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
        Text(verbatim: message.body)
          .foregroundStyle(EsuissColor.foreground)
          .fixedSize(horizontal: false, vertical: true)
          .padding(.top, 24)
      }
    }
    .containerRelativeFrame(.vertical) { height, _ in height - 32 }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.patterns.SplitView
import com.esuiss.ui.patterns.SplitViewColumn
import com.esuiss.ui.patterns.SplitViewItem
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun SplitViewDetailColumnExample() {
    SplitViewMail(defaultColumn = SplitViewColumn.Detail)
}

class SplitViewMessage(
    val id: String,
    val from: String,
    val time: String,
    val subject: String,
    val body: String,
)

val splitViewMailboxes = listOf("Inbox", "Sent", "Drafts", "Archive")

val splitViewMessages = listOf(
    SplitViewMessage(
        "order",
        "Acme Store",
        "09:12",
        "Your order has shipped",
        "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42.",
    ),
    SplitViewMessage(
        "meeting",
        "Jordan Lee",
        "Yesterday",
        "Tomorrow's meeting",
        "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan.",
    ),
    SplitViewMessage(
        "storage",
        "Cloud Drive",
        "Mon",
        "Your storage is almost full",
        "Your cloud storage is 90% full. Upgrade your plan to get more space.",
    ),
)

@Composable
fun SplitViewMail(withSidebar: Boolean = true, defaultColumn: SplitViewColumn = SplitViewColumn.List) {
    var mailbox by rememberSaveable { mutableStateOf("Inbox") }
    var messageId by rememberSaveable { mutableStateOf("order") }
    val message = splitViewMessages.firstOrNull { it.id == messageId } ?: splitViewMessages.first()
    val colors = EsuissTheme.colors
    SplitView(modifier = Modifier.fillMaxWidth().height(640.dp), defaultColumn = defaultColumn) {
        if (withSidebar) {
            SplitViewSidebar(title = "Mailboxes") {
                splitViewMailboxes.forEach { name ->
                    SplitViewItem(isActive = mailbox == name, onClick = { mailbox = name }) {
                        Text(name)
                    }
                }
            }
        }
        SplitViewList(title = mailbox) {
            splitViewMessages.forEach { item ->
                SplitViewItem(isActive = item.id == messageId, onClick = { messageId = item.id }) {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(8.dp),
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        Text(
                            item.from,
                            modifier = Modifier.weight(1f),
                            style = Typography.base.copy(fontWeight = FontWeight.SemiBold),
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis,
                        )
                        Text(item.time, style = Typography.sm.copy(color = colors.mutedForeground), maxLines = 1)
                    }
                    Text(item.subject, maxLines = 1, overflow = TextOverflow.Ellipsis)
                    Text(
                        item.body,
                        style = Typography.sm.copy(color = colors.mutedForeground),
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                    )
                }
            }
        }
        SplitViewDetail(title = message.subject) {
            Text("${message.from} · ${message.time}", style = Typography.sm.copy(color = colors.mutedForeground))
            Text(message.body, modifier = Modifier.padding(top = 24.dp))
        }
    }
}
```

### Start on sidebar

Starts on the sidebar column.

![Preview](https://ui.suiss.app/previews/patterns-split-view--start-on-sidebar.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { SplitView, type SplitViewColumn, SplitViewDetail, SplitViewItem, SplitViewList, SplitViewSidebar } from "@/components/patterns/split-view"
import { Text } from "@/components/ui/text"

const mailboxes = ["Inbox", "Sent", "Drafts", "Archive"]

const messages = [
  {
    id: "order",
    from: "Acme Store",
    time: "09:12",
    subject: "Your order has shipped",
    body: "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42.",
  },
  {
    id: "meeting",
    from: "Jordan Lee",
    time: "Yesterday",
    subject: "Tomorrow's meeting",
    body: "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan.",
  },
  {
    id: "storage",
    from: "Cloud Drive",
    time: "Mon",
    subject: "Your storage is almost full",
    body: "Your cloud storage is 90% full. Upgrade your plan to get more space.",
  },
] as const

function MailExample({
  withSidebar = true,
  defaultColumn,
}: {
  withSidebar?: boolean
  defaultColumn?: SplitViewColumn
}) {
  const [mailbox, setMailbox] = React.useState("Inbox")
  const [messageId, setMessageId] = React.useState<string>("order")
  const message = messages.find((item) => item.id === messageId) ?? messages[0]

  return (
    <SplitView defaultColumn={defaultColumn}>
      {withSidebar ? (
        <SplitViewSidebar title="Mailboxes">
          {mailboxes.map((name) => (
            <SplitViewItem
              key={name}
              isActive={mailbox === name}
              onPress={() => setMailbox(name)}
            >
              {name}
            </SplitViewItem>
          ))}
        </SplitViewSidebar>
      ) : null}
      <SplitViewList title={mailbox}>
        {messages.map((item) => (
          <SplitViewItem
            key={item.id}
            isActive={item.id === messageId}
            onPress={() => setMessageId(item.id)}
          >
            <View className="flex-row items-baseline justify-between gap-2">
              <Text className="shrink font-semibold" numberOfLines={1}>
                {item.from}
              </Text>
              <Text className="text-sm text-muted-foreground">{item.time}</Text>
            </View>
            <Text numberOfLines={1}>{item.subject}</Text>
            <Text className="text-sm text-muted-foreground" numberOfLines={1}>
              {item.body}
            </Text>
          </SplitViewItem>
        ))}
      </SplitViewList>
      <SplitViewDetail title={message.subject}>
        <Text className="text-sm text-muted-foreground">
          {message.from} · {message.time}
        </Text>
        <Text className="mt-6">{message.body}</Text>
      </SplitViewDetail>
    </SplitView>
  )
}

export function SplitViewStartOnSidebarExample() {
  return (
    <MailExample defaultColumn="sidebar" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SplitViewStartOnSidebarExample: View {
  var body: some View {
    SplitViewMail(column: .sidebar)
  }
}

struct SplitViewMessage: Identifiable {
  let id: String
  let from: String
  let time: String
  let subject: String
  let body: String
}

let splitViewMailboxes = ["Inbox", "Sent", "Drafts", "Archive"]

let splitViewMessages = [
  SplitViewMessage(
    id: "order",
    from: "Acme Store",
    time: "09:12",
    subject: "Your order has shipped",
    body: "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42."
  ),
  SplitViewMessage(
    id: "meeting",
    from: "Jordan Lee",
    time: "Yesterday",
    subject: "Tomorrow's meeting",
    body: "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan."
  ),
  SplitViewMessage(
    id: "storage",
    from: "Cloud Drive",
    time: "Mon",
    subject: "Your storage is almost full",
    body: "Your cloud storage is 90% full. Upgrade your plan to get more space."
  ),
]

struct SplitViewMail: View {
  var withSidebar = true

  @State private var column: SplitViewColumn
  @State private var mailbox = "Inbox"
  @State private var messageID = "order"

  init(column: SplitViewColumn = .list, withSidebar: Bool = true) {
    _column = State(initialValue: column)
    self.withSidebar = withSidebar
  }

  var message: SplitViewMessage {
    splitViewMessages.first { $0.id == messageID } ?? splitViewMessages[0]
  }

  var body: some View {
    SplitView(column: $column) {
      if withSidebar {
        SplitViewSidebar("Mailboxes") {
          ForEach(splitViewMailboxes, id: \.self) { name in
            SplitViewItem(isActive: mailbox == name, action: { mailbox = name }) {
              Text(verbatim: name)
            }
          }
        }
      }
      SplitViewList(verbatim: mailbox) {
        ForEach(splitViewMessages) { item in
          SplitViewItem(isActive: item.id == messageID, action: { messageID = item.id }) {
            HStack(alignment: .firstTextBaseline, spacing: 8) {
              Text(verbatim: item.from)
                .fontWeight(.semibold)
                .lineLimit(1)
                .frame(maxWidth: .infinity, alignment: .leading)
              Text(verbatim: item.time)
                .textStyle(.sm)
                .foregroundStyle(EsuissColor.mutedForeground)
            }
            Text(verbatim: item.subject)
              .lineLimit(1)
            Text(verbatim: item.body)
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
              .lineLimit(1)
          }
        }
      }
      SplitViewDetail(verbatim: message.subject) {
        Text(verbatim: "\(message.from) · \(message.time)")
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
        Text(verbatim: message.body)
          .foregroundStyle(EsuissColor.foreground)
          .fixedSize(horizontal: false, vertical: true)
          .padding(.top, 24)
      }
    }
    .containerRelativeFrame(.vertical) { height, _ in height - 32 }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.patterns.SplitView
import com.esuiss.ui.patterns.SplitViewColumn
import com.esuiss.ui.patterns.SplitViewItem
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun SplitViewStartOnSidebarExample() {
    SplitViewMail(defaultColumn = SplitViewColumn.Sidebar)
}

class SplitViewMessage(
    val id: String,
    val from: String,
    val time: String,
    val subject: String,
    val body: String,
)

val splitViewMailboxes = listOf("Inbox", "Sent", "Drafts", "Archive")

val splitViewMessages = listOf(
    SplitViewMessage(
        "order",
        "Acme Store",
        "09:12",
        "Your order has shipped",
        "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42.",
    ),
    SplitViewMessage(
        "meeting",
        "Jordan Lee",
        "Yesterday",
        "Tomorrow's meeting",
        "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan.",
    ),
    SplitViewMessage(
        "storage",
        "Cloud Drive",
        "Mon",
        "Your storage is almost full",
        "Your cloud storage is 90% full. Upgrade your plan to get more space.",
    ),
)

@Composable
fun SplitViewMail(withSidebar: Boolean = true, defaultColumn: SplitViewColumn = SplitViewColumn.List) {
    var mailbox by rememberSaveable { mutableStateOf("Inbox") }
    var messageId by rememberSaveable { mutableStateOf("order") }
    val message = splitViewMessages.firstOrNull { it.id == messageId } ?: splitViewMessages.first()
    val colors = EsuissTheme.colors
    SplitView(modifier = Modifier.fillMaxWidth().height(640.dp), defaultColumn = defaultColumn) {
        if (withSidebar) {
            SplitViewSidebar(title = "Mailboxes") {
                splitViewMailboxes.forEach { name ->
                    SplitViewItem(isActive = mailbox == name, onClick = { mailbox = name }) {
                        Text(name)
                    }
                }
            }
        }
        SplitViewList(title = mailbox) {
            splitViewMessages.forEach { item ->
                SplitViewItem(isActive = item.id == messageId, onClick = { messageId = item.id }) {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(8.dp),
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        Text(
                            item.from,
                            modifier = Modifier.weight(1f),
                            style = Typography.base.copy(fontWeight = FontWeight.SemiBold),
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis,
                        )
                        Text(item.time, style = Typography.sm.copy(color = colors.mutedForeground), maxLines = 1)
                    }
                    Text(item.subject, maxLines = 1, overflow = TextOverflow.Ellipsis)
                    Text(
                        item.body,
                        style = Typography.sm.copy(color = colors.mutedForeground),
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                    )
                }
            }
        }
        SplitViewDetail(title = message.subject) {
            Text("${message.from} · ${message.time}", style = Typography.sm.copy(color = colors.mutedForeground))
            Text(message.body, modifier = Modifier.padding(top = 24.dp))
        }
    }
}
```

### Two columns

Only list and detail panes, without a sidebar.

![Preview](https://ui.suiss.app/previews/patterns-split-view--two-columns.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { SplitView, type SplitViewColumn, SplitViewDetail, SplitViewItem, SplitViewList, SplitViewSidebar } from "@/components/patterns/split-view"
import { Text } from "@/components/ui/text"

const mailboxes = ["Inbox", "Sent", "Drafts", "Archive"]

const messages = [
  {
    id: "order",
    from: "Acme Store",
    time: "09:12",
    subject: "Your order has shipped",
    body: "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42.",
  },
  {
    id: "meeting",
    from: "Jordan Lee",
    time: "Yesterday",
    subject: "Tomorrow's meeting",
    body: "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan.",
  },
  {
    id: "storage",
    from: "Cloud Drive",
    time: "Mon",
    subject: "Your storage is almost full",
    body: "Your cloud storage is 90% full. Upgrade your plan to get more space.",
  },
] as const

function MailExample({
  withSidebar = true,
  defaultColumn,
}: {
  withSidebar?: boolean
  defaultColumn?: SplitViewColumn
}) {
  const [mailbox, setMailbox] = React.useState("Inbox")
  const [messageId, setMessageId] = React.useState<string>("order")
  const message = messages.find((item) => item.id === messageId) ?? messages[0]

  return (
    <SplitView defaultColumn={defaultColumn}>
      {withSidebar ? (
        <SplitViewSidebar title="Mailboxes">
          {mailboxes.map((name) => (
            <SplitViewItem
              key={name}
              isActive={mailbox === name}
              onPress={() => setMailbox(name)}
            >
              {name}
            </SplitViewItem>
          ))}
        </SplitViewSidebar>
      ) : null}
      <SplitViewList title={mailbox}>
        {messages.map((item) => (
          <SplitViewItem
            key={item.id}
            isActive={item.id === messageId}
            onPress={() => setMessageId(item.id)}
          >
            <View className="flex-row items-baseline justify-between gap-2">
              <Text className="shrink font-semibold" numberOfLines={1}>
                {item.from}
              </Text>
              <Text className="text-sm text-muted-foreground">{item.time}</Text>
            </View>
            <Text numberOfLines={1}>{item.subject}</Text>
            <Text className="text-sm text-muted-foreground" numberOfLines={1}>
              {item.body}
            </Text>
          </SplitViewItem>
        ))}
      </SplitViewList>
      <SplitViewDetail title={message.subject}>
        <Text className="text-sm text-muted-foreground">
          {message.from} · {message.time}
        </Text>
        <Text className="mt-6">{message.body}</Text>
      </SplitViewDetail>
    </SplitView>
  )
}

export function SplitViewTwoColumnsExample() {
  return (
    <MailExample withSidebar={false} />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SplitViewTwoColumnsExample: View {
  var body: some View {
    SplitViewMail(withSidebar: false)
  }
}

struct SplitViewMessage: Identifiable {
  let id: String
  let from: String
  let time: String
  let subject: String
  let body: String
}

let splitViewMailboxes = ["Inbox", "Sent", "Drafts", "Archive"]

let splitViewMessages = [
  SplitViewMessage(
    id: "order",
    from: "Acme Store",
    time: "09:12",
    subject: "Your order has shipped",
    body: "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42."
  ),
  SplitViewMessage(
    id: "meeting",
    from: "Jordan Lee",
    time: "Yesterday",
    subject: "Tomorrow's meeting",
    body: "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan."
  ),
  SplitViewMessage(
    id: "storage",
    from: "Cloud Drive",
    time: "Mon",
    subject: "Your storage is almost full",
    body: "Your cloud storage is 90% full. Upgrade your plan to get more space."
  ),
]

struct SplitViewMail: View {
  var withSidebar = true

  @State private var column: SplitViewColumn
  @State private var mailbox = "Inbox"
  @State private var messageID = "order"

  init(column: SplitViewColumn = .list, withSidebar: Bool = true) {
    _column = State(initialValue: column)
    self.withSidebar = withSidebar
  }

  var message: SplitViewMessage {
    splitViewMessages.first { $0.id == messageID } ?? splitViewMessages[0]
  }

  var body: some View {
    SplitView(column: $column) {
      if withSidebar {
        SplitViewSidebar("Mailboxes") {
          ForEach(splitViewMailboxes, id: \.self) { name in
            SplitViewItem(isActive: mailbox == name, action: { mailbox = name }) {
              Text(verbatim: name)
            }
          }
        }
      }
      SplitViewList(verbatim: mailbox) {
        ForEach(splitViewMessages) { item in
          SplitViewItem(isActive: item.id == messageID, action: { messageID = item.id }) {
            HStack(alignment: .firstTextBaseline, spacing: 8) {
              Text(verbatim: item.from)
                .fontWeight(.semibold)
                .lineLimit(1)
                .frame(maxWidth: .infinity, alignment: .leading)
              Text(verbatim: item.time)
                .textStyle(.sm)
                .foregroundStyle(EsuissColor.mutedForeground)
            }
            Text(verbatim: item.subject)
              .lineLimit(1)
            Text(verbatim: item.body)
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
              .lineLimit(1)
          }
        }
      }
      SplitViewDetail(verbatim: message.subject) {
        Text(verbatim: "\(message.from) · \(message.time)")
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
        Text(verbatim: message.body)
          .foregroundStyle(EsuissColor.foreground)
          .fixedSize(horizontal: false, vertical: true)
          .padding(.top, 24)
      }
    }
    .containerRelativeFrame(.vertical) { height, _ in height - 32 }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.patterns.SplitView
import com.esuiss.ui.patterns.SplitViewColumn
import com.esuiss.ui.patterns.SplitViewItem
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun SplitViewTwoColumnsExample() {
    SplitViewMail(withSidebar = false)
}

class SplitViewMessage(
    val id: String,
    val from: String,
    val time: String,
    val subject: String,
    val body: String,
)

val splitViewMailboxes = listOf("Inbox", "Sent", "Drafts", "Archive")

val splitViewMessages = listOf(
    SplitViewMessage(
        "order",
        "Acme Store",
        "09:12",
        "Your order has shipped",
        "Order #1042 will be delivered tomorrow. Tracking number: 4815 1623 42.",
    ),
    SplitViewMessage(
        "meeting",
        "Jordan Lee",
        "Yesterday",
        "Tomorrow's meeting",
        "Does 10:00 work for you? On the agenda: the Q4 budget and the campaign plan.",
    ),
    SplitViewMessage(
        "storage",
        "Cloud Drive",
        "Mon",
        "Your storage is almost full",
        "Your cloud storage is 90% full. Upgrade your plan to get more space.",
    ),
)

@Composable
fun SplitViewMail(withSidebar: Boolean = true, defaultColumn: SplitViewColumn = SplitViewColumn.List) {
    var mailbox by rememberSaveable { mutableStateOf("Inbox") }
    var messageId by rememberSaveable { mutableStateOf("order") }
    val message = splitViewMessages.firstOrNull { it.id == messageId } ?: splitViewMessages.first()
    val colors = EsuissTheme.colors
    SplitView(modifier = Modifier.fillMaxWidth().height(640.dp), defaultColumn = defaultColumn) {
        if (withSidebar) {
            SplitViewSidebar(title = "Mailboxes") {
                splitViewMailboxes.forEach { name ->
                    SplitViewItem(isActive = mailbox == name, onClick = { mailbox = name }) {
                        Text(name)
                    }
                }
            }
        }
        SplitViewList(title = mailbox) {
            splitViewMessages.forEach { item ->
                SplitViewItem(isActive = item.id == messageId, onClick = { messageId = item.id }) {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.spacedBy(8.dp),
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        Text(
                            item.from,
                            modifier = Modifier.weight(1f),
                            style = Typography.base.copy(fontWeight = FontWeight.SemiBold),
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis,
                        )
                        Text(item.time, style = Typography.sm.copy(color = colors.mutedForeground), maxLines = 1)
                    }
                    Text(item.subject, maxLines = 1, overflow = TextOverflow.Ellipsis)
                    Text(
                        item.body,
                        style = Typography.sm.copy(color = colors.mutedForeground),
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                    )
                }
            }
        }
        SplitViewDetail(title = message.subject) {
            Text("${message.from} · ${message.time}", style = Typography.sm.copy(color = colors.mutedForeground))
            Text(message.body, modifier = Modifier.padding(top = 24.dp))
        }
    }
}
```

## API Reference

### SplitView

The root that tracks the current column and the layout mode.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `column` | `SplitViewColumn` | - | The visible column on phones when controlled: `sidebar`, `list` or `detail`. |
| `defaultColumn` | `SplitViewColumn` | `"list"` | The initial column when uncontrolled. Defaults to `list`. |
| `onColumnChange` | `(column: SplitViewColumn) => void` | - | Called with the new column. |
| `breakpoint` | `number` | `768` | Window width at which the panes sit side by side. Defaults to `768`. |

Also accepts the props of `ViewProps`.

### SplitViewSidebar

The first pane, for top-level sections.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `React.ReactNode` | - | The title shown at the top of the column. |

Also accepts the props of `SplitViewPaneProps`.

### SplitViewList

The middle pane, for the items in a section.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `React.ReactNode` | - | The title shown at the top of the column. |

Also accepts the props of `SplitViewPaneProps`.

### SplitViewDetail

The last pane, for the selected item.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `React.ReactNode` | - | The title shown at the top of the column. |

Also accepts the props of `SplitViewPaneProps`.

### SplitViewItem

A pressable row that moves to the next column.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `isActive` | `boolean` | `false` | Highlights the row and marks it as selected. |
| `children` | `React.ReactNode` | - |  |

Also accepts the props of `React.ComponentProps<typeof Pressable>`.

## Accessibility

- Pane titles have the `heading` role, and panes are labelled by string titles.
- On phones, screen reader focus moves to the new pane's heading after navigating.
- `SplitViewItem` exposes its `selected` state.
- Back and row chevrons mirror in right-to-left layouts.

## Notes

- On phones the new pane fades in, respecting the system reduce motion setting.
- Active rows use `bg-muted`; panes are divided by `border-border`.

