Skip to content

Pattern

Settings panel

A page of configuration split into sections. The whole pattern turns on one question — does a change apply immediately, or when the person saves — and on never letting those two kinds sit together without saying so.

Built fromFormFieldInputSelectCheckboxSwitchButton

The patternPermalink to this section

Change a field to see the save bar wake up, then toggle a switch and notice it commits on its own.

General

Applied when you save.

Shown in the sidebar and in deployment logs.

Moving regions triggers a full redeploy.

Email notifications

Sent every Monday at 09:00 in your timezone.

Sent as they happen.

Security

Applies immediately

These take effect the moment you toggle them.

Require a verification code at every sign-in.

Anyone with the link can see your name, role and avatar.

Two kinds of settingPermalink to this section

Mixing them without a signal is the single most confusing thing a settings page can do.

  • Deferred — text fields, selects and checkboxes. Nothing happens until Save, and the save bar shows there is something pending.
  • Immediate — switches. The change commits on toggle and is confirmed in place, with no Save button involved.
  • Never put a switch inside a section that has a Save button, or a checkbox in one that does not.
  • When both kinds appear on one page, separate them into sections and label the immediate one.

The failure mode

A switch above a Save button leaves people unsure whether their change took effect — so they press Save anyway, and if it fails they cannot tell what was and was not applied.

Unsaved-change feedbackPermalink to this section

The save bar is the only place the state of the form is reported, so it has to be honest about all four states.

No changesSave disabled, bar quiet.
Unsaved changesSave enabled, discard offered.
Saving…Button shows a spinner and blocks a second submit.
All changes savedConfirmed, then the bar goes quiet again.

BehaviorPermalink to this section

  • The save bar sticks to the bottom of the viewport, so it is reachable from anywhere in a long page.
  • Discard restores the last saved values and is only enabled when there is something to discard.
  • Warn before navigating away with unsaved changes — a beforeunload handler plus a router guard.
  • Report save failures next to the save bar, keeping the edited values in place so nothing is retyped.
  • Announce the result through a role="status" region rather than a toast that may be missed.
  • Group settings by what they affect, not by control type, and give each group a heading.
  • Put destructive settings in their own section at the bottom, behind a confirmation dialog.

Do and don'tPermalink to this section

SecurityApplies immediately

Dosay which section applies immediately

One badge removes all the ambiguity about whether Save is needed.

Don'tmix switches and Save

The switch already committed. The Save button suggests otherwise, and there is no way to tell which is true.

Unsaved changes

Dokeep Save reachable

A sticky bar means someone editing a field near the top does not have to scroll to the bottom to commit it.

Don'tauto-save silently

If a field saves as it is typed, say so and show when it happened. Silent persistence leaves people unsure whether to wait.

Code examplePermalink to this section

app/settings/settings-form.tsx
"use client";

import { Button, FormField, Input } from "@borneo/react";
import { useEffect, useState } from "react";

export function SettingsForm({ initial, onSave }) {
  const [saved, setSaved] = useState(initial);
  const [draft, setDraft] = useState(initial);
  const [status, setStatus] = useState("idle");

  const dirty = JSON.stringify(saved) !== JSON.stringify(draft);

  // Browsers only show their own generic message here, but the
  // prompt itself is what prevents losing the edit.
  useEffect(() => {
    if (!dirty) return;
    const warn = (event) => event.preventDefault();
    window.addEventListener("beforeunload", warn);
    return () => window.removeEventListener("beforeunload", warn);
  }, [dirty]);

  async function save() {
    setStatus("saving");
    await onSave(draft);
    setSaved(draft);
    setStatus("saved");
  }

  return (
    <form onSubmit={(event) => { event.preventDefault(); save(); }}>
      <FormField id="workspace-name" label="Workspace name">
        {(field) => (
          <Input
            {...field}
            value={draft.name}
            onChange={(event) =>
              setDraft({ ...draft, name: event.target.value })
            }
          />
        )}
      </FormField>

      <div className="sticky bottom-0 flex items-center justify-between border-t py-4">
        <p role="status" className="type-caption text-ink-muted">
          {status === "saving"
            ? "Saving…"
            : dirty
              ? "You have unsaved changes."
              : "All changes saved."}
        </p>

        <div className="flex gap-2">
          <Button type="button" variant="ghost" disabled={!dirty}
            onClick={() => setDraft(saved)}>
            Discard
          </Button>
          <Button type="submit" disabled={!dirty} loading={status === "saving"}>
            Save changes
          </Button>
        </div>
      </div>
    </form>
  );
}

Responsive behaviorPermalink to this section

Settings are a single column at every width — a two-column settings form makes the reading order ambiguous. Below 640px the save bar stacks its status above the buttons.

General

Applied when you save.

Shown in the sidebar and in deployment logs.

Moving regions triggers a full redeploy.

Email notifications

Sent every Monday at 09:00 in your timezone.

Sent as they happen.

Security

Applies immediately

These take effect the moment you toggle them.

Require a verification code at every sign-in.

Anyone with the link can see your name, role and avatar.

AccessibilityPermalink to this section

  • Each section is a real section with a heading, so the page can be navigated by heading.
  • Related checkboxes are wrapped in a fieldset with a legend naming the group.
  • Save state is announced through role="status", so “Saving…” and “All changes saved” are perceivable.
  • The Save button is disabled only when there is genuinely nothing to save, never as a validation signal.
  • The sticky save bar never covers the last field — the form reserves space for its height.
  • Immediate settings are labelled as such in text, not only by the control type.
  • Every field is rendered through FormField, so labels and descriptions are wired consistently.