Skip to content
9 min readSisyphos UI Contributors

Building an accessible settings page with Sisyphos UI

tutorialreactaccessibility

Settings pages are where accessibility bugs go to hide. They are dense with interactive controls, they rarely get the design attention of a landing page, and they are exactly the screens that keyboard and screen-reader users visit on purpose — to turn on the preferences that make the rest of your app usable for them. In this tutorial we build one with five Sisyphos UI components: Tabs, Card, Switch, Select, and RadioGroup, and we look at exactly which accessibility work the library does for you and which parts remain your job.

Setup

terminalbash
pnpm add @sisyphos-ui/react @sisyphos-ui/core

Import the stylesheet once at your app entry — components ship no inline styles:

app/layout.tsxtsx
import "@sisyphos-ui/react/styles.css";

A note on scope before we start: everything below is plain composition. There is no settings-page template to configure, no schema DSL — just five components with their documented props, arranged the way any React page is. That is deliberate. Settings pages vary too much between products for a prefab to survive contact with real requirements, so the useful thing a library can give you is controls whose accessible behavior is already correct, plus a clear account of the seams between them. This article is that account.

Structure: Tabs as the page skeleton

We will split the page into three panels — Account, Notifications, and Appearance — using the compound Tabs API. Tabs implements a roving tabindex: only the active trigger sits in the tab order, and Arrow keys move between triggers, exactly as the WAI-ARIA tabs pattern specifies. You get that by composing Tabs.List, Tabs.Trigger, and Tabs.Panel; the matching value strings wire the aria-controls / aria-labelledby relationships for you.

app/settings/page.tsxtsx
import { Tabs } from "@sisyphos-ui/react";

export default function SettingsPage() {
  return (
    <main>
      <h1>Settings</h1>
      <Tabs defaultValue="account" variant="underline">
        <Tabs.List aria-label="Settings sections">
          <Tabs.Trigger value="account">Account</Tabs.Trigger>
          <Tabs.Trigger value="notifications">Notifications</Tabs.Trigger>
          <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
        </Tabs.List>

        <Tabs.Panel value="account"><AccountPanel /></Tabs.Panel>
        <Tabs.Panel value="notifications"><NotificationsPanel /></Tabs.Panel>
        <Tabs.Panel value="appearance"><AppearancePanel /></Tabs.Panel>
      </Tabs>
    </main>
  );
}

Two details worth noting. Tabs works controlled (value + onValueChange) or uncontrolled (defaultValue) — for a settings page, uncontrolled is fine unless you want to sync the active tab to the URL. And panels stay mounted by default (forceMount defaults to true), so form state in an inactive tab is not lost when the user switches away. That default matters more on a settings page than anywhere else.

Account: Select with real labels

Select takes a flat options array and — crucially — a label prop. When you pass it, the library renders a visible label and associates it with the control; you never need to hand-roll htmlFor/id pairs. helperText renders advisory text below the field, and searchable turns the listbox into a filterable one, which is the humane choice for a long timezone list:

account-panel.tsxtsx
import { useState } from "react";
import { Card, Select, type SelectValue } from "@sisyphos-ui/react";

const TIMEZONES = [
  { value: "Europe/Istanbul", label: "Istanbul (UTC+3)" },
  { value: "Europe/Berlin", label: "Berlin (UTC+1)" },
  { value: "America/New_York", label: "New York (UTC-5)" },
  // …
];

const LANGUAGES = [
  { value: "en", label: "English" },
  { value: "tr", label: "Türkçe" },
  { value: "es", label: "Español", description: "Beta" },
];

export function AccountPanel() {
  const [timezone, setTimezone] = useState<SelectValue | null>("Europe/Istanbul");
  const [language, setLanguage] = useState<SelectValue | null>("en");

  return (
    <Card variant="elevated" padding="lg">
      <Card.Header>
        <h2>Locale</h2>
      </Card.Header>
      <Card.Body>
        <Select
          label="Timezone"
          options={TIMEZONES}
          value={timezone}
          onChange={setTimezone}
          searchable
          fullWidth
          helperText="Used for digests and scheduled exports."
        />
        <Select
          label="Language"
          options={LANGUAGES}
          value={language}
          onChange={setLanguage}
          fullWidth
        />
      </Card.Body>
    </Card>
  );
}

Card here is pure structure: Card.Header renders a semantic <header>, Card.Footer a <footer>. Keep one heading per card and keep heading levels sequential (h1 for the page, h2 per card) — screen-reader users navigate settings pages by heading far more often than by tabbing.

Notifications: the Switch labeling trap

Switch renders a native <button role="switch"> with aria-checked, toggling on click, Space, and Enter. It is always controlled: you pass checked and receive the next value in onChange. What it deliberately does not do is invent a label. If there is no visible text associated with it, you must pass aria-label — and if there is visible text, associate it properly instead of duplicating it:

notifications-panel.tsxtsx
import { useState } from "react";
import { Card, Switch } from "@sisyphos-ui/react";

function SwitchRow(props: {
  id: string;
  title: string;
  description: string;
  checked: boolean;
  onChange: (next: boolean) => void;
}) {
  return (
    <div className="switch-row">
      <div>
        <span id={props.id}>{props.title}</span>
        <p id={`${props.id}-desc`}>{props.description}</p>
      </div>
      <Switch
        checked={props.checked}
        onChange={props.onChange}
        aria-labelledby={props.id}
        aria-describedby={`${props.id}-desc`}
      />
    </div>
  );
}

export function NotificationsPanel() {
  const [mentions, setMentions] = useState(true);
  const [digest, setDigest] = useState(false);

  return (
    <Card variant="elevated" padding="lg">
      <Card.Header><h2>Email notifications</h2></Card.Header>
      <Card.Body>
        <SwitchRow
          id="notif-mentions"
          title="Mentions"
          description="Email me when someone @mentions me."
          checked={mentions}
          onChange={setMentions}
        />
        <SwitchRow
          id="notif-digest"
          title="Weekly digest"
          description="A summary of activity, every Monday."
          checked={digest}
          onChange={setDigest}
        />
      </Card.Body>
    </Card>
  );
}

The aria-labelledby / aria-describedbypair is the part the library cannot do for you, because only you know which text on screen names the control. A screen reader now announces “Mentions, switch, on” followed by the description — instead of the dreaded unlabeled “switch, on.”

Appearance: RadioGroup wired to the theme

Theme choice is a single-select decision, so it should be a radio group, not a row of switches. RadioGroup accepts a flat options array (each option can carry a description), renders a group label, and handles arrow-key movement between options. The variant="card" style turns each option into a large click target while keeping native radio semantics underneath. We wire the result to setThemeMode() from core:

appearance-panel.tsxtsx
import { useState } from "react";
import { Card, RadioGroup } from "@sisyphos-ui/react";
import { setThemeMode } from "@sisyphos-ui/core";

const THEME_OPTIONS = [
  { value: "light", label: "Light", description: "Bright surfaces, dark text." },
  { value: "dark", label: "Dark", description: "For late nights and OLED screens." },
];

export function AppearancePanel() {
  const [theme, setTheme] = useState<string | number>("light");

  function handleChange(value: string | number) {
    setTheme(value);
    setThemeMode(value as "light" | "dark");
    localStorage.setItem("theme", String(value));
  }

  return (
    <Card variant="elevated" padding="lg">
      <Card.Header><h2>Theme</h2></Card.Header>
      <Card.Body>
        <RadioGroup
          label="Interface theme"
          options={THEME_OPTIONS}
          value={theme}
          onChange={handleChange}
          variant="card"
          direction="horizontal"
        />
      </Card.Body>
    </Card>
  );
}

Note what did not appear in that snippet: no role="radiogroup", no name attribute (auto-generated when omitted), no manual aria-checked bookkeeping. If the group were required or could fail validation, required, error, and errorMessage props handle the styling and the ARIA wiring in one place.

Testing it: role-based queries prove the semantics

A nice property of doing the labeling correctly is that your tests get simpler and stricter at the same time. Query by ARIA role and accessible name — the same interface a screen reader uses — and the test fails precisely when the semantics break:

settings.test.tsxtsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

it("toggles the mentions preference", async () => {
  render(<NotificationsPanel />);

  // Passes only if aria-labelledby is wired: the switch's
  // accessible name must be "Mentions".
  const mentions = screen.getByRole("switch", { name: "Mentions" });
  expect(mentions).toBeChecked();

  await userEvent.click(mentions);
  expect(mentions).not.toBeChecked();
});

it("moves between sections with arrow keys", async () => {
  render(<SettingsPage />);
  const account = screen.getByRole("tab", { name: "Account" });

  account.focus();
  await userEvent.keyboard("{ArrowRight}");
  expect(screen.getByRole("tab", { name: "Notifications" })).toHaveFocus();
});

If someone later replaces the SwitchRow markup and drops the aria-labelledby, the first test cannot find its switch and fails loudly — an accessibility regression caught by a functional test, with no axe scan required. This is also exactly how the library tests itself: the component suites assert role="switch", aria-checked, and arrow-key movement per framework, so your integration tests can take the per-control behavior as given and focus on the wiring only you can get wrong.

The dividing line: what you still own

The library's components arrive with the ARIA pattern work done:

  • Tabs: roving tabindex, arrow-key navigation, trigger–panel associations.
  • Switch: role="switch", aria-checked, Space/Enter activation, disabled semantics.
  • Select: labeled combobox behavior, keyboard-navigable listbox, type-to-filter.
  • RadioGroup: group labeling, arrow-key selection, error announcement.

What no library can own, and this page still needs from you:

  • Label associations. Every aria-labelledby in the Switch rows above. An unlabeled control is the most common settings-page defect, and it is always an integration bug, not a component bug.
  • Heading structure. One h1, then h2 per card. Screen-reader users navigate by headings first.
  • Persistence feedback. If saving is async, announce success or failure — a visually silent auto-save is also a semantically silent one.
  • The two-minute keyboard test. Unplug your mouse: Tab to the tab list, arrow between sections, toggle a switch with Space, pick a timezone with type-ahead, change the theme with arrows. If any step traps you or skips a control, you have found a real bug before your users do.

That test is the whole philosophy in miniature: the library guarantees each control behaves correctly in isolation, and you verify the composition. Both halves are necessary; neither is sufficient alone.