suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Patterns19
  • Overview
  • Action Bar
  • Action Menu
  • App Shell
  • Command Palette
  • Confirm
  • Context Actions
  • Date Picker
  • Flyout
  • Modal
  • Navbar
  • Paged List
  • Panel
  • Picker
  • Preview
  • Search Picker
  • Segment Picker
  • Split View
  • Table List
  • Trail

suiss/ui · Pattern

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.

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

$ pnpm dlx shadcn@latest add @esuiss/split-view

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add patterns split-view

Manual

  1. Install the dependencies and add the cn helper:
$ pnpm add @base-ui/react @phosphor-icons/react
  1. Add the pieces it builds on: Resizable, use-mobile.

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

  1. Update the import paths to match your project.

Usage

import { SplitView, SplitViewSidebar, SplitViewList, SplitViewDetail, SplitViewItem } 
<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.

Start on sidebar

Starts on the sidebar column on mobile through defaultColumn.

API Reference

01

SplitView

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

PropTypeDefaultDescription
columnSplitViewColumn-The visible mobile column when controlled: sidebar, list or detail.
defaultColumnSplitViewColumn"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">.

02

SplitViewSidebar

The leading pane, usually for sources or categories.

PropTypeDefaultDescription
titleReact.ReactNode-The pane heading and accessible name; also labels the back button in the list pane.

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

03

SplitViewList

The middle pane, usually a list of items.

PropTypeDefaultDescription
titleReact.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">.

04

SplitViewDetail

The trailing pane that shows the selected item.

PropTypeDefaultDescription
titleReact.ReactNode-The pane heading and accessible name.

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

05

SplitViewItem

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

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

Set asChild on SplitViewItem and pass a link as the child to render it as a link.

Pass a render function to SplitViewItem that returns a link with the props it receives.

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

$ pnpm dlx shadcn@latest add @esuiss-rn/split-view

See Installation for the components.json setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add split-view
$ pnpm dlx @esuiss/ui@latest add split-view --platform swift
NextTable ListPreviousSegment Picker

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • Two columns
    • Start on sidebar
  4. 04API Reference
    • SplitView
    • SplitViewSidebar
    • SplitViewList
    • SplitViewDetail
    • SplitViewItem
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Detail column
    • Start on sidebar
    • Two columns
  10. 10API Reference
    • SplitView
    • SplitViewSidebar
    • SplitViewList
    • SplitViewDetail
    • SplitViewItem
  11. 11Accessibility
  12. 12Notes
from
"@/components/patterns/split-view"
/>
$ pnpm dlx @esuiss/ui@latest add split-view --platform kotlin

The CLI also adds what it builds on: Icon, Text. On React Native it also adds the animated view components/ui/animated-view.tsx.

Manual

  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/hooks.tsx, and the animated view components/ui/animated-view.tsx from the next step.

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

  1. Copy the source into components/patterns/split-view.tsx:
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/hooks.tsx, and the animated view components/ui/animated-view.tsx from the next step.

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

  1. Copy the source into components/patterns/split-view.tsx:
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/hooks.tsx, and the animated view components/ui/animated-view.tsx from the next step.

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

  1. Copy the source into components/patterns/split-view.tsx:
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Add the Swift package to your app:

Package.swift
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
  1. Copy the theme files every view uses:
swift/Theme/Theme.swift
import SwiftUI
import UIKit

struct RGBA: Sendable {
  let red: CGFloat
  let green: CGFloat
  let blue: CGFloat
  let alpha: CGFloat

  init(_ hex: UInt32, _ alpha: CGFloat = 1) {
    red = CGFloat((hex >> 16) & 0xFF) / 255
    green = CGFloat((hex >> 8) & 0xFF) / 255
    blue = CGFloat(hex & 0xFF) / 255
    self.alpha = alpha
  }
swift/Theme/Tokens.swift
import SwiftUI

public enum EsuissColor {
  public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF))
  public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  1. Copy the source and every file it uses:
swift/Components/Button.swift
import SwiftUI

public struct VariantButtonStyle: ButtonStyle {
  public enum Variant: CaseIterable, Sendable {
    case `default`, outline, secondary, tinted, ghost, plain, destructive, link
  }

  public enum Size: CaseIterable, Sendable {
    case `default`, xs, sm, lg, xl, icon, iconXs, iconSm, iconLg, iconXl

    var height: CGFloat {
      switch self {
      case .xs, .iconXs: 24
      case .sm, .iconSm: 32
      case .default, .icon: 36
      case .lg, .iconLg: 44
swift/Components/ButtonGroup.swift
import SwiftUI

extension EnvironmentValues {
  @Entry var buttonGroup: Axis? = nil
  @Entry var buttonGroupSize: CGSize = .zero
}

enum ButtonGroupSpace: Hashable {
  case group
}

struct ButtonGroupFrame {
  let bounds: Anchor<CGRect>
  let outlined: Bool
}
swift/Components/Field.swift
import SwiftUI

extension EnvironmentValues {
  @Entry public var isInvalid = false
  @Entry var fieldInset = false
  @Entry var fieldGroupInset = false
  @Entry var fieldSetInset = false
}

extension View {
  public func invalid(_ isInvalid: Bool = true) -> some View {
    environment(\.isInvalid, isInvalid)
  }
}

public enum FieldOrientation: CaseIterable, Sendable {
swift/Components/Separator.swift
import SwiftUI

public struct Separator: View {
  let orientation: Axis

  public init(_ orientation: Axis = .horizontal) {
    self.orientation = orientation
  }

  public var body: some View {
    Rectangle()
      .fill(EsuissColor.border)
      .frame(width: orientation == .vertical ? 1 : nil, height: orientation == .horizontal ? 1 : nil)
      .frame(maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil)
      .accessibilityHidden(true)
  }
swift/Components/TextVariant.swift
import SwiftUI

public enum TextVariant: CaseIterable, Sendable {
  case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted

  var style: TextStyle {
    switch self {
    case .h1: .xl3
    case .h2: .xl2
    case .h3: .xl
    case .h4, .lead, .large: .lg
    case .code, .small, .muted: .sm
    default: .base
    }
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
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")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
kotlin/theme/Tokens.kt
package com.esuiss.ui.theme

import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Immutable
data class Colors(
    val background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
kotlin/components/Content.kt
package com.esuiss.ui.components

import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
kotlin/components/Icon.kt
package com.esuiss.ui.components

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun Icon(
    imageVector: ImageVector,
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { SplitView, SplitViewSidebar, SplitViewList, SplitViewDetail, SplitViewItem } from "@/components/patterns/split-view"
<MailExample />

Give each pane a title; the back button only appears when the previous pane exists and has a title. Put SplitViewItems 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.

iOS
Split View (detail column) on iOS in light modeSplit View (detail column) on iOS in dark mode
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" />
  )
}
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 }
  }
}
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.

iOS
Split View (start on sidebar) on iOS in light modeSplit View (start on sidebar) on iOS in dark mode
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" />
  )
}
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 }
  }
}
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.

iOS
Split View (two columns) on iOS in light modeSplit View (two columns) on iOS in dark mode
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} />
  )
}
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 }
  }
}
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

01

SplitView

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

PropTypeDefaultDescription
columnSplitViewColumn-The visible column on phones when controlled: sidebar, list or detail.
defaultColumnSplitViewColumn"list"The initial column when uncontrolled. Defaults to list.
onColumnChange(column: SplitViewColumn) => void-Called with the new column.
breakpointnumber768Window width at which the panes sit side by side. Defaults to 768.

Also accepts the props of ViewProps.

02

SplitViewSidebar

The first pane, for top-level sections.

PropTypeDefaultDescription
titleReact.ReactNode-The title shown at the top of the column.

Also accepts the props of SplitViewPaneProps.

03

SplitViewList

The middle pane, for the items in a section.

PropTypeDefaultDescription
titleReact.ReactNode-The title shown at the top of the column.

Also accepts the props of SplitViewPaneProps.

04

SplitViewDetail

The last pane, for the selected item.

PropTypeDefaultDescription
titleReact.ReactNode-The title shown at the top of the column.

Also accepts the props of SplitViewPaneProps.

05

SplitViewItem

A pressable row that moves to the next column.

PropTypeDefaultDescription
isActivebooleanfalseHighlights the row and marks it as selected.
childrenReact.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.
iOS
Split View on iOS in light modeSplit View on iOS in dark mode
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 />
  )
}
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 }
  }
}
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))
        }
    }
}
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 />
}
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"
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" />
}
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} />
}
uniwind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
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
swift/Components/DropdownMenu.swift
import PhosphorSwift
import SwiftUI

enum MenuPresentation: Sendable {
  case system, popup
}

extension EnvironmentValues {
  @Entry var menuPresentation: MenuPresentation = .system
  @Entry var menuRadio = MenuRadioContext()
}

struct MenuRadioContext: @unchecked Sendable {
  var selected: AnyHashable?
  var select: @MainActor @Sendable (AnyHashable) -> Void = { _ in }
}
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"
swift/Components/Overlay.swift
import PhosphorSwift
import Observation
import SwiftUI

public enum PopupAlignment: Sendable, CaseIterable {
  case start, center, end
}

struct OverlayDismiss: Sendable {
  let action: @MainActor @Sendable () -> Void

  @MainActor func callAsFunction() {
    action()
  }
}
swift/Components/Select.swift
import PhosphorSwift
import SwiftUI

struct SelectContext: @unchecked Sendable {
  var selected: AnyHashable?
  var registering = false
  var select: @MainActor @Sendable (AnyHashable) -> Void = { _ in }
}

extension EnvironmentValues {
  @Entry var select = SelectContext()
}

struct SelectLabelsKey: PreferenceKey {
  static var defaultValue: [AnyHashable: Text] { [:] }
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
nativewind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
stylesheet/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
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"
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"