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

'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

'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

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.

Follows the page theme and corners
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

Settings

Coach

How long a payment waits before it counts.

Wait tiers

A payment up to an amount waits this long before it counts.

  1. Tier 1
    Changed
    $
    WaitChanged
  2. Tier 2
    Changed
    $
    WaitChanged
2 of 3

Default: $100 waits 3 days, $500 a week, $1,500 a month

Changed
$
Empty keeps a month of your fixed bills.

Suggested: $2,340, a month of your fixed bills

Coach
Follows the page theme and corners
'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

index at the list's own address, section at a section's. Read from the URL by the app.

aria-label
string

A name for assistive technology. Give it, or aria-labelledby, where no label or text is shown.

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: aria-current="page" and the ink pill.

isDefault
boolean

This section is what the index shows on the split face: drawn as current there and only there, and never aria-current, since on a phone the index shows the list and no section.

children
ReactNode

description
ReactNode

A sentence under the heading, in slate.

headingLevel
2 | 3

The heading's level under the page's h1.

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.

TabMoves through the nav's links, then the open section. On a phone's section, Back comes first.
Shift+TabMoves back through them.
EnterOn 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.

esc

↑ ↓ to move↵ to open