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

Changed
Changed
Changed
Changed
Changed
Corner Mart, 14 Oct 2026
Follows the page theme and corners
'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

MerchantDateAmountMemoCategory
Corner Mart
15 Oct 2026Changed
$
Changed
City Transit
13 Oct 2026
$Changed
Changed
Green Grocer
12 Oct 2026
$
Enter the amount paid.
Unsaved changes5 changes to 3 receipts
Follows the page theme and corners
'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

PayeeAmount
Corner Mart−$42.18
City Transit−$2.75
Northside Rent−$1,450.00
Acme Payroll$3,125.00
Corner Mart−$42.18
City Transit−$2.75
Northside Rent−$1,450.00
Acme Payroll$3,125.00
Follows the page theme and corners
'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. isDirty adds the chip, makes Cancel ask first and warns before the page unloads.
  • message says 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.
  • status is for a state the reader must act on, such as invalid fields or a save that failed, and statusTone="error" draws it in red with an icon. A save that worked is an event: raise toast({ 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 onAction and 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 through reportError. isPending does the same for work your app tracks itself.
  • Each press is answered afresh: a press that gets the same status as 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 through onCloseRequest.
  • 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; pendingLabel names 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 overflow of 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 reportError.

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"

error draws the status in the error color with an icon.

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 pendingLabel is given, so one translated string covers both.

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.

TabFrom the last field to Cancel, your buttons, then the action.
EnterSpaceOn a button, presses it.
EscapeIn 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.

esc

↑ ↓ to move↵ to open