Save bar
The sticky unsaved-changes bar with a change count, Discard, Save and ⌘S.
import { Input } from "@oration/canon/components/input";import { SaveBar } from "@oration/canon/components/save-bar";import { SettingsSelect } from "@oration/canon/components/select-field";import { descriptionId, SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import * as React from "react";export function Hero() { const id = React.useId(); const form = useDirtyForm( { remitEmail: "ap@cedarline.com", cutoff: "14:00", autoApprove: true }, { onSave: async () => { await new Promise((resolve) => setTimeout(resolve, 800)); toast.add({ type: "success", title: "Changes saved" }); }, }, ); const { set } = form; // Start with one unsaved change so the bar is showing. React.useEffect(() => { set("cutoff", "15:00"); }, [set]); return ( <div className="relative h-80 w-full max-w-xl overflow-y-auto rounded-xl bg-background p-4 text-left shadow-border"> <SettingsGroup> <SettingsRow label="Remittance email" htmlFor={`${id}-email`} description="Suppliers reply here about payments." > <Input id={`${id}-email`} value={form.values.remitEmail} onChange={(event) => form.set("remitEmail", event.target.value) } aria-describedby={descriptionId(`${id}-email`)} className="sm:w-56" /> </SettingsRow> <SettingsRow label="Daily cutoff" htmlFor={`${id}-cutoff`} description="Invoices approved after this go in tomorrow's run." > <SettingsSelect id={`${id}-cutoff`} value={form.values.cutoff} onValueChange={(cutoff) => form.set("cutoff", cutoff)} describedBy={descriptionId(`${id}-cutoff`)} options={[ { value: "12:00", label: "12:00 PM CT" }, { value: "14:00", label: "2:00 PM CT" }, { value: "15:00", label: "3:00 PM CT" }, { value: "17:00", label: "5:00 PM CT" }, ]} className="sm:w-56" /> </SettingsRow> <SettingsRow inline label="Auto-approve small invoices" htmlFor={`${id}-auto`} description="Invoices under $5,000 that match a PO skip review." > <Switch id={`${id}-auto`} checked={form.values.autoApprove} onCheckedChange={(autoApprove) => form.set("autoApprove", autoApprove) } aria-describedby={descriptionId(`${id}-auto`)} /> </SettingsRow> </SettingsGroup> <SaveBar dirty={form.isDirty} changes={form.dirtyCount} saving={form.saving} onDiscard={form.reset} onSave={() => void form.save()} // Off only because this demo starts dirty and would ask before you // leave the docs. Keep the default in the product. warnOnLeave={false} /> </div> );}Usage#
Save bar is the sticky unsaved-changes bar for settings and configuration pages. It springs up from the bottom of the page column the moment the form differs from what was saved, says how many fields changed, and offers Discard, Save and ⌘S. Pair it with useDirtyForm, which keeps the saved baseline and the change count for you. The common mistake is wiring dirty to "the user typed something" instead of "the values differ from the last save", so the bar stays up after someone types a value back to what it was.
When to use
- On a settings or configuration page where several fields are edited and committed together: payment run defaults, remittance settings, an agent's details.
- When a page spans several sections and one save should commit all of them.
- When leaving with unsaved edits would lose work, so the tab-close warning and ⌘S are worth having.
When not to use
- For a single setting that applies the moment it changes. Save it in place and confirm with a toast. Use Switch
- For a short form in a dialog. The dialog footer is the save action. Use Form dialog
- For actions on selected rows in a table or list. Use Action bar
- For one inline value, such as a supplier name in a record header. Use Editable text
- For the saving model of a whole page, including autosave and drafts. Read the pattern first. Use Saving and unsaved changes
The One Filled Button Rule
The Quiet Indigo Rule
The Tabular Figures Rule
Anatomy#
3 unsaved changes
- Bar. A
<section aria-label="Unsaved changes">on the popover surface with 12px corners and the popover shadow. Sticky 16px above the bottom of the scroll container, plus the safe-area inset. - Unsaved dot. A 6px Quiet Indigo dot, decorative.
- Message. 13px muted text, one line, truncated. The change count by default, or your
message. A polite live region. - Discard. A ghost button that resets the form to the saved values. Disabled while saving.
- Save. The filled button. Shows a spinner while
savingand carriesaria-keyshortcuts. - Shortcut hint. A
Kbdreading ⌘S on Apple platforms and Ctrl S elsewhere, tinted to sit on the fill. Hidden below 640px.
Examples#
With useDirtyForm
useDirtyForm keeps the saved baseline, so the count follows what actually differs. Change two fields and the bar says 2; put one back and it says 1; put both back and it leaves.
import { Input } from "@oration/canon/components/input";import { SaveBar } from "@oration/canon/components/save-bar";import { SettingsSelect } from "@oration/canon/components/select-field";import { descriptionId, SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import * as React from "react";export function ChangeCount() { const id = React.useId(); const form = useDirtyForm( { payer: "Cedarline Inc.", terms: "net-30", approver: "maya", notify: true, }, { onSave: () => { toast.add({ type: "success", title: "Changes saved" }); }, }, ); return ( <div className="relative h-80 w-full max-w-xl overflow-y-auto rounded-xl bg-background p-4 shadow-border"> <SettingsGroup> <SettingsRow label="Payer name" htmlFor={`${id}-payer`} description="Printed on every remittance." > <Input id={`${id}-payer`} value={form.values.payer} onChange={(event) => form.set("payer", event.target.value) } aria-describedby={descriptionId(`${id}-payer`)} className="sm:w-56" /> </SettingsRow> <SettingsRow label="Default terms" htmlFor={`${id}-terms`}> <SettingsSelect id={`${id}-terms`} value={form.values.terms} onValueChange={(terms) => form.set("terms", terms)} options={[ { value: "net-15", label: "Net 15" }, { value: "net-30", label: "Net 30" }, { value: "net-45", label: "Net 45" }, ]} className="sm:w-56" /> </SettingsRow> <SettingsRow label="Final approver" htmlFor={`${id}-approver`}> <SettingsSelect id={`${id}-approver`} value={form.values.approver} onValueChange={(approver) => form.set("approver", approver) } options={[ { value: "maya", label: "Maya Okafor" }, { value: "priya", label: "Priya Raman" }, { value: "tomas", label: "Tomás Ferreira" }, ]} className="sm:w-56" /> </SettingsRow> <SettingsRow inline label="Email suppliers when paid" htmlFor={`${id}-notify`} > <Switch id={`${id}-notify`} checked={form.values.notify} onCheckedChange={(notify) => form.set("notify", notify)} /> </SettingsRow> </SettingsGroup> <SaveBar dirty={form.isDirty} changes={form.dirtyCount} saving={form.saving} onDiscard={form.reset} onSave={() => void form.save()} /> </div> );}Saving and a failed save
While onSave is pending the button shows a spinner and both buttons are disabled. Throw from onSave and the form stays dirty, so nothing is lost; say what happened in a toast.
import { Input } from "@oration/canon/components/input";import { SaveBar } from "@oration/canon/components/save-bar";import { descriptionId, SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import * as React from "react";export function SavingAndFailure() { const id = React.useId(); const [failNext, setFailNext] = React.useState(false); const form = useDirtyForm( { holdDays: "3", reminder: true }, { onSave: async () => { await new Promise((resolve) => setTimeout(resolve, 1200)); if (failNext) { setFailNext(false); toast.add({ type: "error", title: "Couldn't save changes", description: "Check your connection and try again.", }); throw new Error("Save failed"); } toast.add({ type: "success", title: "Changes saved" }); }, }, ); return ( <div className="flex w-full max-w-xl flex-col gap-3"> <div className="flex items-center gap-2.5"> <Switch id={`${id}-fail`} checked={failNext} onCheckedChange={setFailNext} /> <label htmlFor={`${id}-fail`} className="text-13 text-muted-foreground" > Make the next save fail </label> </div> <div className="relative h-72 overflow-y-auto rounded-xl bg-background p-4 shadow-border"> <SettingsGroup> <SettingsRow label="Hold new suppliers" htmlFor={`${id}-hold`} description="Days before a new supplier's first payment can go out." > <Input id={`${id}-hold`} inputMode="numeric" value={form.values.holdDays} onChange={(event) => form.set("holdDays", event.target.value) } aria-describedby={descriptionId(`${id}-hold`)} className="tabular-nums sm:w-24" /> </SettingsRow> <SettingsRow inline label="Remind approvers" htmlFor={`${id}-reminder`} description="Nudge the approver a day before the cutoff." > <Switch id={`${id}-reminder`} checked={form.values.reminder} onCheckedChange={(reminder) => form.set("reminder", reminder) } aria-describedby={descriptionId(`${id}-reminder`)} /> </SettingsRow> </SettingsGroup> <SaveBar dirty={form.isDirty} changes={form.dirtyCount} saving={form.saving} onDiscard={form.reset} onSave={() => void form.save()} /> </div> </div> );}A custom message and labels
When the save means more than storing fields, say what it does with message, and name the commit with saveLabel and discardLabel.
import { SaveBar } from "@oration/canon/components/save-bar";import { SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import * as React from "react";export function CustomMessage() { const id = React.useId(); const approvers = [ { key: "maya", name: "Maya Okafor", role: "VP of Revenue" }, { key: "priya", name: "Priya Raman", role: "Controller" }, { key: "jordan", name: "Jordan Lee", role: "AP lead" }, ] as const; const form = useDirtyForm( { maya: true, priya: true, jordan: false }, { onSave: () => { toast.add({ type: "success", title: "Approval chain updated", description: "Applies from the Friday, Oct 2 payment run.", }); }, }, ); return ( <div className="relative h-72 w-full max-w-xl overflow-y-auto rounded-xl bg-background p-4 shadow-border"> <SettingsGroup> {approvers.map((approver) => ( <SettingsRow key={approver.key} inline label={approver.name} description={approver.role} htmlFor={`${id}-${approver.key}`} > <Switch id={`${id}-${approver.key}`} checked={form.values[approver.key]} onCheckedChange={(on) => form.set(approver.key, on)} /> </SettingsRow> ))} </SettingsGroup> <SaveBar dirty={form.isDirty} message="Approval changes apply to the next payment run" saveLabel="Apply changes" discardLabel="Revert" saving={form.saving} onDiscard={form.reset} onSave={() => void form.save()} /> </div> );}Blocked by an invalid field
Pass saveDisabled while a field is invalid and use message to name the fix. ⌘S is off too. Discard still puts the last saved value back.
import { Input } from "@oration/canon/components/input";import { SaveBar } from "@oration/canon/components/save-bar";import { descriptionId, SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import * as React from "react";export function BlockedSave() { const id = React.useId(); const form = useDirtyForm( { replyTo: "ap@cedarline.com" }, { onSave: () => { toast.add({ type: "success", title: "Changes saved" }); }, }, ); const invalid = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.values.replyTo); return ( <div className="relative h-64 w-full max-w-xl overflow-y-auto rounded-xl bg-background p-4 shadow-border"> <SettingsGroup> <SettingsRow label="Reply-to address" htmlFor={`${id}-reply`} description={ invalid ? ( <span className="text-destructive"> Enter a full email address, like ap@cedarline.com. </span> ) : ( "Supplier replies to remittance emails land here." ) } > <Input id={`${id}-reply`} value={form.values.replyTo} onChange={(event) => form.set("replyTo", event.target.value) } aria-invalid={invalid || undefined} aria-describedby={descriptionId(`${id}-reply`)} className="sm:w-56" /> </SettingsRow> </SettingsGroup> <SaveBar dirty={form.isDirty} changes={form.dirtyCount} message={ invalid ? "Enter a full email address to save" : undefined } saveDisabled={invalid} saving={form.saving} onDiscard={form.reset} onSave={() => void form.save()} /> </div> );}States#
2 unsaved changes
2 unsaved changes
Fix the overlapping hours to save
import { SaveBar } from "@oration/canon/components/save-bar";import { toast } from "@oration/canon/components/toast";export function StatesMatrix() { const rows = [ { label: "Unsaved", saving: false, blocked: false }, { label: "Saving", saving: true, blocked: false }, { label: "Save blocked", saving: false, blocked: true }, ]; return ( <div className="grid w-full max-w-xl grid-cols-[6rem_minmax(0,1fr)] items-center gap-x-4 gap-y-3"> {rows.map((row) => ( <div key={row.label} className="contents"> <span className="text-13 text-muted-foreground"> {row.label} </span> <div> <SaveBar dirty changes={2} message={ row.blocked ? "Fix the overlapping hours to save" : undefined } saving={row.saving} saveDisabled={row.blocked} shortcut={false} warnOnLeave={false} className="mt-0" onDiscard={() => toast.add({ title: "Changes discarded" }) } onSave={() => toast.add({ type: "success", title: "Changes saved", }) } /> </div> </div> ))} </div> );}| State | Treatment |
|---|---|
| Hidden | While dirty is false nothing renders, and the shortcut and leave warning are off. |
| Unsaved | The bar is up with the count, Discard and Save. ⌘S saves and closing the tab asks first. |
| Saving | saving puts a 14px spinner before the Save label and disables both buttons and the shortcut. |
| Save blocked | saveDisabled dims Save and turns the shortcut off. Discard still works. Say what blocks it in message. |
| Entering | Springs up from 12px below at 98% scale on the slow spring (240ms, light bounce). |
| Leaving | Drops 8px and fades over a 160ms ease-out tween, faster than it came. |
Behavior#
- Visibility follows
dirtyalone. WithuseDirtyForm, passform.isDirty, which is true only while a field differs from the last saved snapshot, so typing a value back to what it was hides the bar again. changesdrives the message: 1 reads 1 unsaved change, more reads 3 unsaved changes, and 0 or nothing reads You have unsaved changes.messagereplaces it entirely.- ⌘S (Ctrl+S off Apple platforms) calls
onSaveand suppresses the browser's save dialog, but only while the bar can save: dirty, not saving and notsaveDisabled. Shift and Alt variants are ignored. - With
warnOnLeave, abeforeunloadhandler asks before the tab closes or reloads while dirty. It does not intercept in-app navigation. useDirtyForm(initial, { onSave })holdsvaluesand asavedbaseline.save()setssaving, awaitsonSave(values), and moves the baseline only if it resolves; throw or reject to keep the form dirty. It returnstrueorfalse.reset()puts the values back to the baseline;replace(next)swaps both, for when data finishes loading. Values are compared withObject.is, then by JSON for objects and arrays.- The bar is sticky, so it rides at the bottom of the nearest scrolling ancestor while the page scrolls and settles after the last section at the end. Render it last inside the page column.
Do and don't#
3 unsaved changes
Warning: you have unsaved data!
2 unsaved changes
Enter a full email address to save
1 unsaved change
Content#
- Leave the default count in most cases. It is specific, short and tabular.
- A custom
messagesays what will happen or what blocks the save, in one short sentence without a period: Approval changes apply to the next payment run. - Keep Save changes and Discard unless the commit means something more specific, such as Publish for a versioned procedure or Apply to 212 invoices.
- Confirm a successful save with a short toast, Changes saved. Report a failure with what to do: Couldn't save changes and Check your connection and try again.
Accessibility#
- The bar is a labelled region, Unsaved changes, so screen reader users can jump to it from the landmarks list.
- The message is
aria-live="polite", so a changing count is announced without stealing focus. - The bar never takes focus when it appears. It sits after the form in source order, so Tab reaches Discard and Save after the last field.
- Save carries
aria-keyshortcuts(Meta+SorControl+S). The visible key hint isaria-hiddenso the name stays Save changes. - While saving, the spinner is hidden from assistive tech and both buttons are disabled. Announce the result with a toast.
- Labels on both buttons are real text at the 32px control height.
| Keys | Action |
|---|---|
| ⌘S | Saves while the bar is showing and can save. Ctrl+S off Apple platforms. |
| Tab | Moves from the last field to Discard, then Save. |
| Enter | Activates the focused button. |
Design tokens#
| Token | Used for |
|---|---|
--popover | Bar surface |
--popover-foreground | Bar text |
shadow-popover | The bar's edge and lift |
--radius-xl | 12px corners |
--primary | Unsaved dot and the Save fill |
--primary-foreground | Save label and the key hint at 15% fill, 80% text |
--muted-foreground | Message text |
spring.slow / exit.slow | Enter spring (240ms, bounce 0.12) and 160ms exit tween |
API reference#
SaveBar
The sticky bar. It takes no other props; className lands on the sticky wrapper.
| Prop | Type | Default | Description |
|---|---|---|---|
dirtyRequired | boolean | No default | Shows the bar and arms the shortcut and leave warning. Pass form.isDirty. |
onSaveRequired | () => void | No default | Runs on Save and on ⌘S. Usually () => void form.save(). |
onDiscardRequired | () => void | No default | Runs on Discard. Usually form.reset. |
changes | number | No default | Number of changed fields, shown as 3 unsaved changes. Pass form.dirtyCount. |
message | React.ReactNode | No default | Replaces the change count. |
saving | boolean | false | Shows the spinner and disables both buttons and the shortcut. |
saveDisabled | boolean | No default | Disables Save and the shortcut. Discard stays enabled. |
saveLabel | React.ReactNode | "Save changes" | The Save button's label. |
discardLabel | React.ReactNode | "Discard" | The Discard button's label. |
shortcut | boolean | true | Saves with ⌘S or Ctrl+S and shows the key hint. |
warnOnLeave | boolean | true | Asks before closing or reloading the tab while dirty. |
className | string | No default | Classes for the sticky wrapper, such as a different bottom-*. |
useDirtyForm
useDirtyForm(initial, options?) from @oration/canon/hooks/use-dirty-form. Local form state with a saved baseline.
| Prop | Type | Default | Description |
|---|---|---|---|
initialRequired | T extends Record<string, unknown> | No default | The starting values and the first baseline. Read once on mount. |
options.onSave | (values: T) => void | Promise<void> | No default | Runs on save(). Throw or reject to keep the form dirty. |
useDirtyForm return value
Also exported: the DirtyForm<T> type.
| Prop | Type | Default | Description |
|---|---|---|---|
values | T | No default | The current values. |
saved | T | No default | The last saved baseline. |
set | (key: K, value: T[K] | ((current: T[K]) => T[K])) => void | No default | Sets one field, with a value or an updater. |
setValues | React.Dispatch<React.SetStateAction<T>> | No default | Replaces all values without touching the baseline. |
reset | () => void | No default | Puts the values back to the baseline. Wire to Discard. |
replace | (next: T) => void | No default | Replaces values and baseline together, e.g. after a load. |
save | () => Promise<boolean> | No default | Runs onSave and moves the baseline on success. Resolves false on failure. |
saving | boolean | No default | True while onSave is pending. |
dirtyKeys | Array<keyof T> | No default | Keys whose value differs from the baseline. |
dirtyCount | number | No default | dirtyKeys.length. Pass to changes. |
isDirty | boolean | No default | True when any field differs. Pass to dirty. |
changedKeys
changedKeys(saved, values), the comparison useDirtyForm uses, for forms that keep their own state.
| Prop | Type | Default | Description |
|---|---|---|---|
savedRequired | T | No default | The baseline snapshot. |
valuesRequired | T | No default | The current snapshot. Returns the keys that differ. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The ⌘S listener is on window. Two dirty save bars on one screen both save on one keypress, so keep one per page.
warnOnLeave only covers closing or reloading the tab. Following an in-app link with unsaved edits loses them without asking.
While saving, the spinner is added before the label rather than over it, so Save widens by the spinner's width. Pending button keeps the width steady.
The message's live region mounts with its text already in it, so most screen readers don't announce the bar's arrival, only later count changes.
The enter spring and exit tween have no reduced-motion check of their own. The app-wide MotionConfig reducedMotion="user" drops the rise and scale; the fade still plays.
The key hint reads ⌘S on first render everywhere and switches to Ctrl S after mount on other platforms.
A failed save is silent: save() swallows the error and returns false. Show the failure from onSave yourself.