Updated 9 October 2026. I built the settings page from the first version of this post as a real project and ran it in Chromium, Firefox and WebKit, with oks-ui 1.4.1, TypeScript 7 in strict mode and Playwright 1.63. Most of it worked as described. Four things in the first version did not: the toast never appeared, the "type the workspace name" dialog had nowhere to type it, the save bar never went away after saving, and dark mode left the tab labels unreadable. Each is corrected below, and the results are in a table near the end.
Every product needs a settings screen, and most of them share the same shape: a few sections, a form per section, some switches, and a dangerous action at the bottom. This tutorial builds one with oks-ui.
import "oks-ui/tokens.css"; // the design tokens every component stylesheet reads
import { Tabs, Tab } from "oks-ui/tabs";
import "oks-ui/tabs.css";
import { Card } from "oks-ui/card";
import "oks-ui/card.css";
import { FormFieldSet } from "oks-ui/form-field-set";
import "oks-ui/form-field-set.css";
import { Button } from "oks-ui/button";
import "oks-ui/button.css";
import { Modal } from "oks-ui/modal";
import "oks-ui/modal.css";
import { toast, ToastProvider } from "oks-ui/toast";
import "oks-ui/toast.css";
The finished page in four states.
1. Sections as tabs
Settings grow. Tabs keep each group short enough to scan:
<Tabs aria-label="Settings" variant="underlined">
<Tab key="profile" title="Profile">
<ProfileSettings />
</Tab>
<Tab key="notifications" title="Notifications">
<NotificationSettings />
</Tab>
<Tab key="danger" title="Danger zone">
<DangerZone />
</Tab>
</Tabs>
In all three browsers the tabs have the right roles, and the arrow keys move between them and switch the panel at once.
On a narrow screen the tabs scroll sideways instead of wrapping. I checked this with eight tabs at 320 pixels wide: they stayed on one line, the tab list was 645 to 659 pixels wide inside 288, the page itself did not scroll sideways, and pressing the arrow key through to the last tab brought it into view. With only three tabs they simply fit.
Put the open tab in the URL, for example /settings?section=notifications, so support can send someone straight to the right place and a refresh does not drop them back on Profile. Tabs takes a selectedKey and an onSelectionChange, so you control it yourself:
const [section, setSection] = useState(new URLSearchParams(location.search).get("section") ?? "profile");
const onSelect = (key: React.Key) => {
setSection(String(key));
const url = new URL(location.href);
url.searchParams.set("section", String(key));
history.replaceState(null, "", url);
};
<Tabs aria-label="Settings" variant="underlined" selectedKey={section} onSelectionChange={onSelect}>
I tested exactly this: choosing a tab changed the address, and reloading the page opened the same tab, in all three browsers. In Next.js you would read the value with useSearchParams instead of location.search. I did not test that variant.
A common mistake is to split settings into eight tabs with three fields each. Aim for the opposite: a handful of tabs, each worth opening. If a section has one switch in it, it belongs in another section.
2. The profile form
Group related fields in a card, one column on phones and two on desktop:
const ProfileSettings = () => {
const [profile, setProfile] = useState(initial);
const set = (key: string) => (v: unknown) => setProfile((p) => ({ ...p, [key]: String(v ?? "") }));
return (
<Card shadow="sm" radius="lg" style={{ padding: 24 }}>
<div className="settings-grid">
<FormFieldSet type="text" name="fullName" label="Full name" value={profile.fullName} onChange={set("fullName")} />
<FormFieldSet type="email" name="email" label="Email" value={profile.email} onChange={set("email")}
description="Used for sign-in and billing receipts." />
<FormFieldSet type="text" name="company" label="Company" value={profile.company} onChange={set("company")} />
<FormFieldSet type="select" name="timezone" label="Time zone" value={profile.timezone} onChange={set("timezone")}
options={[{ label: "India (IST)", value: "Asia/Kolkata" }, { label: "UK (GMT)", value: "Europe/London" }]} />
</div>
</Card>
);
};
.settings-grid { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (max-width: 720px) { .settings-grid { grid-template-columns: 1fr; } }
This type-checks as written. Each label is connected to its input, and the description under the email field is linked to it with aria-describedby, so a screen reader reads it with the field. The grid changed from two columns to one under 720 pixels.
3. Switches for preferences
A preference that takes effect immediately belongs on a switch, not behind a save button:
<FormFieldSet type="switch" name="productUpdates" label="Product updates"
description="A monthly email about new components." />
<FormFieldSet type="switch" name="commentReplies" label="Replies to my comments" />
Give every switch a description. "Product updates" alone does not tell anyone what they will receive. The switches have role="switch" and Space toggles them. In this example they are not connected to anything: wire onChange to your own save call. I tested the markup and the keyboard, not a save call.
4. A save bar that only appears when needed
Nothing is more annoying than a save button you cannot tell is active. Track whether anything changed, and show the bar only then. The first version compared against a constant, so after a successful save the bar stayed on screen forever. Keep the last saved copy in state and update it when saving succeeds:
const [saved, setSaved] = useState(initial);
const dirty = JSON.stringify(profile) !== JSON.stringify(saved);
const save = async () => {
setSaving(true);
await api.saveProfile(profile);
setSaved(profile); // the new baseline: the bar goes away
setSaving(false);
toast.success("Settings saved");
};
{dirty && (
<Card className="save-bar" radius="none" shadow="lg">
<span>You have unsaved changes</span>
<div className="save-bar-actions">
<Button variant="bordered" onPress={() => setProfile(saved)}>Discard</Button>
<Button color="primary" isLoading={saving} onPress={save}>Save changes</Button>
</div>
</Card>
)}
The first version said "pin it to the bottom" and gave no CSS. This is what I used, and why:
.save-bar { flex-direction: row; position: fixed; inset: auto 0 0 0; z-index: 10;
justify-content: space-between; align-items: center; gap: 12px; padding: 12px 16px; }
.save-bar-actions { display: flex; gap: 8px; }
Two details. I render the bar with Card so it gets the surface colour, border and text colour from the tokens, which also makes it work in dark mode. And Card stacks its content in a column, hence the flex-direction: row. A fixed bar covers the end of the page, so add bottom padding to the page while the bar is showing. I used 96 pixels; with it, the last field could always be scrolled clear of the bar at a 380-pixel-tall window.
Discard put the fields back and removed the bar, and Save showed the button's loading state, in all three browsers.
5. The danger zone
Destructive actions get their own section, a danger colour, and a confirmation that names the thing being deleted. The first version's dialog said "Type the workspace name to confirm" but had no field to type it in, and the Delete button worked without typing anything. I confirmed both in all three browsers. Here is the version that does what it says:
const [confirm, setConfirm] = useState(false);
const [typed, setTyped] = useState("");
<Card shadow="sm" radius="lg" style={{ padding: 24, borderColor: "var(--oks-color-danger-200)" }}>
<h3>Delete this workspace</h3>
<p>Everything in it is removed, including your posts and files. This can't be undone.</p>
<Button color="danger" onPress={() => setConfirm(true)}>Delete workspace</Button>
</Card>
<Modal
isOpen={confirm}
onClose={() => setConfirm(false)}
role="alertdialog"
title="Delete this workspace?"
actions={
<>
<Button variant="bordered" onPress={() => setConfirm(false)}>Cancel</Button>
<Button color="danger" onPress={remove} isDisabled={typed !== workspace.name}>Delete</Button>
</>
}
>
Type the workspace name to confirm. This removes all of its data.
<FormFieldSet type="text" name="confirmName" label="Workspace name" value={typed}
onChange={(v: unknown) => setTyped(String(v ?? ""))} />
</Modal>
With this, Delete stayed disabled until the typed text matched, in all three browsers. The dialog has role="alertdialog" and its title as its accessible name; that role is the correct one for a confirmation that interrupts the user. The --oks-color-danger-200 token exists in tokens.css.
6. Feedback after saving
Use a toast, not an alert that pushes the page around. The first version showed toast.success("Settings saved") and nothing else, and with only that, nothing appears: I tried it in all three browsers and the page stayed silent. The toast needs a ToastProvider rendered once, near the top of your app:
import { toast, ToastProvider } from "oks-ui/toast";
import "oks-ui/toast.css";
root.render(
<ToastProvider>
<App />
</ToastProvider>,
);
toast.success("Settings saved");
7. Do not lose someone's edits
A settings page is the classic place to lose work: a person edits three fields, clicks a nav link, and the changes are gone with no warning. Guard the exit while the form is dirty:
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
This worked in all three browsers: closing the page after an edit raised the browser's "leave site?" prompt, and closing an untouched form did not. Browsers only show the prompt if the person has interacted with the page, which an edit counts as. That covers closing the tab. For in-app navigation, intercept the router's route change and show the same kind of confirmation dialog. I did not test router interception.
Dark mode: set the page text colour
data-theme="dark" changes the components, but the text you write yourself, the page heading and the tab labels, takes its colour from the page. tokens.css does not set one. On my page in dark mode the tab labels came out black on a near-black background, a contrast ratio of 1.06 to 1, and axe-core flagged them. One rule fixes it:
body { background: var(--oks-color-surface); color: var(--oks-color-neutral-900); }
With that, axe-core reported no violations in light or dark in any of the three browsers. The same token worked in both themes in my test.
What to leave out
Two things creep into settings pages and never earn their place. The first is a second confirmation on harmless actions: asking "are you sure?" before saving a display name teaches people to click through every dialog, including the one that matters. The second is an avatar uploader that silently drops a 12 MB photo; if you have one, state the limits next to it.
Results
Every result, in all three browsers.
For size, the whole page with React came to 118 KB of JavaScript and 26 KB of CSS gzipped.
Check your own unsaved-changes guard
This script types into a field, closes the page, and reports whether the browser would have asked before leaving, in each engine. Install playwright first.
// npm i -D playwright
// node unsaved-guard-check.mjs http://localhost:3000/settings "input[name=company]"
import { chromium, firefox, webkit } from "playwright";
const [url, selector] = process.argv.slice(2);
for (const [name, type] of [["Chromium", chromium], ["Firefox", firefox], ["WebKit", webkit]]) {
const results = [];
for (const edit of [false, true]) {
const browser = await type.launch();
const context = await browser.newContext({ viewport: { width: 1000, height: 800 } });
const page = await context.newPage();
let asked = false;
page.on("dialog", (dialog) => {
if (dialog.type() === "beforeunload") asked = true;
dialog.dismiss().catch(() => {});
});
await page.goto(url);
await page.click(selector); // browsers only ask after the page has had a user interaction
if (edit) await page.keyboard.type("x");
await page.waitForTimeout(200); // let the app notice the edit
await page.close({ runBeforeUnload: true });
await new Promise((resolve) => setTimeout(resolve, 400)); // the dialog event can arrive just after close()
results.push(`${edit ? "after an edit" : "untouched"}: ${asked ? "asks before leaving" : "leaves silently"}`);
await browser.close();
}
console.log(`${name}: ${results.join("; ")}`);
}
On my page it printed "untouched: leaves silently; after an edit: asks before leaving" for all three browsers.
See a finished version on the settings pattern.
Limits
- I built one page with three sections and four profile fields. Larger forms, validation messages and long option lists were not tested.
- The save call is simulated with a short delay. I did not test failed saves or what the bar should do when one fails.
- The tab-in-URL code was tested with plain
URLSearchParamsandhistory.replaceState, not with Next.js'suseSearchParams. Router interception for in-app navigation was not tested. - axe-core can only find some kinds of accessibility problem, and I did not test with a screen reader.
- I tested in desktop browsers at several widths, not on real phones.
Originally published at oks-ui.com.
Top comments (0)