Skip to content
oks-ui

Build a SaaS Settings Page in React

Page build · on · by oks-ui team

A settings panel with a switch, a checkbox and a radio option, and a save button below.
ShareXLinkedIn
On this page
  1. 1. Sections as tabs
  2. 2. The profile form
  3. 3. Switches for preferences
  4. 4. A save bar that only appears when needed
  5. 5. The danger zone
  6. 6. Feedback after saving
  7. 7. Don't lose someone's edits
  8. What to leave out

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 { 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";

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>

On a narrow screen, tabs scroll sideways rather than wrapping into a tangle.

Put the open tab in the URL — /settings?section=notifications — so support can send someone straight to the right place and a refresh doesn't drop them back on Profile. Read it with useSearchParams and pass it as selectedKey; the ten lines it costs save a surprising number of support replies.

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:

function 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; } }
Advertisement

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 doesn't tell anyone what they'll receive.

4. A save bar that only appears when needed

Nothing is more annoying than a save button you can't tell is active. Track whether anything changed, and show the bar only then:

const dirty = JSON.stringify(profile) !== JSON.stringify(initial);

{dirty && (
  <div className="save-bar">
    <span>You have unsaved changes</span>
    <div>
      <Button variant="bordered" onPress={() => setProfile(initial)}>Discard</Button>
      <Button color="primary" isLoading={saving} onPress={save}>Save changes</Button>
    </div>
  </div>
)}

Pin it to the bottom of the screen so it's reachable on a long form.

5. The danger zone

Destructive actions get their own section, a danger colour, and a confirmation that names the thing being deleted:

<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}>Delete</Button>
    </>
  }
>
  Type the workspace name to confirm — this removes all of its data.
</Modal>

role="alertdialog" is the correct role for a confirmation that interrupts the user.

6. Feedback after saving

Use a toast, not an alert that pushes the page around:

import { toast } from "oks-ui/toast";
import "oks-ui/toast.css";

toast.success("Settings saved");

7. Don't 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]);

That covers closing the tab. For in-app navigation, intercept the router's route change and show the same confirmation modal you built for the danger zone.

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 12MB photo; if you have one, state the limits next to it.

See a finished version on the settings pattern.

Advertisement

oks-ui team

We build oks-ui — a React component library in strict TypeScript, themed entirely through CSS custom properties, with no runtime dependencies beyond React. These posts are what we learned building it.

Read the docs · All posts

Was this post helpful?

ShareXLinkedIn