A settings page: a list of sections beside the open one, a list and a page on a phone, one address per section.
One address per section
Each section is a page with its own address, /settings/coach, and the index is /settings. Read the open section from the URL and pass view (index or section) and sectionKey; the layout holds no state of its own. Mark the open link isCurrent, which sets aria-current="page". At the index, pass the section a wide screen shows beside the list as the children and mark its link isDefault: it is drawn as current beside the list and never aria-current, since on a phone the index is the list and no section is open.
From 48rem of the layout's own width it draws the list beside the open section; under it, the list alone at the index, and Back and the section at a section. Both faces are in the server's HTML and a container query picks one, so a reload or the browser's Back lands right with no script. After a section change, the section's heading takes focus; back at the index on a phone, the link of the section you left does. A change you start outside the layout, from the rail or a command palette, keeps its own focus.
Every link goes through RouterProvider. While a section has unsaved changes, ask in your navigate with useConfirm before you go.
Example
Example
Coach
How the coach waits before a charge.
'use client';
import {
PanelLayout,
PanelNav,
PanelNavItem,
PanelSection,
RouterProvider,
Switch,
} from '@snapshot-ui/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Suspense, type ReactElement } from 'react';
import { SETTINGS_SECTIONS, sectionOf } from './panel-layout-data';
/** The page at `?section=`, or at the index with none: the URL is the state, and the layout holds none. */
function Settings({ sectionKey }: { sectionKey: string | null }): ReactElement {
const pathname = usePathname();
const open = sectionOf(sectionKey);
const view = open === undefined ? 'index' : 'section';
// At the index the split face shows the first section beside the list, marked isDefault.
const shown = open ?? SETTINGS_SECTIONS[0];
return (
<PanelLayout
view={view}
sectionKey={open?.key}
backHref={pathname}
backLabel="Settings"
nav={
<PanelNav aria-label="Settings sections">
{SETTINGS_SECTIONS.map((section) => (
<PanelNavItem
key={section.key}
href={`${pathname}?section=${section.key}`}
isCurrent={section.key === open?.key}
isDefault={view === 'index' && section === SETTINGS_SECTIONS[0]}
description={section.description}
end={section.end}
>
{section.name}
</PanelNavItem>
))}
</PanelNav>
}
>
{shown === undefined ? null : (
<PanelSection title={shown.name} description={shown.description}>
<Switch defaultSelected={shown.end !== 'Off'}>Turn on {shown.name.toLowerCase()}</Switch>
</PanelSection>
)}
</PanelLayout>
);
}
function SettingsFromUrl(): ReactElement {
return <Settings sectionKey={useSearchParams().get('section')} />;
}
export default function PanelLayoutBasic(): ReactElement {
const router = useRouter();
// An app wraps its whole tree once; the example wraps only itself. The layout's width is 0 until a holder gives
// it one: this one fills the stage.
return (
<div style={{ width: '100%' }}>
<RouterProvider
navigate={(href) => {
router.push(href, { scroll: false });
}}
>
{/* The page is static, so the server renders the fallback: the index, Coach beside the list. */}
<Suspense fallback={<Settings sectionKey={null} />}>
<SettingsFromUrl />
</Suspense>
</RouterProvider>
</div>
);
}In the App Router, the settings layout reads the open segment and each page renders one section. A layout persists across its pages, so the nav keeps its scroll.
// app/settings/layout.tsx
import { PageHeader } from '@snapshot-ui/react';
import { SettingsFrame } from './settings-frame';
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<>
<PageHeader title="Settings" />
<SettingsFrame>{children}</SettingsFrame>
</>
);
}
// app/settings/settings-frame.tsx
'use client';
import { PanelLayout, PanelNav, PanelNavItem } from '@snapshot-ui/react';
import { useSelectedLayoutSegment } from 'next/navigation';
import { SECTIONS } from './sections';
export function SettingsFrame({ children }: { children: React.ReactNode }) {
const segment = useSelectedLayoutSegment();
const view = segment === null ? 'index' : 'section';
return (
<PanelLayout
view={view}
sectionKey={segment ?? undefined}
backHref="/settings"
backLabel="Settings"
nav={
<PanelNav aria-label="Settings sections">
{SECTIONS.map((section, index) => (
<PanelNavItem
key={section.key}
href={`/settings/${section.key}`}
isCurrent={section.key === segment}
isDefault={view === 'index' && index === 0}
description={section.description}
>
{section.name}
</PanelNavItem>
))}
</PanelNav>
}
>
{children}
</PanelLayout>
);
}
// app/settings/section.tsx: one section, a server component.
import { PanelSection } from '@snapshot-ui/react';
import { notFound } from 'next/navigation';
import { SECTIONS } from './sections';
export function Section({ sectionKey }: { sectionKey: string }) {
const section = SECTIONS.find((each) => each.key === sectionKey);
if (section === undefined) notFound();
return (
<PanelSection title={section.name} description={section.description}>
{/* the section's cards */}
</PanelSection>
);
}
// app/settings/[section]/page.tsx
import { Section } from '../section';
export default async function SectionPage({ params }: { params: Promise<{ section: string }> }) {
const { section } = await params;
return <Section sectionKey={section} />;
}
// app/settings/page.tsx: the index shows the first section beside the list on a wide screen.
import { Section } from './section';
import { SECTIONS } from './sections';
export default function SettingsIndex() {
return <Section sectionKey={SECTIONS[0].key} />;
}On a phone
Under 48rem the index is a list of rows, each with its description, its end state and a chevron, and a section is a page with Back above its heading. The page's title stays in the shell's header on both views, so a reader sees "Settings", then the way back, then the section. The tab bar stays, with Settings current across the index and every section. This example keeps its address in the page's hash, so a link to #settings-logos opens Logos, and the browser's Back and Forward move between the list and a section with the same focus rules.
Example
Example
Settings
Coach
How the coach waits before a charge.
'use client';
import {
PageHeader,
PanelLayout,
PanelNav,
PanelNavItem,
PanelSection,
RouterProvider,
Switch,
} from '@snapshot-ui/react';
import { useSyncExternalStore, type ReactElement } from 'react';
import { SETTINGS_SECTIONS, sectionOf } from './panel-layout-data';
/** Listens for the page's hash, which a link, Back and the browser's Back and Forward change. */
function subscribe(onChange: () => void): () => void {
window.addEventListener('hashchange', onChange);
return () => {
window.removeEventListener('hashchange', onChange);
};
}
const readHash = (): string => window.location.hash;
/** The server sees no hash, so it draws the index; a hash on load opens its section once the page hydrates. */
const serverHash = (): string => '';
export default function PanelLayoutPhone(): ReactElement {
// The example's address is the page's hash, so it moves apart from the basic example's ?section= and a reload,
// a shared link and the browser's Back land on the same view. An app reads its route the same way.
const hash = useSyncExternalStore(subscribe, readHash, serverHash);
const open = sectionOf(hash.startsWith('#settings-') ? hash.slice('#settings-'.length) : null);
const view = open === undefined ? 'index' : 'section';
const shown = open ?? SETTINGS_SECTIONS[0];
return (
// A phone's width: under 48rem of its own box the layout draws the list at the index, and Back and the section
// at a section. The frame gives it that width; the layout's own is 0.
<div style={{ width: 'min(375px, 100%)', display: 'grid', gap: 'var(--spacing-part)' }}>
{/* The shell's header holds the page's title on both views; here it is a second-level heading, since the
docs page has its own h1. */}
<PageHeader title="Settings" headingLevel={2} />
<RouterProvider
navigate={(href) => {
window.location.hash = href;
}}
>
<PanelLayout
view={view}
sectionKey={open?.key}
backHref="#settings"
backLabel="Settings"
nav={
<PanelNav aria-label="Settings sections on a phone">
{SETTINGS_SECTIONS.map((section) => (
<PanelNavItem
key={section.key}
href={`#settings-${section.key}`}
isCurrent={section.key === open?.key}
isDefault={view === 'index' && section === SETTINGS_SECTIONS[0]}
description={section.description}
end={section.end}
>
{section.name}
</PanelNavItem>
))}
</PanelNav>
}
>
{shown === undefined ? null : (
<PanelSection title={shown.name} description={shown.description} headingLevel={3}>
<Switch defaultSelected={shown.end !== 'Off'}>Turn on {shown.name.toLowerCase()}</Switch>
</PanelSection>
)}
</PanelLayout>
</RouterProvider>
</div>
);
}For iOS's single title, where the section's name is the page's title, render the PageHeader with the section's title and leave Back to your own trail. The default, the page's name above the section's, is recommended: the reader always sees where they are and where Back goes.
Card pairs
A section is one column of cards. Where two cards belong side by side, a PanelRow holds exactly two: two halves, or a third beside two thirds in either order. From 66rem of the row's own width they pair, each at its own height; under it they stack in source order. A full card is a Card outside any row.
Example
Example
Profile
Ada Lovelace, ada@example.com
Sign in
A code to your email, then your passkey.
Theme
Follows the system.
Density
Comfortable rows, with every target at 40px or more. Compact fits more rows on a screen.
import { Card, CardBody, CardHeader, CardTitle, PanelRow, Text } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
/** A server component: PanelRow and Card are static, so the pairs need no client code. */
export default function PanelLayoutPairs(): ReactElement {
// A row's width is 0 until a holder gives it one: this column fills the stage. From 66rem of the row's own
// width the cards pair; under it they stack in source order.
return (
<div style={{ width: '100%', display: 'grid', gap: 'var(--spacing-peer)' }}>
<PanelRow layout="halves">
<Card>
<CardHeader>
<CardTitle level={3}>Profile</CardTitle>
</CardHeader>
<CardBody>
<Text>Ada Lovelace, ada@example.com</Text>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle level={3}>Sign in</CardTitle>
</CardHeader>
<CardBody>
<Text>A code to your email, then your passkey.</Text>
</CardBody>
</Card>
</PanelRow>
<PanelRow layout="third-two-thirds">
<Card>
<CardHeader>
<CardTitle level={3}>Theme</CardTitle>
</CardHeader>
<CardBody>
<Text>Follows the system.</Text>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle level={3}>Density</CardTitle>
</CardHeader>
<CardBody>
<Text>
Comfortable rows, with every target at 40px or more. Compact fits more rows on a screen.
</Text>
</CardBody>
</Card>
</PanelRow>
</div>
);
}The Settings recipe
An app's Settings page, built from the library's parts. The example below is one client component, since this site is static: the open section is ?settings= on this page's URL, read with useSearchParams inside a Suspense and opened through a RouterProvider whose navigate asks before it leaves a section with unsaved changes. The sections come from one array, in your order: nothing counts them, the first is the index's default, and a reorder of the array reorders the page.
- Coach. A card holds the wait tiers, a Repeater of an "Up to" amount and a wait, from none, with its empty line, to three, where Add stays drawn, disabled, with its reason. An amount another tier already waits from is a row error. Under the set, FieldExtras with
scope="group"offers the default tiers, and Reset to default focuses the first amount. A second card holds the cushion, empty by default, with a suggestion to use. Save drops the rows whose fields are all empty and sorts the rest by amount, and Cancel puts the saved tiers back. - Notifications. Push amounts, off by default, and the weekly email, on, whose ConditionalFields hold its status line and Send a test.
- Bank sync. A DescriptionList of the two connections and their states; "2 connected" is the end state in the nav.
- AI assistant. A password field for the key and the model, with the default offered.
- Logos. A switch gates the provider and its key. Hidden, they stay in the page and are never sent: the save reads the form's
FormData, where an absent field means "unchanged". The setup address has a CopyButton in its row's actions, and the nav's end state says Off or On. - Appearance. A ThemeSwitch, applied at once, outside every SaveBar and never Changed. Here it sets this site's theme.
- Data and Danger zone. Export in a card's actions; Delete all data is a danger Button in a plain card, asking with a ConfirmDialog in the danger tone. No card is red: error means a failure or an invalid value.
Each section with fields ends with a SaveBar. While it is dirty, a link to another section asks first with useConfirm, and focus goes back to the link if you keep editing, or to the next section's heading if you discard.
// The page's router: every link of the layout goes through it.
const { confirm, dialog } = useConfirm();
<RouterProvider
navigate={async (href) => {
if (dirtySection !== null) {
const isSure = await confirm({
tone: 'danger',
title: 'Discard changes?',
description: `The changes you made in ${dirtySection.name} will be lost.`,
confirmLabel: 'Discard changes',
cancelLabel: 'Keep editing',
});
if (!isSure) return;
// Once the dialog has given focus back to the link, so the next section's heading takes it.
await focusBack();
}
router.push(href, { scroll: false });
}}
>
{children}
{dialog}
</RouterProvider>;In an app, the settings layout and each section are server components, as One address per section shows; only the leaves that hold state are client components: the layout's nav, each section's form with its SaveBar, the Repeater, the switches and the theme. Read the theme from your cookie in the root layout and write it in a server action, as the theme switch page shows. The rail's footer holds the AccountMenu, with Settings, the theme as a submenu, Help and Sign out.
// app/layout.tsx, a server component: the shell, with the person's menu in the rail's footer.
<AppShell sidebar={<AppNav footer={<PersonMenu name={person.name} detail={person.email} />} />}>
{children}
</AppShell>
// app/person-menu.tsx
'use client';
import { AccountMenu, MenuItem, MenuSeparator, ThemeMenu } from '@snapshot-ui/react';
import { useTheme } from './theme-provider';
export function PersonMenu({ name, detail }: { name: string; detail: string }) {
const [theme, setTheme] = useTheme();
return (
<AccountMenu name={name} detail={detail}>
<MenuItem id="settings" href="/settings">
Settings
</MenuItem>
<ThemeMenu value={theme} onChange={setTheme} />
<MenuItem id="help" href="/help">
Help
</MenuItem>
<MenuSeparator />
<MenuItem id="sign-out" onAction={signOut}>
Sign out
</MenuItem>
</AccountMenu>
);
}Example
Example
Settings
Coach
How long a payment waits before it counts.
Default: $100 waits 3 days, $500 a week, $1,500 a month
Suggested: $2,340, a month of your fixed bills
'use client';
import {
Button,
Card,
CardActions,
CardBody,
CardHeader,
CardTitle,
ConditionalFields,
ConfirmDialog,
CopyButton,
DescriptionList,
DescriptionListItem,
DialogTrigger,
FieldExtras,
NumberField,
PageHeader,
PanelLayout,
PanelNav,
PanelNavItem,
PanelSection,
PickerOption,
Repeater,
RouterProvider,
SaveBar,
Select,
Switch,
Text,
TextField,
ThemeSwitch,
toast,
useConfirm,
type ThemeChoice,
} from '@snapshot-ui/react';
import { useTheme } from 'next-themes';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
Suspense,
useCallback,
useEffect,
useId,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type ReactElement,
} from 'react';
import {
CONNECTIONS,
DEFAULT_MODEL,
DEFAULT_TIERS,
DEFAULT_TIERS_LABEL,
dollars,
FIRST_SAVED,
MAX_TIERS,
MODELS,
MONTH_OF_BILLS,
PROVIDERS,
SECTIONS,
SETUP_ADDRESS,
WAITS,
type Saved,
type SettingsSection,
type Tier,
} from './settings-data';
/** The search parameter that holds the open section. The basic example above reads its own, `section`. */
const PARAM = 'settings';
/** What every section is given: what is saved, how to save its part, and how to say it holds unsaved changes. */
interface SectionProps {
saved: Saved;
save: (patch: Partial<Saved>) => void;
onDirty: (isDirty: boolean) => void;
}
/** Tells the page whether the section holds unsaved changes, and that it holds none once it has gone. */
function useReportDirty(onDirty: (isDirty: boolean) => void, isDirty: boolean): void {
useEffect(() => {
onDirty(isDirty);
}, [onDirty, isDirty]);
useEffect(
() => () => {
onDirty(false);
},
[onDirty],
);
}
/** Your server action goes here; the example waits a moment instead. */
const stubAction = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 400));
const STACK: CSSProperties = { display: 'grid', gap: 'var(--spacing-separate)', minWidth: 0 };
// ---- Coach ------------------------------------------------------------------------------------------------------
let madeTiers = 0;
const newTierId = (): string => {
madeTiers += 1;
return `tier-${String(madeTiers)}`;
};
/** Whether two lists of tiers hold the same amounts and waits, in order; ids aside. */
const sameTiers = (a: readonly Omit<Tier, 'id'>[], b: readonly Omit<Tier, 'id'>[]): boolean =>
a.length === b.length &&
a.every((tier, index) => b[index]?.amount === tier.amount && b[index].wait === tier.wait);
/** A row whose fields are all empty means none: it is dropped when the tiers are saved. */
const isBlank = (tier: Tier): boolean => tier.amount === null && tier.wait === null;
/**
* The rows' errors, by row id: an amount an earlier tier already waits from, and, once a save has been tried, a
* tier with an amount and no wait or a wait and no amount.
*/
function tierErrors(tiers: readonly Tier[], checkMissing: boolean): Record<string, string> {
const errors: Record<string, string> = {};
tiers.forEach((tier, index) => {
const { amount } = tier;
if (amount !== null && tiers.slice(0, index).some((other) => other.amount === amount)) {
errors[tier.id] = `Another tier waits from ${dollars(amount)}`;
} else if (checkMissing && !isBlank(tier) && (tier.amount === null || tier.wait === null)) {
errors[tier.id] = 'Enter an amount and a wait, or remove the tier';
}
});
return errors;
}
function Coach({ saved, save, onDirty }: SectionProps): ReactElement {
const [tiers, setTiers] = useState<Tier[]>(() => [...saved.tiers]);
const [cushion, setCushion] = useState<number | null>(saved.cushion);
const [checkMissing, setCheckMissing] = useState(false);
const [problem, setProblem] = useState('');
const rowErrors = tierErrors(tiers, checkMissing);
const tiersChanged = !sameTiers(tiers, saved.tiers);
const cushionChanged = cushion !== saved.cushion;
const changes = Number(tiersChanged) + Number(cushionChanged);
useReportDirty(onDirty, changes > 0);
return (
<>
<div style={STACK}>
<Card>
<CardBody>
{/* One offer for the whole set: a reset replaces the rows and focuses the first row's amount. */}
<FieldExtras
scope="group"
fieldLabel="Wait tiers"
defaultOffer={{
value: DEFAULT_TIERS_LABEL,
isCurrent: sameTiers(tiers, DEFAULT_TIERS),
onTake: () => {
setTiers(DEFAULT_TIERS.map((tier) => ({ ...tier, id: newTierId() })));
},
}}
>
<Repeater<Tier>
label="Wait tiers"
description="A payment up to an amount waits this long before it counts."
value={tiers}
onChange={setTiers}
createRow={() => ({ id: newTierId(), amount: null, wait: null })}
rowLabel={(index) => `Tier ${String(index + 1)}`}
minRows={0}
maxRows={MAX_TIERS}
rowErrors={rowErrors}
isChanged={tiersChanged}
labels={{
add: 'Add a tier',
empty: 'No tiers. The coach never waits.',
full: (max) => `${String(max)} of ${String(max)}. Remove a tier to add another.`,
}}
>
{(tier, { update, error }) => (
<>
<NumberField
label="Up to"
money={{ symbol: '$' }}
value={tier.amount}
isInvalid={error !== undefined}
onChange={(amount) => {
update({ ...tier, amount });
}}
/>
<Select
label="Wait"
placeholder="Choose a wait"
value={tier.wait}
isInvalid={error !== undefined}
onChange={(key) => {
update({ ...tier, wait: key === null ? null : String(key) });
}}
>
{WAITS.map(([id, name]) => (
<PickerOption key={id} id={id}>
{name}
</PickerOption>
))}
</Select>
</>
)}
</Repeater>
</FieldExtras>
</CardBody>
</Card>
<Card>
<CardBody>
{/* Empty by default, never prefilled: the suggestion is an edit the reader takes. */}
<FieldExtras
fieldLabel="Cushion"
suggestion={{
value: '$2,340',
reason: 'a month of your fixed bills',
isCurrent: cushion === MONTH_OF_BILLS,
onTake: () => {
setCushion(MONTH_OF_BILLS);
},
}}
>
<NumberField
label="Cushion"
description="Empty keeps a month of your fixed bills."
money={{ symbol: '$' }}
value={cushion}
isChanged={cushionChanged}
onChange={setCushion}
/>
</FieldExtras>
</CardBody>
</Card>
</div>
<SaveBar
message={
changes === 0 ? 'Coach' : `${String(changes)} ${changes === 1 ? 'change' : 'changes'} to Coach`
}
isDirty={changes > 0}
actionLabel="Save"
onAction={async () => {
setCheckMissing(true);
const kept = tiers.filter((tier) => !isBlank(tier));
if (Object.keys(tierErrors(kept, true)).length > 0) {
setProblem('Fix the tiers marked above.');
return;
}
setProblem('');
// Rows whose fields are all empty mean none; the rest are sorted by amount, as the server keeps them.
const sorted = [...kept].sort((a, b) => (a.amount ?? 0) - (b.amount ?? 0));
await stubAction();
save({ tiers: sorted, cushion });
setTiers(sorted);
setCheckMissing(false);
toast({ title: 'Coach saved', tone: 'success' });
}}
onCancel={() => {
setTiers([...saved.tiers]);
setCushion(saved.cushion);
setCheckMissing(false);
setProblem('');
}}
status={problem}
statusTone="error"
/>
</>
);
}
// ---- Notifications ----------------------------------------------------------------------------------------------
function Notifications({ saved, save, onDirty }: SectionProps): ReactElement {
const [pushAmounts, setPushAmounts] = useState(saved.pushAmounts);
const [weeklyEmail, setWeeklyEmail] = useState(saved.weeklyEmail);
const id = useId();
const changes = Number(pushAmounts !== saved.pushAmounts) + Number(weeklyEmail !== saved.weeklyEmail);
useReportDirty(onDirty, changes > 0);
return (
<>
<div style={STACK}>
<Card>
<CardBody>
<Switch
isSelected={pushAmounts}
onChange={setPushAmounts}
isChanged={pushAmounts !== saved.pushAmounts}
>
Push amounts
</Switch>
<Switch
isSelected={weeklyEmail}
onChange={setWeeklyEmail}
isChanged={weeklyEmail !== saved.weeklyEmail}
aria-controls={id}
>
Weekly email
</Switch>
{/* No field at all: a status line and a button, hidden with the email. */}
<ConditionalFields id={id} isShown={weeklyEmail}>
<Text>Last sent Monday at 9:00 to you@example.com</Text>
<div>
<Button
variant="secondary"
onPress={() => {
toast({ title: 'Test email sent to you@example.com', tone: 'success' });
}}
>
Send a test
</Button>
</div>
</ConditionalFields>
<Text>A push names the amount only while Push amounts is on.</Text>
</CardBody>
</Card>
</div>
<SaveBar
message={
changes === 0
? 'Notifications'
: `${String(changes)} ${changes === 1 ? 'change' : 'changes'} to Notifications`
}
isDirty={changes > 0}
actionLabel="Save"
onAction={async () => {
await stubAction();
save({ pushAmounts, weeklyEmail });
toast({ title: 'Notifications saved', tone: 'success' });
}}
onCancel={() => {
setPushAmounts(saved.pushAmounts);
setWeeklyEmail(saved.weeklyEmail);
}}
/>
</>
);
}
// ---- Bank sync --------------------------------------------------------------------------------------------------
function BankSync(): ReactElement {
return (
<Card>
<CardBody>
<DescriptionList>
{CONNECTIONS.map((connection) => (
<DescriptionListItem key={connection.name} term={connection.name}>
{connection.state}
</DescriptionListItem>
))}
</DescriptionList>
</CardBody>
</Card>
);
}
// ---- AI assistant -----------------------------------------------------------------------------------------------
function Assistant({ saved, save, onDirty }: SectionProps): ReactElement {
const [key, setKey] = useState(saved.assistantKey);
const [model, setModel] = useState(saved.model);
const changes = Number(key !== saved.assistantKey) + Number(model !== saved.model);
useReportDirty(onDirty, changes > 0);
const modelName = (choice: string): string => MODELS.find(([each]) => each === choice)?.[1] ?? choice;
return (
<>
<div style={STACK}>
<Card>
<CardBody>
<TextField
label="API key"
type="password"
description="From your provider's dashboard."
value={key}
isChanged={key !== saved.assistantKey}
onChange={setKey}
/>
<FieldExtras
fieldLabel="Model"
defaultOffer={{
value: modelName(DEFAULT_MODEL),
isCurrent: model === DEFAULT_MODEL,
onTake: () => {
setModel(DEFAULT_MODEL);
},
}}
>
<Select
label="Model"
value={model}
isChanged={model !== saved.model}
onChange={(choice) => {
setModel(String(choice));
}}
>
{MODELS.map(([id, name]) => (
<PickerOption key={id} id={id}>
{name}
</PickerOption>
))}
</Select>
</FieldExtras>
</CardBody>
</Card>
</div>
<SaveBar
message={
changes === 0
? 'AI assistant'
: `${String(changes)} ${changes === 1 ? 'change' : 'changes'} to AI assistant`
}
isDirty={changes > 0}
actionLabel="Save"
onAction={async () => {
await stubAction();
save({ assistantKey: key, model });
toast({ title: 'AI assistant saved', tone: 'success' });
}}
onCancel={() => {
setKey(saved.assistantKey);
setModel(saved.model);
}}
/>
</>
);
}
// ---- Logos ------------------------------------------------------------------------------------------------------
function Logos({ saved, save, onDirty }: SectionProps): ReactElement {
const [logos, setLogos] = useState(saved.logos);
const [provider, setProvider] = useState(saved.logoProvider);
const [key, setKey] = useState(saved.logoKey);
const form = useRef<HTMLFormElement>(null);
const id = useId();
// Hidden fields are kept, never sent and never counted: under an off gate they mean "unchanged".
const changes =
Number(logos !== saved.logos) +
(logos ? Number(provider !== saved.logoProvider) + Number(key !== saved.logoKey) : 0);
useReportDirty(onDirty, changes > 0);
return (
<>
<form
ref={form}
style={STACK}
onSubmit={(event) => {
event.preventDefault();
}}
>
<Card>
<CardBody>
{/* The gate sends `logos` only while on, so the server knows what an absent key means. */}
<Switch
name="logos"
isSelected={logos}
onChange={setLogos}
isChanged={logos !== saved.logos}
aria-controls={id}
>
Show bank logos
</Switch>
<ConditionalFields id={id} isShown={logos}>
<Select
label="Provider"
name="logoProvider"
value={provider}
isChanged={provider !== saved.logoProvider}
onChange={(choice) => {
setProvider(String(choice));
}}
>
{PROVIDERS.map(([value, name]) => (
<PickerOption key={value} id={value}>
{name}
</PickerOption>
))}
</Select>
<TextField
label="API key"
name="logoKey"
type="password"
description="Kept while logos are off, and never sent."
value={key}
isChanged={key !== saved.logoKey}
onChange={setKey}
/>
</ConditionalFields>
<Text>Logos come from your provider, never from your bank.</Text>
</CardBody>
</Card>
<Card>
<CardBody>
<DescriptionList>
<DescriptionListItem
term="Setup address"
actions={<CopyButton value={SETUP_ADDRESS} labels={{ copy: 'Copy Setup address' }} />}
>
{/* A break opportunity after each part, so a narrow column wraps the address between its words. */}
<code style={{ fontSize: 'var(--text-small)' }}>
https://
<wbr />
hooks.example.com/
<wbr />
logos/
<wbr />
7Fq2
</code>
</DescriptionListItem>
<DescriptionListItem term="Last update">None yet</DescriptionListItem>
</DescriptionList>
</CardBody>
</Card>
</form>
<SaveBar
message={
changes === 0 ? 'Logos' : `${String(changes)} ${changes === 1 ? 'change' : 'changes'} to Logos`
}
isDirty={changes > 0}
actionLabel="Save"
onAction={async () => {
const element = form.current;
if (element === null) return;
// What a server action receives: with logos off, no provider and no key.
const data = new FormData(element);
const sent = (name: string): string | undefined => {
const value = data.get(name);
return typeof value === 'string' ? value : undefined;
};
await stubAction();
save({
logos: data.has('logos'),
logoProvider: sent('logoProvider') ?? saved.logoProvider,
logoKey: sent('logoKey') ?? saved.logoKey,
});
toast({ title: 'Logos saved', tone: 'success' });
}}
onCancel={() => {
setLogos(saved.logos);
setProvider(saved.logoProvider);
setKey(saved.logoKey);
}}
/>
</>
);
}
// ---- Appearance -------------------------------------------------------------------------------------------------
const subscribeNothing = (): (() => void) => () => undefined;
/** next-themes' stored theme as a ThemeSwitch value; anything else is System. */
const themeChoice = (theme: string | undefined): ThemeChoice =>
theme === 'light' || theme === 'dark' ? theme : 'system';
/**
* Applied at once, outside every SaveBar, and never Changed. Here it drives the docs site's own theme; in an app,
* `onChange` sets `data-theme` on `<html>` and a server action writes the cookie the root layout reads.
*/
function Appearance(): ReactElement {
const { theme, setTheme } = useTheme();
// The choice is known only in the browser, so the switch shows none until then.
const mounted = useSyncExternalStore(
subscribeNothing,
() => true,
() => false,
);
return (
<Card>
<CardBody>
<ThemeSwitch label="Appearance" value={mounted ? themeChoice(theme) : null} onChange={setTheme} />
<Text>Applied at once, on this device. There is nothing to save.</Text>
</CardBody>
</Card>
);
}
// ---- Data and Danger zone ---------------------------------------------------------------------------------------
function Data(): ReactElement {
return (
<Card>
<CardHeader>
<CardTitle level={4}>Transactions</CardTitle>
<CardActions>
<Button
variant="secondary"
onPress={() => {
toast({ title: 'Your export is being made. We will email you a link.', tone: 'info' });
}}
>
Export transactions
</Button>
</CardActions>
</CardHeader>
<CardBody>
<Text>A CSV of every transaction, with its category and notes.</Text>
</CardBody>
</Card>
);
}
/** A plain card with a danger button: danger is the action's, and a card is never red (error means a failure). */
function DangerZone({ save }: SectionProps): ReactElement {
return (
<Card>
<CardHeader>
<CardTitle level={4}>Your data</CardTitle>
<CardActions>
<DialogTrigger>
<Button variant="danger">Delete all data</Button>
<ConfirmDialog
tone="danger"
title="Delete all data?"
description="Every transaction, tier and setting goes, and your bank connections close. This cannot be undone."
confirmLabel="Delete all data"
onConfirm={() => {
save(FIRST_SAVED);
toast({ title: 'All data deleted', tone: 'info' });
}}
/>
</DialogTrigger>
</CardActions>
</CardHeader>
<CardBody>
<Text>Delete your transactions, tiers and settings, and close your bank connections.</Text>
</CardBody>
</Card>
);
}
/** Each section's cards, by its key. A key with none draws nothing. */
const BODIES: Record<string, (props: SectionProps) => ReactElement> = {
coach: Coach,
notifications: Notifications,
'bank-sync': BankSync,
assistant: Assistant,
logos: Logos,
appearance: Appearance,
data: Data,
danger: DangerZone,
};
// ---- The page ---------------------------------------------------------------------------------------------------
interface PageProps extends Omit<SectionProps, 'onDirty'> {
sectionKey: string | null;
pathname: string;
onDirty: (section: SettingsSection, isDirty: boolean) => void;
}
/** The page at `?settings=`, or at the index with none: the URL is the state, and the layout holds none. */
function Settings({ sectionKey, pathname, saved, save, onDirty }: PageProps): ReactElement {
const open = SECTIONS.find((section) => section.key === sectionKey);
const view = open === undefined ? 'index' : 'section';
// At the index the split face shows the first section beside the list, marked isDefault.
const shown = open ?? SECTIONS[0];
const Body = shown === undefined ? undefined : BODIES[shown.key];
const onShownDirty = useCallback(
(isDirty: boolean) => {
if (shown !== undefined) onDirty(shown, isDirty);
},
[onDirty, shown],
);
return (
<div style={{ display: 'grid', gap: 'var(--spacing-part)', width: '100%', minWidth: 0 }}>
{/* The shell's header holds the page's title; here it is a second-level heading, since the docs page has
its own h1. */}
<PageHeader title="Settings" headingLevel={2} />
<PanelLayout
view={view}
sectionKey={open?.key}
backHref={pathname}
backLabel="Settings"
nav={
<PanelNav aria-label="Settings">
{SECTIONS.map((section) => (
<PanelNavItem
key={section.key}
href={`${pathname}?${PARAM}=${section.key}`}
isCurrent={section.key === open?.key}
isDefault={view === 'index' && section === SECTIONS[0]}
description={section.description}
end={section.end?.(saved)}
>
{section.name}
</PanelNavItem>
))}
</PanelNav>
}
>
{shown === undefined ? null : (
<PanelSection title={shown.name} description={shown.description} headingLevel={3}>
{Body === undefined ? null : (
// Keyed by the section, so another one opens with what is saved.
<Body key={shown.key} saved={saved} save={save} onDirty={onShownDirty} />
)}
</PanelSection>
)}
</PanelLayout>
</div>
);
}
/**
* Resolves once focus has left the question's dialog, which gives it back to what opened it as it closes, or after
* a second at most. A section change moves focus to the next heading only when focus is in the layout, so the
* router waits for it.
*/
const focusBack = (): Promise<void> =>
new Promise((resolve) => {
const started = performance.now();
const check = (): void => {
const active = document.activeElement;
const placed =
active !== null && active !== document.body && active.closest('[role="alertdialog"]') === null;
if (placed || performance.now() - started > 1000) resolve();
else requestAnimationFrame(check);
};
requestAnimationFrame(check);
});
function SettingsFromUrl(props: Omit<PageProps, 'sectionKey'>): ReactElement {
return <Settings {...props} sectionKey={useSearchParams().get(PARAM)} />;
}
export default function SettingsRecipe(): ReactElement {
const router = useRouter();
const pathname = usePathname();
// In an app the server holds these; the example keeps them in state so Save shows its effect.
const [saved, setSaved] = useState<Saved>(FIRST_SAVED);
const save = useCallback((patch: Partial<Saved>) => {
setSaved((current) => ({ ...current, ...patch }));
}, []);
// The section holding unsaved changes, read by navigate: a ref, since leaving needs no render.
const dirty = useRef<SettingsSection | null>(null);
const onDirty = useCallback((section: SettingsSection, isDirty: boolean) => {
if (isDirty) dirty.current = section;
else if (dirty.current === section) dirty.current = null;
}, []);
const { confirm, dialog } = useConfirm();
const leave = async (href: string): Promise<void> => {
const section = dirty.current;
if (section !== null) {
// Focus goes back to the link pressed, and the layout then gives the next section's heading focus.
const isSure = await confirm({
tone: 'danger',
title: 'Discard changes?',
description: `The changes you made in ${section.name} will be lost.`,
confirmLabel: 'Discard changes',
cancelLabel: 'Keep editing',
});
if (!isSure) return;
dirty.current = null;
// Go once the question has given focus back to the link, so the layout sees a change made from inside it.
await focusBack();
}
router.push(href, { scroll: false });
};
const props = { pathname, saved, save, onDirty };
return (
// An app wraps its whole tree once; the example wraps only itself. Every link of the page goes through
// navigate, which asks before it leaves a section with unsaved changes.
<RouterProvider
navigate={(href) => {
void leave(href);
}}
>
<div style={{ width: '100%', minWidth: 0 }}>
{/* The page is static, so the server renders the fallback: the index, Coach beside the list. */}
<Suspense fallback={<Settings {...props} sectionKey={null} />}>
<SettingsFromUrl {...props} />
</Suspense>
</div>
{dialog}
</RouterProvider>
);
}Usage
PanelLayout, PanelNav and PanelNavItem are client components, for their links and the focus move. PanelSection and PanelRow are static and render in a server component.
import { PanelLayout, PanelNav, PanelNavItem, PanelRow, PanelSection } from '@snapshot-ui/react';
<PanelLayout
view="section"
sectionKey="bank-sync"
backHref="/settings"
backLabel="Settings"
nav={
<PanelNav aria-label="Settings sections">
<PanelNavItem href="/settings/coach" description="How the coach waits before a charge.">
Coach
</PanelNavItem>
<PanelNavItem href="/settings/bank-sync" end="2 connected" isCurrent>
Bank sync
</PanelNavItem>
</PanelNav>
}
>
<PanelSection title="Bank sync" description="Connected accounts and how often they sync.">
<PanelRow layout="halves">
<Card>...</Card>
<Card>...</Card>
</PanelRow>
</PanelSection>
</PanelLayout>;The layout's and a row's intrinsic inline size is 0, as every size container's is. In a holder sized to its content, such as an auto grid track, a max-content box or a flex item that does not grow, give them a width. A lone PanelNav or PanelSection in such a holder is as wide as its longest word, which stays whole.
Props
The rest of a div's attributes go on the layout's root and on a row, and the rest of a section element's on PanelSection, with className merged after the library's class and ref on that element. PanelNavItem passes the rest of a link's props to its link.
backHref string | Where the list face's Back goes: the index's address, "/settings". Through RouterProvider. |
|---|---|
backLabel string | Back's text: the index's name, "Settings". It is the link's whole name. |
children ReactNode | The open section, a PanelSection. In the index view, what the split face shows beside the list (the default section); the list face does not show it. |
className string | |
nav ReactNode | The section list: a PanelNav. |
sectionKey Key | The open section's key, from the URL. A change after hydration moves focus to the section's heading. |
view PanelLayoutView |
|
aria-label string | A name for assistive technology. Give it, or |
|---|---|
aria-labelledby string | The id of an element that holds the name, such as a label shown elsewhere. |
children ReactNode | PanelNavItem elements, in order. |
children ReactNode | The section's name: "Bank sync". |
|---|---|
className string | Merged after the library's class on the link. |
description ReactNode | A line under the name on the list face: "Connected accounts and how often they sync". |
end ReactNode | A short state at the end, on both faces: "On", "2 connected". Text, never a color. |
href string | The section's address. Required: a section is a page. |
icon ReactNode | A 16px icon at the start, hidden from assistive technology. |
isCurrent boolean | This section is open: |
isDefault boolean | This section is what the index shows on the split face: drawn as current there and only there, and never
|
children ReactNode | |
|---|---|
description ReactNode | A sentence under the heading, in slate. |
headingLevel 2 | 3 | The heading's level under the page's Default: 2 |
title ReactNode | The section's name, its heading: "Coach". Focus lands on it when the section opens. |
children readonly [ReactNode, ReactNode] | Exactly two cards: widths only ever pair. |
|---|---|
layout PanelRowLayout | Which pair. A full card is a Card outside any row. |
Accessibility
The nav is a navigation landmark you name, with plain links: no menu role and no tabs. The open section's link carries aria-current="page". Each link's name is the section's name; its end state and description describe it, so a screen reader says "Bank sync, link, current page, 2 connected, Connected accounts and how often they sync". The end state is words, never a dot.
The section is a region named by its heading. After a section change the heading takes focus, a frame after the change, on both faces, which brings it into view under the sticky header and tells a screen reader where it landed, as a whole page load does. It moves only when focus was in the layout or was lost, never when you started the change elsewhere, and nothing moves on the first render or on hydration. Back's name is its label alone, "Settings", and back at the index on a phone focus returns to the link of the section you left. When a resize turns the face, a window narrowed or a tablet turned, and hides what held focus, the section's heading takes it, or at the index on a phone the default section's link.
On a wide screen the index shows the default section beside the list, drawn current, while no link is aria-current: on a phone the same address is the list and no section is open, and a screen reader hears that.
| Tab | Moves through the nav's links, then the open section. On a phone's section, Back comes first. |
|---|---|
| Shift+Tab | Moves back through them. |
| Enter | On a link, opens its section and moves focus to the section's heading. On Back, opens the list and moves focus to the link of the section left. |
Design rules
What keeps a settings page in the system.
Give every section its own address, and read view and sectionKey from it.
Mark the section beside the index isDefault, never isCurrent.
Write end states as words, such as On or 2 connected.
Keep a section to one column of cards, and pair two only in a PanelRow.
Never hold the open section in state the URL does not carry.
Never put three cards in a row, or one.
Never prefix Back with words the page does not show.
Never draw a section red: a danger zone is a plain card with a danger button.