Panes

Two panes side by side, such as a list and its detail.

BetaSince 1.0.0
On this page

Import

import { Panes, readPanesWidth } from '@envsemble/envui';

Usage

Panes.Root takes a behavior: supporting holds Panes.Main and Panes.Supporting, such as a document and its details, and list-detail holds Panes.List and Panes.Detail, such as messages and the one chosen, each pair with an optional Panes.Divider between them.

  • Below splitAt, the supporting pane is a modal sheet over the main one while open, and a list-detail shows the detail instead of the list while open.
  • Left uncontrolled, with defaultOpen, Panes.Trigger opens the sheet and people close it; a list-detail application keeps open itself, setting it as an item is chosen.
  • onOpenChange receives the new state and why: true with trigger-press, and false as people close it, with escape-key, close-press, outside-press for the backdrop, or back-press for the back button that returns to the list.
SupportingPane.tsx
import { useState } from 'react';
import { Column, DescriptionList, Panes, Text } from '@envsemble/envui';
import { IconInfo } from '@envsemble/envui/icons/Info';

export default function SupportingPane() {
  const [open, setOpen] = useState(false);
  return (
    // Side by side from an expanded width of their own; narrower, the details are a sheet over
    // the document, which the button opens and Escape closes.
    <Panes.Root
      behavior="supporting"
      open={open}
      onOpenChange={setOpen}
      style={{ blockSize: '18rem' }}
    >
      <Panes.Main label="Document">
        <Column gap={3} padding={4} align="start">
          <Text variant="title" render={<h3 />}>
            Quarterly report
          </Text>
          <Text>Revenue grew in every region, led by the new subscriptions.</Text>
          <Panes.Trigger variant="secondary" startIcon={<IconInfo />}>
            Details
          </Panes.Trigger>
        </Column>
      </Panes.Main>
      <Panes.Supporting label="Details">
        <Column gap={3} padding={4}>
          <DescriptionList.Root aria-label="About the report">
            <DescriptionList.Item label="Owner">Sam Lee</DescriptionList.Item>
            <DescriptionList.Item label="Due">31 March 2026</DescriptionList.Item>
          </DescriptionList.Root>
        </Column>
      </Panes.Supporting>
    </Panes.Root>
  );
}

Names and buttons

Panes.Supporting and Panes.List take a label, which names their region; on Panes.Main and Panes.Detail it is optional, and makes the pane a region landmark, for the panes of a page's main layout.

  • Panes.Trigger is a Button, primary by default, that opens the supporting sheet and hides while the panes sit side by side.
  • closeLabel names the sheet's close button, and backLabel the back button: name where it goes, such as Inbox.
  • Set aria-current on the list's current row.
<Panes.Root behavior="list-detail" open={open} onOpenChange={setOpen} backLabel="Inbox">
  <Panes.List label="Inbox">
    <Surface padding={2} style={{ minBlockSize: '100%' }}>
      <List.Root aria-label="Messages">…</List.Root>
    </Surface>
  </Panes.List>
  <Panes.Divider label="Resize the inbox" />
  <Panes.Detail label={chosen.subject}>
    <Surface padding={4} style={{ minBlockSize: '100%' }}>
      …
    </Surface>
  </Panes.Detail>
</Panes.Root>

Side by side

splitAt is the window class of the Panes' own width from which they sit side by side: medium, 37.5 rem, expanded, 52.5 rem and the default, or large, 75 rem. CSS decides it, so the server's markup is already right.

Resizable

Panes.Divider, named by its label, Resize panes by default, resizes the supporting pane or the list, by pointer and the arrow keys; a double click returns it to defaultWidth, 22.5 rem by default.

  • It stays between minWidth and maxWidth, 16 and 40 rem by default, and never leaves the other pane less than 16 rem.
  • The arrow keys move it 1 rem, 5 rem with Shift, and Page Up and Page Down 5 rem.
  • width and onWidthChange, which receives each width in rem and committed once a drag ends or a key moves it, as Sheet's do, let the application control it.
  • The divider is the gap between two surfaces: give each pane a Surface as tall as the pane, with min-block-size: 100%, and where the detail shows alone, its surface fills the room below the back button.
  • Panes keeps the same gap without a divider.

persist keeps the width people choose in a cookie of that name. Read it on the server with readPanesWidth(cookieHeader, key) and pass it as persistedWidth, so nothing moves as the page hydrates.

<Panes.Root
  behavior="supporting"
  defaultWidth={18}
  minWidth={14}
  maxWidth={28}
  persist="details-width"
>
  <Panes.Main label="Canvas">…</Panes.Main>
  <Panes.Divider label="Resize details" />
  <Panes.Supporting label="Canvas details">…</Panes.Supporting>
</Panes.Root>

Composition

The parts are the root, env-panes, with data-behavior, data-split-at and data-open; the main pane, env-panes-main, and the supporting one, env-panes-supporting, with its close button, env-panes-close, and its backdrop, env-panes-backdrop, while it is a sheet, and the trigger that opens it, env-panes-trigger; the list, env-panes-list, and the detail, env-panes-detail, with its back button, env-panes-back; and the divider, env-panes-divider. Panes paints no background of its own: the surfaces in its panes do. name names the node for dev mode, as data-env-name.

Accessibility

You provide

  • The labels and the backLabel, in the application's language.
  • open, and aria-current on the list's current row.
  • A Panes.Trigger, or a button of your own, that opens the supporting pane in less room.

envUI provides

  • Named regions for the panes with a label.
  • The supporting sheet as a modal dialog: focus moves in and stays, the rest of the page is inert, and focus returns as it closes.
  • Focus on the back button as the detail opens alone, and on the list's current row as it closes.
  • The divider as a focusable separator with its value in rem.
  • Panes.Trigger, which opens the supporting sheet, says whether it is open with aria-expanded, and hides while the panes sit side by side.

Props

Panes.Root

Two panes side by side, after Material's pane patterns, and how they behave in less room: a main pane with a supporting pane that becomes a sheet over it, or a list and its detail, one at a time. Invisible structure that never paints, except the supporting pane while it is a sheet, which is modal. Its own width decides, in CSS: it is the env-panes container. Each pane scrolls on its own when Panes has a height, as in a Scaffold's body that scrolls; an optional Panes.Divider resizes them.

PropTypeDescription
behaviorrequired
'supporting''list-detail'
supporting: Panes.Main and Panes.Supporting, such as a document and its details; below `splitAt` the supporting pane is a sheet over the main one while `open`. list-detail: Panes.List and Panes.Detail, such as messages and the one chosen; below `splitAt` one shows at a time, the detail while `open`, with a back button to the list.
splitAtDefault 'expanded'
'medium''expanded''large'
The window class of the Panes' own width from which the panes sit side by side: medium (37.5 rem), expanded (52.5 rem) or large (75 rem). Narrower, they behave as `behavior` says. CSS decides it, so the server's markup is right before any script runs.
open
boolean
Below `splitAt`: whether the supporting sheet is open, or the detail shows instead of the list, for Panes the application controls with onOpenChange, such as a list-detail that opens the detail as an item is chosen. Side by side, both panes show whatever it is.
defaultOpenDefault false
boolean
Whether uncontrolled Panes start with the supporting sheet open, or the detail shown, below `splitAt`. Panes.Trigger opens the sheet, and Escape, the close button, the backdrop and the back button close what shows.
onOpenChange
(open: boolean, details: OpenChangeDetails) => void
Called with the new state, and why: true when Panes.Trigger opens the supporting sheet, and false when people close what `open` shows below `splitAt`: Escape, the close button or a press on the backdrop closes the supporting sheet, and the back button returns from the detail to the list.
closeLabelDefault 'Close', or EnvUIProvider's messages.panes.close
string
The name of the supporting sheet's close button, which shows only while the pane is a sheet.
backLabelDefault 'Back', or EnvUIProvider's messages.panes.back
string
The back button's label in a list-detail's detail, below `splitAt`. Name where it goes, such as All projects.
width
number
The resized pane's width in rem, for Panes the application controls with onWidthChange: the supporting pane, or the list. It applies while the panes sit side by side.
defaultWidthDefault 22.5
number
The resized pane's width in rem until people choose one with Panes.Divider, or one is kept under `persist`, and the width a double click on the divider returns it to.
onWidthChange
(width: number, details: ResizeDetails) => void
Receives each new width of the resized pane, in rem: while dragging the divider with committed false, and with committed true as a drag ends, a key moves it or a double click resets it.
minWidthDefault 16
number
The resized pane's smallest width, in rem.
maxWidthDefault 40
number
The resized pane's largest width, in rem. It never leaves the other pane less than 16 rem.
persist
string
Keeps the width people choose in a cookie of this name, for Panes the application doesn't control; a double click on the divider forgets it and returns to `defaultWidth`. Read it on the server with `readPanesWidth(cookieHeader, key)` and pass it as `persistedWidth`, so the server's markup has it and nothing moves as the page hydrates. Without that, the kept width applies after the first render.
persistedWidth
number
The width kept under `persist`, in rem, as the server read it from the request's cookies with `readPanesWidth`: the first render uses it, on the server and in the browser alike.
name
string
A name for this node, such as "inbox", which dev mode and the docs show; it renders as `data-env-name`.
childrenrequired
ReactNode
The panes: Panes.Main, an optional Panes.Divider and Panes.Supporting; or Panes.List, an optional Panes.Divider and Panes.Detail.

Also accepts the native props of <div>, such as ref, className and aria-* attributes.

Panes.Main

The main pane of supporting Panes, at the start.

PropTypeDescription
label
string
Names the pane, which makes it a region landmark that people can jump to, such as Documents. Name the panes of a page's main layout; leave small panes unnamed.

Also accepts the native props of <div>, such as ref, className and aria-* attributes.

Panes.Supporting

The supporting pane, at the end, such as details or filters for the main pane. Below `splitAt` it is a modal sheet over the main pane while `open`: a side sheet in a medium width, a bottom sheet in a compact one. Inside a Scaffold whose document scrolls, the sheet is the window's, as Sheet is: fixed to the window over a backdrop, scrolling inside itself while the page stays. As a sheet it is a modal dialog with a close button: everything else on the page is inert, focus moves into it and Tab stays in it, and Escape, the close button or a press on the backdrop closes it, which returns focus to where it was. In a contained PortalContainer, such as a preview on a page, the sheet belongs to the container: it is not modal, so the page stays in use and focus stays where it is.

PropTypeDescription
labelrequired
string
Names the pane's region, such as Details.
skeletonDefault false
boolean
Shows it and its content as a busy skeleton while that loads; turning it on or off renders the content anew.

Also accepts the native props of <div>, such as ref, className and aria-* attributes.

Panes.List

The list pane of list-detail Panes, at the start. Below `splitAt` it hides while `open`.

PropTypeDescription
labelrequired
string
Names the pane's region, such as Projects.

Also accepts the native props of <div>, such as ref, className and aria-* attributes.

Panes.Detail

The detail pane of list-detail Panes, at the end. Below `splitAt` it shows only while `open`, with a back button to the list at its start, and focus follows what shows: the back button as the detail opens, the list's current row as it closes.

PropTypeDescription
label
string
Names the pane, which makes it a region landmark that people can jump to, such as Documents. Name the panes of a page's main layout; leave small panes unnamed.

Also accepts the native props of <div>, such as ref, className and aria-* attributes.

Panes.Divider

The divider between the panes, which resizes the supporting pane or the list: the gap between them, with a small grip and no line. It is a focusable separator that the pointer drags and the arrow keys move, 1 rem a step, 5 with Shift, which names the pane it resizes in aria-controls. A double click returns it to the default width. It shows only while the panes sit side by side. Place it between two surfaces, each pane's own, such as a Surface as tall as the pane, never beside content on the page.

PropTypeDescription
labelDefault 'Resize panes', or EnvUIProvider's messages.panes.resize
string
The divider's name, which says what it resizes, such as Resize project list.

Also accepts the native props of <div>, such as ref, className and aria-* attributes.

Panes.Trigger

The button that opens the supporting pane while it is a sheet, below `splitAt`; place it in the main pane. It hides while the panes sit side by side, where the supporting pane shows anyway, and says whether the sheet is open with aria-expanded. It takes Button's props.

PropTypeDescription
variantDefault 'primary'
'primary''secondary''outline''ghost''text'
The emphasis, primary for a group's main action; without it, the container's, such as ghost in a toolbar, and else primary.
toneDefault 'neutral'
'neutral''danger''accent'
The meaning: neutral for most actions, danger for one that deletes or ends something, and accent, on a primary only, for a main action that must stand apart from the brand blue.
underlineDefault false
boolean
Show an underline for the text variant.
size
'sm''md''lg'
The control height, sm, md or lg; without it, the container's, and else md.
fullWidthDefault false
boolean
Fill the width of the container, such as the actions of a narrow form stacked in a column.
startIcon
ReactNode
A decorative icon before the label, such as IconAdd.
endIcon
ReactNode
A decorative icon after the label, such as a ChevronRight for a next step.
end
ReactNode
Quiet content at the far end, such as a shortcut's Kbd, with aria-hidden on its keys and aria-keyshortcuts on the button.
pendingDefault false
boolean
Its work is running: a spinner turns over it, it keeps its width and name, and presses do nothing.

Also accepts the native props of <button>, such as ref, className and aria-* attributes.

readPanesWidth

The width Panes keeps under its `persist` key, read from a Cookie header, such as the request's on the server, or from `document.cookie`. Render Panes.Root with it as `persistedWidth`, on the server and in the browser alike, so the server's markup has the width people chose and nothing moves as the page hydrates: <Panes.Root persist="inbox" persistedWidth={readPanesWidth(cookieHeader, 'inbox')}> Returns undefined where no width is kept under the key, or what is kept there is no width.

readPanesWidth(cookies: string | null | undefined, key: string): number | undefined

Part names

Each part's class and state attributes are public and versioned, for an application's CSS to style.

env-panes
The panes: two panes side by side, or how they behave in less room; it never paints.
env-panes-back
The button back to the list, when one pane shows at a time.
env-panes-backdrop
The backdrop behind the supporting sheet.
env-panes-close
The supporting sheet's close button, which shows only while the pane is a sheet.
env-panes-detail
The detail pane of list-detail panes.
env-panes-divider
The gap between the panes, with its grip, that resizes them.
env-panes-list
The list pane of list-detail panes.
env-panes-main
The main pane of supporting panes.
env-panes-supporting
The supporting pane, a sheet over the main pane in less room.
env-panes-trigger
The button in the main pane that opens the supporting sheet, which hides while the panes sit side by side.
data-behavior
list-detail · supporting
data-env-layout
panes
data-env-name
Any value, or present
data-open
Present
data-split-at
expanded · large · medium

Strings

EnvUIProvider's messages overrides each, and a component's own label prop wins over it.

messages.panes.resize
"Resize panes" The divider's name, which resizes the panes.
messages.panes.size
({ size }) => string The divider's value: the resized pane's width in rem, such as 22.5 rem, in the locale's digits.
messages.panes.back
"Back" The back button of a list-detail's detail while one pane shows at a time.
messages.panes.close
"Close" The close button of the supporting pane while it is a sheet.

Changelog

1.0.0

Panes, which replaces SplitPane and ListDetail: the supporting and list-detail behaviors, splitAt at a window class, a divider in rem, and Panes.Trigger, which opens the supporting sheet.

1.0.0-prerelease.5

The resized pane's size, defaultSize, onSizeChange, minSize, maxSize and persistedSize are width, defaultWidth, onWidthChange, minWidth, maxWidth and persistedWidth, as on Sheet, and readPanesSize is readPanesWidth. defaultOpen leaves the open state to Panes, and onOpenChange receives why. The part props are Panes.MainProps, Panes.DetailProps and Panes.ListProps.

The supporting sheet is on a Sheet's fill, --env-color-bg-default, rather than --env-color-bg-raised, which differ only at high contrast in the light theme.