An "Unsaved changes" bar with Discard and Save that only renders when there is something to save.
Build this with your agent
Copy a ready prompt for Claude Code, Cursor or any coding agent.
"use client"
import * as React from "react"Use it under any form or list that is edited in place and saved as a whole. When nothing has changed the bar is not there at all, so a clean screen has no save controls to ignore. When something has, it says so and offers exactly two ways out: Save or Discard.
Installation#
pnpm dlx shadcn@latest add https://realgood.site/r/save-bar.json
Or, with the @crisp namespace set up:
pnpm dlx shadcn@latest add @crisp/save-bar
This also adds the shadcn button.
Usage#
import { SaveBar } from "@/components/ui/save-bar"<SaveBar
dirty={name !== saved.name}
saving={saving}
onDiscard={() => setName(saved.name)}
onSave={save}
/>Notes#
- You decide what is dirty. The bar only reads
dirty. Compare the edited value with the saved one. For lists of addresses,sameListinnotify-envelopeignores order, case and duplicates, so reordering is not a change worth saving. - Dirty must go back to false after a save. Update the saved value once the write succeeds. If it fails, leave it, and the bar stays so the person can try again.
- Saving. While
savingis true both buttons are disabled and Save reads "Saving…". - Placement. It is a plain bar with a top border and a muted background, meant to sit at the bottom of a card. It is not sticky or fixed.
- Announcement. The bar is a labelled
region, and a hidden live region announces "Unsaved changes" when it appears and "Saving changes…" while it saves. - Focus. After Save or Discard the bar unmounts, and so does the button that had focus. The bar cannot know where focus should go, so move it yourself, for example to the field or heading the person was editing.
- Fixed wording. The text, "Discard" and "Save" are not configurable. Copy the file if you need different words.
What it does not do#
- It does not save. Save calls your
onSave. It also does not know what is dirty: you passdirty. - It does not validate. It cannot be switched off for an invalid form, so check in
onSaveand show your own message. - It has no autosave, undo or leave-page warning, and it is not sticky or fixed. It is a plain bar for the bottom of a card.
- Its wording is fixed. "Unsaved changes", Discard and Save are not configurable. Copy the file if you need other words.
- It is not a record. It does not log that a save happened, and it makes no compliance claim. Your server records changes.
Props#
SaveBar#
Also accepts the props of a div, including className and ref. The prop types are exported as SaveBarProps.
| Prop | Type | Description |
|---|---|---|
dirty | boolean | The bar renders nothing while this is false. Required. |
onSave | () => void | Called when Save is pressed. Required. |
onDiscard | () => void | Called when Discard is pressed. Required. |
saving | boolean | Disables both buttons and shows "Saving…". Default false. |
Agent prompt#
Paste this into your coding agent (Claude Code, Cursor, Codex or similar) in your project. Replace the bracketed parts with your own.
Goal: add an "Unsaved changes" bar with Discard and Save under my [form or list], shown only when something has changed.
Install: npx shadcn@latest add https://realgood.site/r/save-bar.json
Read the installed files (components/ui/save-bar.tsx) before writing any code. Do not guess props.
Contract:
<SaveBar dirty={boolean} onSave={() => void} onDiscard={() => void} saving?={boolean} /> // also takes div props, e.g. className
It renders nothing while dirty is false. The wording is fixed: "Unsaved changes", Discard, Save.
Wiring rule: I decide what is dirty. Keep `saved` and the edited value in state and pass dirty={!same(edited, saved)}. onDiscard resets the edited value to `saved`. onSave calls my own API, sets `saving` around it, and updates `saved` ONLY after the write succeeds. On failure leave `saved` alone so the bar stays, and show my own error. Validate in onSave, because the bar cannot be switched off. After Save or Discard the bar unmounts, so move focus myself to the field or heading the person was editing.
States to handle: clean (no bar), dirty, saving (both buttons disabled, "Saving…"), save failed (still dirty, error shown), discarded.
Acceptance checks (run them and show me the output):
1. Typecheck and lint pass.
2. A clean form renders no bar and no save controls.
3. Editing shows the bar, and changing the value back to the saved one hides it.
4. A failed save keeps the bar and the edits. A successful save hides it.
5. After Save or Discard, focus lands on a sensible element, not on the page body.
UI only: it does not save, validate, autosave or check permissions, and it makes no compliance claim. No new dependencies or abstractions beyond this. If something is unclear, ask me.Examples by sector#
Example data only, to show the wording. These are not claims about any real organisation, and the component proves nothing about a sector's rules. The pattern is the same in each; only the data changes.
- Education. The list of staff addresses that get training reminders. This pattern fits any form edited in place and saved as a whole, so the sector matters little.
- Manufacturing. Days between calibrations for each kind of gauge.
- Engineering. The default reviewers for a change order.
- Health. How many days before expiry a licence reminder goes out.
Next#
Comes after: recipient-roster, alert-rules. Leads to: confirm-send.