Skip to content

Save bar

The sticky unsaved-changes bar with a change count, Discard, Save and ⌘S.

Status
Stable
Category
Feedback
Adoption
Not used yet
import { SaveBar } from "@oration/canon/components/save-bar";
packages/canon/src/components/save-bar.tsx
Suppliers reply here about payments.
Invoices approved after this go in tomorrow's run.
Invoices under $5,000 that match a PO skip review.
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

While the bar is showing, its Save button is the view's one filled indigo button. Page-level actions above it stay outline or ghost, and sections never carry their own filled Save.

The Quiet Indigo Rule

The 6px indigo dot is the unsaved-changes marker, one of the viewer's own markers the rule allows. Don't add other indigo to the bar.

The Tabular Figures Rule

The change count is set in tabular figures, so 9 unsaved changes becoming 10 unsaved changes doesn't shimmy.

Anatomy#

3 unsaved changes

  1. 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.
  2. Unsaved dot. A 6px Quiet Indigo dot, decorative.
  3. Message. 13px muted text, one line, truncated. The change count by default, or your message. A polite live region.
  4. Discard. A ghost button that resets the form to the saved values. Disabled while saving.
  5. Save. The filled button. Shows a spinner while saving and carries aria-keyshortcuts.
  6. Shortcut hint. A Kbd reading ⌘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.

Printed on every remittance.
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.

Days before a new supplier's first payment can go out.
Nudge the approver a day before the cutoff.
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.

VP of Revenue
Controller
AP lead
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.

Supplier replies to remittance emails land here.
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#

Unsaved

2 unsaved changes

Saving

2 unsaved changes

Save blocked

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>    );}
States
StateTreatment
HiddenWhile dirty is false nothing renders, and the shortcut and leave warning are off.
UnsavedThe bar is up with the count, Discard and Save. ⌘S saves and closing the tab asks first.
Savingsaving puts a 14px spinner before the Save label and disables both buttons and the shortcut.
Save blockedsaveDisabled dims Save and turns the shortcut off. Discard still works. Say what blocks it in message.
EnteringSprings up from 12px below at 98% scale on the slow spring (240ms, light bounce).
LeavingDrops 8px and fades over a 160ms ease-out tween, faster than it came.

Behavior#

  • Visibility follows dirty alone. With useDirtyForm, pass form.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.
  • changes drives the message: 1 reads 1 unsaved change, more reads 3 unsaved changes, and 0 or nothing reads You have unsaved changes. message replaces it entirely.
  • ⌘S (Ctrl+S off Apple platforms) calls onSave and suppresses the browser's save dialog, but only while the bar can save: dirty, not saving and not saveDisabled. Shift and Alt variants are ignored.
  • With warnOnLeave, a beforeunload handler asks before the tab closes or reloads while dirty. It does not intercept in-app navigation.
  • useDirtyForm(initial, { onSave }) holds values and a saved baseline. save() sets saving, awaits onSave(values), and moves the baseline only if it resolves; throw or reject to keep the form dirty. It returns true or false.
  • reset() puts the values back to the baseline; replace(next) swaps both, for when data finishes loading. Values are compared with Object.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

Do. Say how many fields changed, in plain words: 3 unsaved changes.

Warning: you have unsaved data!

Don't. Alarm people with a vague message such as Warning: you have unsaved data!
Remittance emails
Approval reminders

2 unsaved changes

Do. Use one save bar for the whole page, under the last section, so one save commits every section.
Remittance emails
Approval reminders
Don't. Put a filled Save button in each section. People save one, leave, and lose the edits in the other.

Enter a full email address to save

Do. When something blocks saving, disable Save and name the fix in the message: Enter a full email address to save.

1 unsaved change

Don't. Disable Save with the default count still showing, so nobody knows why it won't save.

Content#

  • Leave the default count in most cases. It is specific, short and tabular.
  • A custom message says 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+S or Control+S). The visible key hint is aria-hidden so 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.
Keyboard interactions
KeysAction
⌘SSaves while the bar is showing and can save. Ctrl+S off Apple platforms.
TabMoves from the last field to Discard, then Save.
EnterActivates the focused button.

Design tokens#

Design tokens
TokenUsed for
--popoverBar surface
--popover-foregroundBar text
shadow-popoverThe bar's edge and lift
--radius-xl12px corners
--primaryUnsaved dot and the Save fill
--primary-foregroundSave label and the key hint at 15% fill, 80% text
--muted-foregroundMessage text
spring.slow / exit.slowEnter 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.

Props of SaveBar
PropTypeDefaultDescription
dirtyRequiredbooleanNo defaultShows the bar and arms the shortcut and leave warning. Pass form.isDirty.
onSaveRequired() => voidNo defaultRuns on Save and on ⌘S. Usually () => void form.save().
onDiscardRequired() => voidNo defaultRuns on Discard. Usually form.reset.
changesnumberNo defaultNumber of changed fields, shown as 3 unsaved changes. Pass form.dirtyCount.
messageReact.ReactNodeNo defaultReplaces the change count.
savingbooleanfalseShows the spinner and disables both buttons and the shortcut.
saveDisabledbooleanNo defaultDisables Save and the shortcut. Discard stays enabled.
saveLabelReact.ReactNode"Save changes"The Save button's label.
discardLabelReact.ReactNode"Discard"The Discard button's label.
shortcutbooleantrueSaves with ⌘S or Ctrl+S and shows the key hint.
warnOnLeavebooleantrueAsks before closing or reloading the tab while dirty.
classNamestringNo defaultClasses 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.

Props of useDirtyForm
PropTypeDefaultDescription
initialRequiredT extends Record<string, unknown>No defaultThe starting values and the first baseline. Read once on mount.
options.onSave(values: T) => void | Promise<void>No defaultRuns on save(). Throw or reject to keep the form dirty.

useDirtyForm return value

Also exported: the DirtyForm<T> type.

Props of useDirtyForm return value
PropTypeDefaultDescription
valuesTNo defaultThe current values.
savedTNo defaultThe last saved baseline.
set(key: K, value: T[K] | ((current: T[K]) => T[K])) => voidNo defaultSets one field, with a value or an updater.
setValuesReact.Dispatch<React.SetStateAction<T>>No defaultReplaces all values without touching the baseline.
reset() => voidNo defaultPuts the values back to the baseline. Wire to Discard.
replace(next: T) => voidNo defaultReplaces values and baseline together, e.g. after a load.
save() => Promise<boolean>No defaultRuns onSave and moves the baseline on success. Resolves false on failure.
savingbooleanNo defaultTrue while onSave is pending.
dirtyKeysArray<keyof T>No defaultKeys whose value differs from the baseline.
dirtyCountnumberNo defaultdirtyKeys.length. Pass to changes.
isDirtybooleanNo defaultTrue when any field differs. Pass to dirty.

changedKeys

changedKeys(saved, values), the comparison useDirtyForm uses, for forms that keep their own state.

Props of changedKeys
PropTypeDefaultDescription
savedRequiredTNo defaultThe baseline snapshot.
valuesRequiredTNo defaultThe 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.