A detail beside the content on a wide screen and a sheet on a phone, opened and closed by your URL.

Basic

SidePanelLayout holds the page's content and one SidePanel. From 1024px of viewport the open panel stands beside the content in a 24rem column and pushes it narrower; it never covers it, and nothing behind it is dimmed. Under 1024px the same panel is a Sheet that rises over the page, since a phone has no room beside.

isOpen and onClose are yours: read the open row from the URL and remove it there to close. Press a row to open its detail; Escape or the close button closes it.

While an example's panel is open, this site folds its menu to its icons under 1640px, as AppShell folds an app's rail under 1440px: the examples sit in a narrower column than an app's content, and the rows beside the panel need the room. From 1640px this site's column is at its full width beside the menu, so the menu stays. From 1440px it also puts its outline of the page under the content while the panel is open, so the rows beside it have the column's full width.

Example

PayeeCategoryAmount
Corner MartGroceries−$42.18
City TransitTransport−$2.75
Northside RentRent−$1,450.00
Acme PayrollSalary$3,125.00
Green GrocerGroceries−$28.90
Corner MartGroceries−$42.18
City TransitTransport−$2.75
Northside RentRent−$1,450.00
Acme PayrollSalary$3,125.00
Green GrocerGroceries−$28.90
Follows the page theme and corners
'use client';

import {
  DataTable,
  NumberField,
  PickerOption,
  RouterProvider,
  Select,
  SidePanel,
  SidePanelLayout,
  TextField,
  type DataTableColumn,
} from '@snapshot-ui/react';
import { useState, useSyncExternalStore, type ReactElement } from 'react';
import { amountLabel, dayLabel, TRANSACTIONS, type Transaction } from './transactions-data';

const COLUMNS: readonly DataTableColumn<Transaction>[] = [
  { id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
  { id: 'category', header: 'Category', cell: (row) => row.category, listSlot: 'meta' },
  {
    id: 'amount',
    header: 'Amount',
    cell: (row) => amountLabel(row.minor),
    align: 'end',
    width: '30%',
    listSlot: 'end',
  },
];

const CATEGORIES = ['Groceries', 'Transport', 'Rent', 'Salary', 'Utilities', 'Dining'];

const ROWS = TRANSACTIONS.slice(0, 5);

/**
 * Whether the panel stood beside the page when the example first ran in the browser, read once and kept: a later
 * resize moves an open panel between its faces and never opens or closes one. The server and hydration take wide,
 * so the first row's aside is in the server's HTML.
 */
let wideAtFirst: boolean | undefined;
const isWideAtFirst = (): boolean => (wideAtFirst ??= window.matchMedia('(width >= 1024px)').matches);
const isWideOnServer = (): boolean => true;
const subscribeNothing = (): (() => void) => () => undefined;

export default function SidePanelBasic(): ReactElement {
  // An app reads the open row from its URL, such as ?open=t1, and writes it from the row's link and onClose.
  // The example keeps it in state. Until the reader picks a row it shows the first beside the page; on a phone,
  // where the panel is a sheet that covers the page, it waits for a press.
  const [chosen, setOpen] = useState<string | null | undefined>(undefined);
  const isWide = useSyncExternalStore(subscribeNothing, isWideAtFirst, isWideOnServer);
  const open = chosen === undefined ? (isWide ? 't1' : null) : chosen;
  const row = ROWS.find((each) => each.id === open);
  return (
    <div style={{ width: '100%' }}>
      {/* An app wraps its whole tree once; the example wraps only itself. */}
      <RouterProvider
        navigate={(href) => {
          setOpen(href.slice(1));
        }}
      >
        <SidePanelLayout
          panel={
            // Keyed by the open row, so another row is a new opening and focus moves to its heading.
            <SidePanel
              key={open ?? 'none'}
              title={row?.payee ?? ''}
              description={row === undefined ? undefined : dayLabel(row.date)}
              isOpen={row !== undefined}
              onClose={() => {
                setOpen(null);
              }}
            >
              {row === undefined ? null : (
                <div style={{ display: 'grid', gap: 'var(--spacing-peer)' }}>
                  <TextField label="Payee" defaultValue={row.payee} />
                  <Select label="Category" defaultValue={row.category}>
                    {CATEGORIES.map((name) => (
                      <PickerOption key={name} id={name}>
                        {name}
                      </PickerOption>
                    ))}
                  </Select>
                  <NumberField
                    label="Amount"
                    money={{ symbol: '$' }}
                    allowsNegative
                    defaultValue={row.minor}
                  />
                </div>
              )}
            </SidePanel>
          }
        >
          <DataTable
            aria-label="Transactions"
            items={ROWS}
            columns={COLUMNS}
            getKey={(each) => each.id}
            getRowHref={(each) => `#${each.id}`}
            currentKey={open}
          />
        </SidePanelLayout>
      </RouterProvider>
    </div>
  );
}

With actions

footer pins actions under the body, such as Save and Delete, under a divider. onCloseRequest is asked before every close the panel starts, from its close button, Escape, or on a phone a swipe or a press outside the sheet: while the reader has unsaved edits, return false to keep the panel open and ask whether to discard them, as this example does with useConfirm. Pass isDismissable={false} at the same time, so a swipe or a press outside the sheet on a phone does nothing rather than ask; Escape and the close button still ask, as Gmail and Linear ask before a draft is thrown away.

Keep Save enabled: a disabled button leaves the tab order and drops the focus it holds. With nothing changed, Save has nothing to keep.

Example

PayeeAmount
Corner Mart−$42.18
City Transit−$2.75
Northside Rent−$1,450.00
Acme Payroll$3,125.00
Corner Mart−$42.18
City Transit−$2.75
Northside Rent−$1,450.00
Acme Payroll$3,125.00
Follows the page theme and corners
'use client';

import {
  Button,
  DataTable,
  RouterProvider,
  SidePanel,
  SidePanelLayout,
  TextArea,
  useConfirm,
  type DataTableColumn,
} from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';
import { amountLabel, dayLabel, TRANSACTIONS, type Transaction } from './transactions-data';

const COLUMNS: readonly DataTableColumn<Transaction>[] = [
  { id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
  {
    id: 'amount',
    header: 'Amount',
    cell: (row) => amountLabel(row.minor),
    align: 'end',
    width: '30%',
    listSlot: 'end',
  },
];

const ROWS = TRANSACTIONS.slice(0, 4);

export default function SidePanelFooter(): ReactElement {
  const [open, setOpen] = useState<string | null>(null);
  const [notes, setNotes] = useState<Record<string, string>>({});
  // The memo as typed, until Save keeps it: the panel holds unsaved edits while it differs.
  const [draft, setDraft] = useState('');
  const { confirm, dialog } = useConfirm();
  const row = ROWS.find((each) => each.id === open);
  const saved = open === null ? '' : (notes[open] ?? '');
  const isDirty = draft !== saved;

  const openRow = (id: string | null): void => {
    setOpen(id);
    setDraft(id === null ? '' : (notes[id] ?? ''));
  };

  return (
    <div style={{ width: '100%' }}>
      <RouterProvider
        navigate={(href) => {
          openRow(href.slice(1));
        }}
      >
        <SidePanelLayout
          panel={
            <SidePanel
              key={open ?? 'none'}
              title={row?.payee ?? ''}
              description={row === undefined ? undefined : dayLabel(row.date)}
              isOpen={row !== undefined}
              onClose={() => {
                openRow(null);
              }}
              // With edits unsaved, a swipe and a press outside the sheet do nothing, and Escape and the close
              // button ask first.
              isDismissable={!isDirty}
              onCloseRequest={() => {
                if (!isDirty) return true;
                void confirm({
                  title: 'Discard your changes?',
                  description: 'The memo you typed is not saved.',
                  confirmLabel: 'Discard',
                  tone: 'danger',
                }).then((isSure) => {
                  if (isSure) openRow(null);
                });
                return false;
              }}
              footer={
                <>
                  <Button
                    variant="secondary"
                    onPress={() => {
                      openRow(null);
                    }}
                  >
                    Cancel
                  </Button>
                  {/* Never disabled: a disabled button leaves the tab order and drops the focus it holds, so
                      after a save focus stays here, and with nothing changed Save has nothing to keep. */}
                  <Button
                    onPress={() => {
                      if (open !== null && isDirty) setNotes({ ...notes, [open]: draft });
                    }}
                  >
                    Save
                  </Button>
                </>
              }
            >
              <TextArea label="Memo" value={draft} onChange={setDraft} isChanged={isDirty} />
            </SidePanel>
          }
        >
          <DataTable
            aria-label="Transactions"
            items={ROWS}
            columns={COLUMNS}
            getKey={(each) => each.id}
            getRowHref={(each) => `#${each.id}`}
            currentKey={open}
          />
        </SidePanelLayout>
      </RouterProvider>
      {dialog}
    </div>
  );
}

Usage

  • Drive isOpen from a search parameter, such as ?open=t1, and close by removing it, so Back closes the panel too. Back and the app's own links close it without onCloseRequest, so guard them in the app if they can lose edits.
  • Pass key with the open row's id, so another row is a new opening and focus moves to its heading.
  • The panel sticks an inset, 16px, under --page-sticky-top, as tall as the screen allows, and its body scrolls inside it. App shell's header slot writes that offset from your header's measured height; set it yourself, on an ancestor, only for a header outside the shell.
  • Start the layout right under the page's header, with the page's title, filters and table inside its content. The panel is as tall as the screen less the header, so the footer's actions are in view from the start; a layout that starts lower holds the footer below the screen's edge until the page scrolls and the panel sticks.
  • Under 1440px the open panel folds an AppShell's rail to its icons and gives it back as it closes, so a table beside the panel keeps its columns. The reader's own setting is not changed, and a rail they folded stays folded. The stylesheet folds it from the server's HTML, so a page loaded with the panel open draws the rail folded from the start. Pass collapsesRail={false} to the layout to leave the rail alone.
  • The panel's body and the sheet's body scroll, so each sets --page-sticky-top to 0px for what it holds: a table there sticks its header at their top. Do the same on any element of your own that scrolls around a table.
  • The aside is in the server's HTML when open. The sheet mounts only after hydration, so on a phone a link to a row shows the list until scripts run. Give a phone link its own detail route if it must work without scripts.

SidePanel and SidePanelLayout are client components. A server page can render them with a client component's state, as the examples do.

Props

ref goes on the aside, or on the sheet's dialog on a phone, and className on the aside, or the sheet, merged after the library's class.

children
ReactNode

className
string

Goes on the aside, or on the sheet on a phone, merged after the library's class.

closeLabel
string

Default: 'Close'

description
ReactNode

A line under the title, in slate.

footer
ReactNode

Actions pinned under the body, such as Save and Delete.

isDismissable
boolean

On a phone, a swipe or a press outside the sheet closes it. false leaves them doing nothing; pass it while edits are unsaved, so a gesture cannot throw them away. The close button and Escape always ask onCloseRequest, on either face.

Default: true

isOpen
boolean

Controlled from the URL.

isTitleHidden
boolean

Keeps the title for assistive technology only, for a body that opens with its own heading.

Default: false

onClose
() => void

The close button, Escape, and the sheet's swipe and outside press. The app writes the URL.

onCloseRequest
(() => boolean)

Asked first on every close the panel starts: its close button, Escape, and on a phone a swipe or a press outside the sheet. Return false to keep the panel open, such as while the app holds unsaved edits and asks whether to discard them; onClose is then not called.

ref
Ref<HTMLElement>

The aside, or the sheet's dialog on a phone.

title
string

The panel's heading, and the landmark's and the sheet's name.

ref and className go on the layout's root.

children
ReactNode

The page's content, such as the filter bar, the table and the pager.

className
string

collapsesRail
boolean

Under 1440px of viewport, an open panel folds an AppShell's rail to its collapsed width and gives it back as it closes, unless the reader had folded it already, so a table beside the panel keeps its table face. false leaves the rail as the reader set it.

Default: true

panel
ReactNode

One SidePanel.

ref
Ref<HTMLDivElement>

Accessibility

On a wide screen the panel is a complementary landmark named by its title. On a phone it is a dialog titled by it, as a sheet is.

  • Focus moves to the panel's heading when it opens, and back to the row that opened it when it closes. If that row has left the page, focus goes to the content.
  • With isTitleHidden, the title still names the panel, and focus lands on the panel itself, so its ring shows.
  • On a wide screen the page stays usable around the panel: Tab leaves it, nothing is inert and nothing is dimmed. As the window crosses 1024px, an open panel moves between the aside and the sheet and keeps focus in it. The sheet covers the page, so it holds focus while it does; if the reader was elsewhere on the page as it came, focus goes back there as the aside returns.
  • Escape in a table beside the panel is the table's: it clears a selection and never closes the panel.
  • When the panel closes from a dialog or a menu it opened, such as a confirm dialog's Discard, focus goes back to the row once that dialog has gone.
EnterOn a table row, opens its panel; focus moves to the panel heading.
TabShift+TabThrough the panel and out of it into the page on a wide screen; within the sheet on a phone.
EscapeIn the panel, closes it, asking onCloseRequest first; focus returns to the row that opened it.

Design rules

What keeps side panels in the system.

Show the detail of one row, beside the rows it came from.

Keep the open row in the URL, its only state.

Put Save and Delete in the footer.

Never put a scrim behind the panel on a wide screen.

Never open two panels on one page.

Never open a panel the reader did not ask for.

esc

↑ ↓ to move↵ to open