Unsaved Changes Bar

Names every unsaved field, warns before the tab closes, saves on Ctrl/⌘+S and asks twice before discarding.

StopsLeft page with unsaved edits

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/unsaved-changes-bar.json

The files land in your project. They're yours to edit.

Usage

unsaved-changes-bar-usage.tsx
import { UnsavedChangesBar, useUnsavedChanges } from "@/components/ui/unsaved-changes-bar"

<UnsavedChangesBar
  fields={dirty}
  labels={labels}
  onSave={() => new Promise<void>((resolve) => setTimeout(() => { setSaved(draft); resolve() }, 700))}
  onDiscard={() => setDraft(saved)}
  onRevertField={(key) => setDraft((d) => ({ ...d, [key]: saved[key as keyof Profile] }))}
  className="bottom-0"
/>

What it catches

  • Each changed field, by name
  • Fields edited back to their saved value (clean again, deep compare)
  • Closing the tab with unsaved edits (beforeunload)
  • Discard, which needs a second confirm
  • Save failures, shown as an error

Props

UnsavedChangesBar props
PropTypeDefault
fields* (required)

Dirty field keys, usually from `useUnsavedChanges`. The bar hides when empty.

string[]—
labels

Human labels per key, e.g. `{ email: "Email" }`. Falls back to the key itself.

Record<string, string>{}
onSave* (required)

Persist the changes. Return a promise to show a saving state; throw to keep the bar.

() => void | Promise<void>—
onDiscard* (required)

Throw away every change. Only called after the inline confirm.

() => void—
onRevertField

Revert one field. When given, each field chip becomes a one-tap undo.

(field: string) => void—
guardUnload

Warn before closing or reloading the tab while there are changes.

booleantrue
summary

Headline. Defaults to "3 unsaved changes".

string—
confirmDiscardLabel

Question shown with the discard confirm. Defaults to "Discard all 3?".

string—
saveLabel

Save button text.

string"Save"
savingLabel

Save button text while saving.

string"Saving…"
discardLabel

Discard button text.

string"Discard"
keepLabel

Text of the button that cancels a discard.

string"Keep editing"
errorLabel

Shown when `onSave` throws something that isn't an `Error`.

string"Couldn't save. Your changes are still here."
regionLabel

Accessible name of the bar.

string"Unsaved changes"
changedFieldsLabel

Accessible name of the changed-fields list.

string"Changed fields"
revertLabel

Accessible label and tooltip of a field's revert button.

(field: string) => string(field) => `Revert ${field}`
confirmGroupLabel

Accessible name of the discard confirmation group.

string"Confirm discard"

* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.

Accessibility

  • Region with an aria-label; changed fields list has an aria-label
  • Ctrl/⌘+S saves (aria-keyshortcuts); revert buttons have aria-labels
  • Discard confirm is a labelled group focused on Keep; Escape cancels
  • Errors use role=alert; status in a polite live region

Only what the source does. Test with your own screen reader and content before shipping.

Limitations

  • In-app navigation must be guarded by your router; only tab close is covered
  • Browsers show their own generic leave-page text

What it stops

Left page with unsaved edits

Also known as

unsaved changes · dirty form · beforeunload · save bar · ctrl+s · settings form · react hook form · discard changes

Dependencies

Pairs well with