A persistent, decision-bearing bar that floats near the bottom edge of the viewport. It surfaces pending state — primarily a form's unsaved ("dirty") changes — and stays until the user resolves it: save or discard.
Toast announces something that already happened and leaves on its own; a Sandbar waits for the user's decision. If the message needs no decision, reach for Toast or Alert instead.
The name: a sandbar is a bar that blocks navigation — exactly what this component does while
changes are pending. Wire your router's navigation guard to shake() on the SandbarHandle, and
blocked attempts wiggle the panel and announce assertively to screen readers.
Composition
Compose the parts of a Sandbar together to build your own:
Sandbar.Root must stay mounted and be toggled with the controlled open prop: never
conditionally mount it ({isDirty && <Sandbar.Root …>}). The screen-reader announcement only
works when the internal live regions exist in the tree before the message appears. The exit
animation needs the panel alive to play. A Root that mounts with open already true does not
announce (mounting is not a live-region change).
Polymorphism
Sandbar.Message and Sandbar.Actions accept an asChild prop. When true, the part renders
its single child instead of its default element (<p> and <div> respectively), forwarding all
class names, data-* attributes, and the ref onto that child. Reach for it when the message needs
a different semantic element (e.g. a <span> inside a larger sentence) or when the actions row
should be another framework primitive. Sandbar.SaveButton and Sandbar.DiscardButton inherit
asChild from Button. Sandbar.Root deliberately does not support
asChild: it renders multiple nodes (positioning wrapper, two screen-reader announcers, and the
panel), so single-child polymorphism has no coherent meaning. The framed demos on this page render
real Sandbar trees you can adapt; there is no separate polymorphism demo because the bar is a
viewport-fixed singleton that would overlay this page outside a preview frame.
Pending decisions beyond saving
Batch-saving a form is the flagship use, but any pending decision that must be resolved before
moving on fits: compose your own Buttons inside Sandbar.Actions and pass a custom
blocked-navigation announcement to shake() so screen-reader users hear the actions that actually
exist on screen. The panel's accessible name follows Sandbar.Message automatically.
Reporting a failed save
Report the failure where it happened, not on the bar. The bar carries the pending decision:
"you have unsaved changes, resolve them." A failed save is about the content the user is looking at,
so it belongs next to that content: a field-level message on the field that was rejected, or a
form-level Alert at the top of the form for a whole-request failure.
There is deliberately no Sandbar.Error part.
Leave the bar open on failure. The changes are still unsaved, so the decision is still pending and the user needs the Save button to retry.
Two things to get right in the error you render:
- Announce it. The bar's live regions cover opening, saving, and blocked navigation, not your
error. Give a form-level alert
role="alert", or render a field error throughField.Errors, which wires it into the control'saria-describedbyand flips the field to its error state. - Move focus to it. Assistive tech can miss an alert node that mounts already populated.
Focusing the failure also puts a sighted user's attention on what went wrong. Focus the alert
(
tabIndex={-1}) for a form-level failure, or the rejected field for a field-level one.
Saving in the demo below always fails, so the contextual error appears on every attempt.
Theming
The panel is an inverted theme island: it
renders in the opposite theme of the page: dark on light pages, and light on dark pages. The inversion covers the entire subtree, so the blessed
buttons and any custom children you compose inside Sandbar.Actions all read as
the opposite theme without extra styling. The panel's own surface uses the inverted theme's
bg-base and text-strong tokens; style overrides passed via className resolve against the
inverted theme too.
Reduced motion
Under prefers-reduced-motion: reduce the bar keeps its fades and drops everything that travels:
the enter no longer rises, the exit no longer drops, and shake() announces without wiggling. The
announcement carries the feedback, so blocked navigation stays perceivable with the motion
switched off.
Two mechanisms do it, and neither needs a prop. The enter/exit travel sits behind motion-safe:, so
the media query alone removes it. shake() reads the preference with getPrefersReducedMotion()
(the imperative sibling of usePrefersReducedMotion) and skips
panel.animate(...) when it returns true.
A page cannot change an operating-system preference, so the demo below carries a switch that
reproduces the same two outcomes for a reader whose system allows motion. It does that through
className, which Sandbar.Root merges last (the same escape hatch you have). The readout tracks
the real preference live, because usePrefersReducedMotion subscribes to the query.
Do not reach for STILLNESS in an app. It exists to demonstrate the reduced-motion result to a
reader who has motion switched on; in production the preference does this by itself.
Blocking navigation
The bar renders the decision; your app owns the guard. Pair open with your router's blocker
(e.g. react-router's useBlocker plus a beforeunload listener while dirty) and call
sandbarHandle.current?.shake() when a navigation attempt is blocked; the hero example above
shows the wiring with a stand-in guard. Keep the guard out of the component: mantle has no router
dependency, and the split mirrors Shopify's SaveBar / leaveConfirmation() and UIKit's
isModalInPresentation / presentationControllerDidAttemptToDismiss.
Accessibility
- Announcements.
Sandbar.Rootowns two persistent visually-hidden live regions. A politerole="status"announcer speaks the message when the bar opens and "Saving changes…" whileSandbar.SaveButtonis loading. An assertiverole="alert"announcer speaks blocked-navigation attempts (fromshake()). This is why Root must stay mounted. Errors are not announced here; see Reporting a failed save. - Announce success yourself. The bar closes silently when changes resolve. Pair the save's resolution with a status announcement (a success Toast qualifies), or screen-reader users hear a save start and never finish.
- Focus is never stolen. The bar appears without taking focus. If a focused save button goes disabled while loading, the bar parks focus on the panel so it cannot evaporate mid-save. When the bar finishes closing with focus still inside it, the bar restores focus to the element you focused before entering it, unless you already moved focus elsewhere, in which case the bar leaves it alone.
- The exit is unreachable. The panel goes
inertthe moment it starts leaving, not when it finishes, so a user Tabbing toward Discard as the save lands cannot focus or activate a control that is already visually gone. Focus thatinertevicts still restores to the pre-bar element. - Escape is intentionally inert. A one-keypress discard would destroy user data; resolving the pending state requires an explicit action.
- Reduced motion. Under
prefers-reduced-motion, the enter/exit slides degrade to fades andshake()skips the wiggle; the assertive announcement still fires, so blocked navigation stays perceivable. See Reduced motion for a demo you can drive. - Zoom and small viewports. The panel wraps its actions under the message and caps its height, so it stays usable at 320px-wide viewports (400% zoom).
Focus not obscured
A fixed bar can cover the focused element behind it (WCAG 2.4.11). Two mitigations are built in:
the panel is a centered island rather than an edge-to-edge strip, and it floats above the bottom
edge. For long forms, additionally set scroll-padding-bottom (roughly the bar height plus its
offset, ~6rem) on the scrolling container while the bar is open, so keyboard-driven
scroll-into-view lands fields above the bar. Scroll padding only helps elements being scrolled
into view. An element already in view behind the bar is only partially obscured thanks to the
island shape, which satisfies the AA criterion.
Over a dialog or sheet
The bar renders above an open Dialog, AlertDialog, or Sheet and its scrim, the same as a toast. A click on the bar does not close the overlay. Edit a field below, open the sheet, then save or discard: the sheet stays open.
The open overlay traps focus and hides the rest of the page from screen readers. Keyboard and screen-reader users reach the bar after the overlay closes. For a form inside a dialog or sheet, put the save and discard actions in the overlay's footer, not in a Sandbar.
Placement
Render Sandbar.Root in place, inside or immediately after the form it saves; position: fixed
changes paint position, not DOM or tab order, so Tab from the last form field lands directly on
Discard/Save. Two caveats:
- An ancestor with
transform,filter, orwill-changebecomes the containing block forposition: fixed(common with page-transition wrappers). If you cannot render Root outside the transformed ancestor, portal it yourself withcreatePortal(<Sandbar.Root …/>, document.body), and accept that this severs the form→actions tab-order adjacency. - An ancestor that sets
z-indexorisolationstarts a new stacking context. It can put the bar below an open dialog or sheet. Render Root outside that ancestor. - Only render one Sandbar per page: it is a singleton surface, like a page's primary save action.
API Reference
Sandbar.Root
The always-mounted shell: a private fixed-position wrapper, two persistent screen-reader
announcers, and the visible panel. ref, className, and all other props target the panel (a
role="group" element named by Sandbar.Message via aria-labelledby; a consumer aria-label
wins when passed). Does not support asChild — Root renders multiple nodes, so single-child
polymorphism has no coherent meaning.
All props from div, plus:
Stamped on the panel:
Every part also joins an incoming data-slot chain ahead of its own name, so composing a
part through an ancestor's asChild yields data-slot="ancestor-slot sandbar-…" instead of
losing one of the two.
SandbarHandle
The imperative surface received through handleRef. shake() wiggles the panel (skipped under
prefers-reduced-motion — never rely on the motion alone) and always announces assertively. Pass
announcement to replace the default save-flavored wording for other intents.
Sandbar.Message
The visible pending-state text. A plain paragraph: Root's persistent announcer owns the live-region announcement, not this node. The panel's accessible name points here, so the group name always matches the visible text.
All props from p, plus:
Sandbar.Actions
The action-button row: a plain flex container (deliberately not role="toolbar", which is
reserved for 3+ controls). Holds Sandbar.DiscardButton and Sandbar.SaveButton, or your own
composed Buttons.
All props from div, plus:
Sandbar.SaveButton
The primary (save) action — a mantle Button defaulting to the system
primary style. While isLoading, Root announces "Saving changes…" politely and keeps focus from
being lost when the button becomes disabled. Defaults to type="button"; pass type="submit"
inside the form it saves.
All props from Button, with these differences:
Sandbar.DiscardButton
The secondary (discard/reset) action — a mantle Button. Discard is destructive-ish: for large forms, consider confirming before discarding.
All props from Button, with these differences: