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
Example
| Payee | Category | Amount |
|---|---|---|
| Corner Mart | Groceries | −$42.18 |
| City Transit | Transport | −$2.75 |
| Northside Rent | Rent | −$1,450.00 |
| Acme Payroll | Salary | $3,125.00 |
| Green Grocer | Groceries | −$28.90 |
'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
Example
| Payee | Amount |
|---|---|
| Corner Mart | −$42.18 |
| City Transit | −$2.75 |
| Northside Rent | −$1,450.00 |
| Acme Payroll | $3,125.00 |
'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
isOpenfrom 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 withoutonCloseRequest, so guard them in the app if they can lose edits. - Pass
keywith 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'sheaderslot 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-topto0pxfor 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. 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 |
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. 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.
| Enter | On a table row, opens its panel; focus moves to the panel heading. |
|---|---|
| TabShift+Tab | Through the panel and out of it into the page on a wide screen; within the sheet on a phone. |
| Escape | In 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.