A sticky footer for a form or a review: what will be saved, a guarded Cancel and your action.
A form
SaveBar sits under a form as its last child: a sentence that says what will be saved, a status line, Cancel and your action. It sticks to the bottom of the screen while the form is in view, over the fields still to come, and comes to rest at the form's end. Change a field below: the clay "Unsaved changes" chip comes, and Cancel asks before it throws the changes away. Clear the merchant and press Save: the bar answers in its status. A save that works is a toast.
Example
Example
'use client';
import { SaveBar, TextArea, TextField, toast } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';
interface Receipt {
merchant: string;
date: string;
amount: string;
category: string;
memo: string;
}
const SAVED: Receipt = {
merchant: 'Corner Mart',
date: '14 Oct 2026',
amount: '$42.18',
category: 'Food',
memo: 'Milk and bread',
};
const LABELS: Record<keyof Receipt, string> = {
merchant: 'Merchant',
date: 'Date',
amount: 'Amount',
category: 'Category',
memo: 'Memo',
};
export default function SaveBarForm(): ReactElement {
// What the server holds, and the reader's copy: the bar is dirty while they differ.
const [saved, setSaved] = useState(SAVED);
const [draft, setDraft] = useState(SAVED);
const [problem, setProblem] = useState('');
const changed = (Object.keys(SAVED) as (keyof Receipt)[]).filter((key) => draft[key] !== saved[key]);
const count = changed.length;
const message =
count === 0
? `${saved.merchant}, ${saved.date}`
: `${String(count)} ${count === 1 ? 'change' : 'changes'} to ${saved.merchant}, ${saved.date}`;
const field = (key: keyof Receipt): ReactElement => {
const props = {
label: LABELS[key],
value: draft[key],
isChanged: draft[key] !== saved[key],
onChange: (value: string) => {
setDraft((current) => ({ ...current, [key]: value }));
},
};
return key === 'memo' ? <TextArea key={key} {...props} /> : <TextField key={key} {...props} />;
};
return (
// The form is the bar's holder, and the bar its last child: it sticks while the form is in view.
<form
style={{ display: 'grid', gap: 'var(--spacing-peer)', width: '100%' }}
onSubmit={(event) => {
event.preventDefault();
}}
>
{(Object.keys(SAVED) as (keyof Receipt)[]).map(field)}
<SaveBar
message={message}
isDirty={count > 0}
actionLabel="Save"
// A save that cannot go answers the press with a status; a save that worked is a toast.
onAction={async () => {
if (draft.merchant.trim() === '') {
setProblem('Enter the merchant.');
return;
}
setProblem('');
// Your request goes here; the example waits a moment instead.
await new Promise((resolve) => setTimeout(resolve, 1000));
setSaved(draft);
toast({ title: 'Receipt saved', tone: 'success' });
}}
onCancel={() => {
setDraft(saved);
setProblem('');
}}
status={problem}
statusTone="error"
/>
</form>
);
}A review
In a review the action means something even with no edit: "Confirm and review next (2 left)". The bar shows whether or not anything changed, so the page never moves under the reader as they type. Here the grid holds an invalid amount, so a press says so in the status rather than the action being disabled.
Example
Example
| Merchant | Date | Amount | Memo | Category |
|---|---|---|---|---|
| Corner Mart | 15 Oct 2026Changed | $ | Changed | |
| City Transit | 13 Oct 2026 | $Changed | Changed Changed | |
| Green Grocer | 12 Oct 2026 | $ Enter the amount paid. |
'use client';
import { DatePicker, NumberField, PickerOption, SaveBar, Select, TextField } from '@snapshot-ui/react';
import { useState, type CSSProperties, type ReactElement } from 'react';
interface Receipt {
id: string;
merchant: string;
/** An ISO date, or null once the reader has erased it. */
date: string | null;
/** In cents. */
amount: number;
memo: string;
category: string;
}
/** What the server holds: the reader edits a copy, and each field compares its value with this one. */
const [CORNER_MART, CITY_TRANSIT, GREEN_GROCER]: readonly [Receipt, Receipt, Receipt] = [
{
id: 'r1',
merchant: 'Corner Mart',
date: '2026-10-14',
amount: 4218,
memo: 'Milk and bread',
category: 'food',
},
{
id: 'r2',
merchant: 'City Transit',
date: '2026-10-13',
amount: 275,
memo: 'Bus fare',
category: 'transport',
},
{ id: 'r3', merchant: 'Green Grocer', date: '2026-10-12', amount: 1890, memo: 'Apples', category: 'food' },
];
/** Each saved receipt beside the reader's edits so far: a date and a memo, an amount and a category, and one
amount cleared. */
const REVIEW: readonly { saved: Receipt; edited: Receipt }[] = [
{ saved: CORNER_MART, edited: { ...CORNER_MART, date: '2026-10-15', memo: 'Milk, bread and eggs' } },
{ saved: CITY_TRANSIT, edited: { ...CITY_TRANSIT, amount: 550, category: 'home' } },
{ saved: GREEN_GROCER, edited: { ...GREEN_GROCER, amount: 0 } },
];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/** The app's own words for a day, "15 Oct 2026", for the server's HTML: twelve month names, no Intl. */
const dayLabel = (iso: string): string => {
const [year, month, day] = iso.split('-').map(Number);
return `${String(day)} ${MONTHS[(month ?? 1) - 1] ?? ''} ${String(year)}`;
};
const COLUMNS = ['Merchant', 'Date', 'Amount', 'Memo', 'Category'];
const head: CSSProperties = { textAlign: 'start', fontWeight: 500, paddingBlockEnd: 4 };
export default function DatePickerReviewGrid(): ReactElement {
const [rows, setRows] = useState(REVIEW);
const change = (id: string, patch: Partial<Receipt>): void => {
setRows((current) =>
current.map((each) =>
each.edited.id === id ? { ...each, edited: { ...each.edited, ...patch } } : each,
),
);
};
// How many fields differ from the server's, and how many invalid: the save bar's sentence and its status.
const fields = ['date', 'amount', 'memo', 'category'] as const;
const changes = rows.reduce(
(sum, { saved, edited }) => sum + fields.filter((key) => edited[key] !== saved[key]).length,
0,
);
const invalid = rows.filter(({ edited }) => edited.amount <= 0).length;
const [left, setLeft] = useState(2);
const [problem, setProblem] = useState('');
return (
// The holder of the grid and its save bar, whose last child the bar is; never wider than the stage, so the
// grid scrolls inside it.
<div style={{ width: '100%', minWidth: 0 }}>
{/* The grid scrolls sideways inside its own box on a narrow screen; the page never does. */}
<div style={{ width: '100%', overflowX: 'auto' }}>
<table style={{ borderCollapse: 'separate', borderSpacing: '8px 12px', marginInline: -8 }}>
<thead>
<tr>
{COLUMNS.map((name) => (
<th key={name} id={`receipt-${name.toLowerCase()}`} scope="col" style={head}>
{name}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(({ saved, edited: row }) => {
// Each field is named by its column's header and its row's merchant: "Date Corner Mart".
const named = (column: string): string => `receipt-${column} ${row.id}`;
return (
<tr key={row.id}>
<th id={row.id} scope="row" style={{ ...head, fontWeight: 450, whiteSpace: 'nowrap' }}>
{row.merchant}
</th>
<td style={{ minWidth: '12rem' }}>
<DatePicker
aria-labelledby={named('date')}
value={row.date}
valueLabel={row.date === null ? undefined : dayLabel(row.date)}
today="2026-10-16"
isChanged={row.date !== saved.date}
// A year being typed is held back, so null means the reader erased the day.
isInvalid={row.date === null}
errorMessage="Enter the date on the receipt."
onChange={(date) => {
change(row.id, { date });
}}
/>
</td>
<td style={{ minWidth: '9rem' }}>
<NumberField
aria-labelledby={named('amount')}
money={{ symbol: '$' }}
value={row.amount}
isChanged={row.amount !== saved.amount}
isInvalid={row.amount <= 0}
errorMessage="Enter the amount paid."
onChange={(amount) => {
change(row.id, { amount: amount ?? 0 });
}}
/>
</td>
<td style={{ minWidth: '12rem' }}>
<TextField
aria-labelledby={named('memo')}
value={row.memo}
isChanged={row.memo !== saved.memo}
onChange={(memo) => {
change(row.id, { memo });
}}
/>
</td>
<td style={{ minWidth: '10rem' }}>
<Select
aria-labelledby={named('category')}
value={row.category}
isChanged={row.category !== saved.category}
onChange={(category) => {
if (typeof category === 'string') change(row.id, { category });
}}
>
<PickerOption id="food">Food</PickerOption>
<PickerOption id="transport">Transport</PickerOption>
<PickerOption id="home">Home</PickerOption>
</Select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<SaveBar
message={`${String(changes)} ${changes === 1 ? 'change' : 'changes'} to 3 receipts`}
isDirty={changes > 0}
actionLabel={`Confirm and review next (${String(left)} left)`}
// Never disabled: a press with an invalid field is answered in the status.
onAction={() => {
if (invalid > 0) {
setProblem(`${String(invalid)} ${invalid === 1 ? 'field needs' : 'fields need'} attention.`);
return;
}
setProblem('');
setRows((current) => current.map(({ edited }) => ({ saved: edited, edited })));
setLeft((count) => Math.max(0, count - 1));
}}
onCancel={() => {
setProblem('');
setRows((current) => current.map(({ saved }) => ({ saved, edited: saved })));
}}
status={problem}
statusTone="error"
/>
</div>
);
}In a side panel
In a Side panel the bar is the last child of the form in the panel's body, which scrolls, so the bar sticks to the body's bottom. The bar guards its own Cancel; the panel's close button and Escape go through onCloseRequest, which refuses a close while edits are unsaved and asks the same question. Open a row and change its memo.
Example
Example
| Payee | Amount |
|---|---|
| Corner Mart | −$42.18 |
| City Transit | −$2.75 |
| Northside Rent | −$1,450.00 |
| Acme Payroll | $3,125.00 |
'use client';
import {
DataTable,
RouterProvider,
SaveBar,
SidePanel,
SidePanelLayout,
TextArea,
TextField,
useConfirm,
type DataTableColumn,
} from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';
import { amountLabel, dayLabel, TRANSACTIONS, type Transaction } from './transactions-data';
const COLUMNS: readonly DataTableColumn<Transaction>[] = [
{ id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
{
id: 'amount',
header: 'Amount',
cell: (row) => amountLabel(row.minor),
align: 'end',
width: '30%',
listSlot: 'end',
},
];
const ROWS = TRANSACTIONS.slice(0, 4);
export default function SaveBarInSidePanel(): ReactElement {
const [open, setOpen] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
// The memo as typed, until Save keeps it: the panel holds unsaved edits while it differs.
const [draft, setDraft] = useState('');
const { confirm, dialog } = useConfirm();
const row = ROWS.find((each) => each.id === open);
const saved = open === null ? '' : (notes[open] ?? '');
const isDirty = draft !== saved;
const openRow = (id: string | null): void => {
setOpen(id);
setDraft(id === null ? '' : (notes[id] ?? ''));
};
return (
<div style={{ width: '100%' }}>
<RouterProvider
navigate={(href) => {
openRow(href.slice(1));
}}
>
<SidePanelLayout
panel={
<SidePanel
key={open ?? 'none'}
title={row?.payee ?? ''}
description={row === undefined ? undefined : dayLabel(row.date)}
isOpen={row !== undefined}
onClose={() => {
openRow(null);
}}
// The bar guards its own Cancel; the panel's close button and Escape ask the same question while
// edits are unsaved, and a swipe or a press outside the sheet does nothing.
isDismissable={!isDirty}
onCloseRequest={() => {
if (!isDirty) return true;
void confirm({
title: 'Discard changes?',
description: 'The changes you made here will be lost.',
confirmLabel: 'Discard changes',
cancelLabel: 'Keep editing',
tone: 'danger',
}).then((isSure) => {
if (isSure) openRow(null);
});
return false;
}}
>
{row === undefined ? null : (
// The body scrolls, so the bar sticks to its bottom, the last child of the form in it.
<form
style={{ display: 'grid', gap: 'var(--spacing-peer)' }}
onSubmit={(event) => {
event.preventDefault();
}}
>
<TextField label="Payee" value={row.payee} isReadOnly />
<TextArea label="Memo" value={draft} onChange={setDraft} isChanged={isDirty} />
<SaveBar
message={isDirty ? `1 change to ${row.payee}` : row.payee}
isDirty={isDirty}
actionLabel="Save"
onAction={() => {
if (open !== null) setNotes({ ...notes, [open]: draft });
}}
onCancel={() => {
openRow(null);
}}
/>
</form>
)}
</SidePanel>
}
>
<DataTable
aria-label="Transactions"
items={ROWS}
columns={COLUMNS}
getKey={(each) => each.id}
getRowHref={(each) => `#${each.id}`}
currentKey={open}
/>
</SidePanelLayout>
</RouterProvider>
{dialog}
</div>
);
}Usage
- Put the bar last in the holder of the fields it saves: a form, a review's section, a panel's body. It is
position: sticky, so it rides with its holder and never covers what follows it. - It is always drawn.
isDirtyadds the chip, makes Cancel ask first and warns before the page unloads. messagesays what will be saved, in your words: "2 changes to Corner Mart, 14 Oct". It wraps to two lines and then ends in an ellipsis; the whole sentence names the bar.statusis for a state the reader must act on, such as invalid fields or a save that failed, andstatusTone="error"draws it in red with an icon. A save that worked is an event: raisetoast({ tone: 'success' }).- The action is never disabled. A press your app cannot act on is answered with a
status, which says why; a disabled button says nothing and leaves the tab order. - Return a promise from
onActionand the action shows its spinner and Cancel waits until it settles. A rejected promise keeps the bar as it was and reaches the page's error handlers throughreportError.isPendingdoes the same for work your app tracks itself. - Each press is answered afresh: a press that gets the same
statusas the last one is said again. warnsOnUnload, on by default, asks the browser to warn before the tab closes or reloads while dirty. The browser shows its own words and iOS Safari shows none. A client-side link is not guarded: a side panel refuses its own close throughonCloseRequest.- A focused field never ends under the bar. Once the page has loaded, the bar scrolls a field that focus lands on under it, anywhere on the page, 16px clear of it, its description and error line included, and of a phone's upload bar above it, in a box that scrolls too. A field taller than the room keeps its top in view, below your page's
--page-sticky-top. Focus on the bar's own buttons never moves the page: the page's scroll is never padded for the bar. - The spinner is named by
labels.pending, "Saving", the word the status says, so one translated string covers both;pendingLabelnames the spinner alone. - On a narrow bar the buttons take a line of their own under the sentence, side by side in their order and sharing the line; one wraps to a line of its own only when its label is too long to fit beside the others.
- Use one sticky bar per page. The bar lifts the Upload tray and the toasts above itself, and a second bar would cover the first. If a page does show two, the tray and the toasts sit above the taller.
- A bar sticks inside the nearest ancestor that scrolls. In a side panel or a dialog that is the body, which is where it belongs; in a page, give no ancestor of the form an
overflowof its own. - A review that moves to the next document is your navigation; move focus to the next document's heading, as a side panel does for a new row.
- The bar takes no keyboard shortcut. A Mod+S for save is your own listener that calls your action:
useEffect(() => {
const onKeyDown = (event: KeyboardEvent): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 's') {
event.preventDefault();
void save();
}
};
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
};
}, [save]);Props
actionLabel string | The primary action's text, the app's: "Save", "Confirm and review next (2 left)". |
|---|---|
className string | |
isDirty boolean | Unsaved changes exist: the clay chip shows, Cancel asks first, and the page warns before unloading. Default: false |
isPending boolean | Pending for work the app tracks itself: the action shows its spinner and Cancel is disabled. |
labels Partial<SaveBarLabels> | |
message ReactNode | What will be saved, in the app's words: "2 changes to Corner Mart, 14 Oct". Not live: it changes as the reader types. |
onAction () => void | Promise<void> | A returned promise keeps the bar pending until it settles. On reject the bar stays and reports the error through |
onCancel (() => void) | Shows Cancel. Called at once when clean, and after the discard question when dirty. |
pendingLabel string | The spinner's name, read with the action's. Default: labels.pending, 'Saving' |
ref Ref<HTMLDivElement> | |
secondaryActions ReactNode | Buttons between Cancel and the action, such as "Skip" in a review. |
status ReactNode | A state line, spoken politely as it changes: "3 fields need attention", "Couldn't save. Try again." |
statusTone "error" | "neutral" |
Default: 'neutral' |
warnsOnUnload boolean | Warns before the page unloads while dirty. Default: true |
cancel string | Default: 'Cancel' |
|---|---|
discardConfirm string | |
discardDescription string | |
discardKeep string | |
discardTitle string | The discard question. Default: 'Discard changes?', 'The changes you made here will be lost.', 'Discard changes', 'Keep editing' |
pending string | Said in the status once the action has been pending for QUIET_MS, when the action did not hold focus as the
pending began; while it does, React Aria's Button says its pending itself. Also the spinner's name unless
Default: 'Saving' |
unsaved string | The clay chip while there are unsaved changes. Default: 'Unsaved changes' |
Accessibility
The bar is a group named by its message. Its status is a polite live region, in the page from the first render: it says each new status, and the same status again after another press. While a save is pending, the action that holds focus speaks for itself: React Aria's Button says "Save Saving" assertively as the save starts and "Save" as it ends. When focus is elsewhere as the save starts, as after a pointer press in Safari, which focuses no button, or when your app sets isPending on its own, the status says "Saving" instead, once the save has run past a short pause, so a fast one says nothing there. The discard question is an alert dialog that starts on "Keep editing"; either answer returns focus to Cancel, or to the action when Cancel has gone or a save has disabled it meanwhile. Focus that Cancel held while it is disabled or removed goes to the action, never to the page. The reading and tab order is the sentence, the status, then Cancel, your buttons and the action, so a keyboard reaches the actions after the fields they act on.
| Tab | From the last field to Cancel, your buttons, then the action. |
|---|---|
| EnterSpace | On a button, presses it. |
| Escape | In the discard question, keeps editing; focus returns to Cancel. |
Design rules
What keeps a save bar in the system.
Say what will be saved, in the reader's words.
Name the action by its outcome: "Confirm and review next".
Answer a press that cannot save with a status.
Never draw the action in signal orange: it asks for no consent.
Never name a button "OK".
Never disable the action.