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.
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.
Security
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.
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
Do — say which section applies immediately
One badge removes all the ambiguity about whether Save is needed.
Don't — mix switches and Save
The switch already committed. The Save button suggests otherwise, and there is no way to tell which is true.
Do — keep 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't — auto-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
"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.
Security
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.