# Save bar

> An "Unsaved changes" bar with Discard and Save that only renders when there is something to save.

```tsx
"use client"

import * as React from "react"

import { SaveBar } from "@/components/ui/save-bar"
import { Input } from "@/components/ui/input"

// Fictional data. Nothing here is saved anywhere.
export function SaveBarDemo() {
  const [saved, setSaved] = React.useState("Weekly staff training")
  const [name, setName] = React.useState(saved)
  const [saving, setSaving] = React.useState(false)

  return (
    <div className="w-full max-w-xl overflow-hidden rounded-xl border">
      <div className="flex flex-col gap-2 p-4 sm:p-6">
        <label htmlFor="save-bar-demo-name" className="text-sm font-medium">
          Report name
        </label>
        <Input
          id="save-bar-demo-name"
          value={name}
          disabled={saving}
          onChange={(event) => setName(event.target.value)}
        />
      </div>
      <SaveBar
        dirty={name !== saved}
        saving={saving}
        onDiscard={() => setName(saved)}
        onSave={async () => {
          setSaving(true)
          await new Promise((r) => setTimeout(r, 400))
          setSaved(name)
          setSaving(false)
        }}
      />
    </div>
  )
}
```

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

**Command**

```bash
npx shadcn@latest add https://realgood.site/r/save-bar.json
```

Or, with the [`@crisp` namespace](https://realgood.site/docs/installation.md) set up:

```bash
npx shadcn@latest add @crisp/save-bar
```

This also adds the shadcn `button`.

**Manual**

**Step 1.** Copy and paste the following code into your project.

```tsx title="components/ui/save-bar.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

export interface SaveBarProps extends React.ComponentProps<"div"> {
  /** The bar renders nothing until there is something to save. */
  dirty: boolean
  /** Disables both buttons and shows "Saving…". */
  saving?: boolean
  /** Called when Save is pressed. */
  onSave: () => void
  /** Called when Discard is pressed. */
  onDiscard: () => void
}

/**
 * A live region only announces changes made after it is in the page, so the
 * message is filled in one render after the bar mounts.
 */
function Announce({ children }: { children: string }) {
  const [message, setMessage] = React.useState("")
  React.useEffect(() => {
    // After mount, in a callback: the region must exist empty first.
    const timer = setTimeout(() => setMessage(children), 0)
    return () => clearTimeout(timer)
  }, [children])
  return (
    <span role="status" className="sr-only">
      {message}
    </span>
  )
}

function SaveBar({
  dirty,
  saving = false,
  onSave,
  onDiscard,
  className,
  ...props
}: SaveBarProps) {
  if (!dirty) return null
  return (
    <div
      role="region"
      aria-label="Unsaved changes"
      data-slot="save-bar"
      className={cn(
        "flex flex-wrap items-center justify-between gap-3 border-t bg-muted/60 px-4 py-3 sm:px-6",
        className
      )}
      {...props}
    >
      <span className="text-sm" aria-hidden="true">
        Unsaved changes
      </span>
      <Announce>{saving ? "Saving changes…" : "Unsaved changes"}</Announce>
      <div className="flex gap-2">
        <Button variant="outline" onClick={onDiscard} disabled={saving}>
          Discard
        </Button>
        <Button onClick={onSave} disabled={saving}>
          {saving ? "Saving…" : "Save"}
        </Button>
      </div>
    </div>
  )
}

export { SaveBar }
```

**Step 2.** Add the shadcn `button`. Update the import paths to match your project setup.

## Usage

```tsx
import { SaveBar } from "@/components/ui/save-bar"
```

```tsx
<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, `sameList` in [`notify-envelope`](https://realgood.site/docs/components/notify-envelope.md) ignores 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 `saving` is 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 pass `dirty`.
- **It does not validate.** It cannot be switched off for an invalid form, so check in `onSave` and 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.

```text
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`](https://realgood.site/docs/components/recipient-roster.md), [`alert-rules`](https://realgood.site/docs/components/alert-rules.md). Leads to: [`confirm-send`](https://realgood.site/docs/components/confirm-send.md).
