09 / Controls
Save Bar
A bar that exists only while a form has unsaved changes. It says so, saves, confirms, and leaves.
Project settings
Change something to bring the bar up.
"use client"
import * as React from "react"
import { SaveBar } from "mischief-ui/save-bar"
const initial = { name: "Field notes", mentions: true }
const failure = "The settings service did not answer."
export function SaveBarDemo() {
const [saved, setSaved] = React.useState(initial)
const [draft, setDraft] = React.useState(initial)
const [fail, setFail] = React.useState(false)
const dirty = draft.name !== saved.name || draft.mentions !== saved.mentions
return (
<div className="bg-background border-border relative w-full max-w-[32rem] overflow-hidden rounded-[1.25rem] border px-6 pt-6 pb-24 shadow-sm">
<h3 className="text-lg font-semibold">Project settings</h3>
<p className="text-muted-foreground mt-1 text-sm">
Change something to bring the bar up.
</p>
<div className="mt-6 grid gap-5">
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="save-bar-demo-name">
Project name
</label>
<input
className="border-border bg-background focus-visible:ring-ring min-h-9 rounded-md border px-3 text-sm focus-visible:ring-2 focus-visible:outline-none"
id="save-bar-demo-name"
onChange={(event) =>
setDraft((current) => ({ ...current, name: event.target.value }))
}
value={draft.name}
/>
</div>
<label className="flex items-center gap-2.5 text-sm">
<input
checked={draft.mentions}
className="accent-primary size-4"
onChange={(event) =>
setDraft((current) => ({
...current,
mentions: event.target.checked,
}))
}
type="checkbox"
/>
Email me when someone mentions this project
</label>
<label className="text-muted-foreground flex items-center gap-2.5 text-xs">
<input
checked={fail}
className="accent-destructive size-3.5"
onChange={(event) => setFail(event.target.checked)}
type="checkbox"
/>
Make the next save fail
</label>
</div>
<SaveBar
className="absolute inset-x-4 bottom-4 max-w-none"
dirty={dirty}
errorMessage={failure}
onReset={() => setDraft(saved)}
onSave={async () => {
await new Promise((resolve) => setTimeout(resolve, 900))
if (fail) throw new Error(failure)
setSaved(draft)
}}
warnOnLeave={false}
/>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/save-barimport { SaveBar } from "mischief-ui/save-bar"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react" import { cn } from "@/lib/utils" export type SaveBarState = "clean" | "dirty" | "saving" | "saved" | "error" export interface SaveBarProps extends Omit< React.HTMLAttributes<HTMLDivElement>, "children"> { /** Whether the form is holding changes nobody has saved yet. */ dirty: booleanUsage
export function Settings() {
const form = useSettingsForm()
return (
<>
<SettingsFields form={form} />
<SaveBar
dirty={form.dirty}
onSave={() => form.submit()}
onReset={() => form.reset()}
/>
</>
)
}What it asks of the form
Two things. dirty says whether there is anything to save, and onSave does it. The bar keeps no copy of your values, holds no opinion about validation, and has no state you have to keep in step with your own.
<SaveBar
dirty={form.formState.isDirty}
onSave={form.handleSubmit(save)}
onReset={() => form.reset()}
/>onSave may return a promise. While it is pending the bar says it is saving and both buttons go unavailable. When it resolves the check is drawn, and the bar drops away once dirty turns false. A form that is still dirty after a save keeps its bar, which is the right outcome when the write did not take.
When the save does not land
A rejection is the failed state. The bar holds its place, the message changes, and Save comes back as Try again, so the changes are still on screen and still recoverable. onSaveError hands you the rejection itself.
<SaveBar
dirty={dirty}
errorMessage="The settings service did not answer."
onSave={save}
onSaveError={(error) => report(error)}
/>The failure is kept only while there is still something to retry. If the form goes clean, by a reset or by anything else, the message leaves with the bar rather than outliving what it was about.
The shortcut and the reload
Cmd+S and Ctrl+S save while there is something to save, and do nothing while there is not, so a page with no unsaved work leaves the key to the browser. Escape is deliberately not bound: it already belongs to whatever dialog or menu is open over the form.
warnOnLeave is on by default. A bar that reports unsaved changes and then lets the tab close without a word is not telling the truth. Turn it off where the changes survive a reload on their own, or inside a preview like the one above.
API
dirtybooleanWhether the form holds unsaved changes. The bar appears for this and nothing else.onSave() => void | Promise<void>Runs the save. A rejection is the failed state.onReset() => voidDiscards the changes. Omit it and no Reset button is drawn.onSaveError(error: unknown) => voidReceives the rejection, for logging or a more specific message.message, savingMessage, savedMessage, errorMessagestringThe line beside the indicator in each state.saveLabel, resetLabel, retryLabelstringButton copy. Save becomes the retry label after a failure.shortcutbooleanSaves on Cmd+S and Ctrl+S. Defaults to true.warnOnLeavebooleanConfirms a reload or a close while dirty. Defaults to true.labelstringThe accessible name of the bar.data-state (SaveBarState)"clean" | "dirty" | "saving" | "saved" | "error"The step the bar is on, written to the root for styling.classNamestringClasses for placement and appearance....divPropsHTMLAttributesNative div attributes.Accessibility
The bar is a named region. While the form is clean it is hidden from assistive technology and its buttons are out of the tab order, so a page with nothing to save carries no extra stop. The message is repeated in a live region kept outside the bar, because a region that was hidden a moment ago is not reliably read when it returns, and that copy is what announces the save and the failure. Reduced motion removes the rise, the spin, and the drawn check, leaving a bar that is simply there and then gone.