Skip to main content

Sandbar

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├── Sandbar.Message└── Sandbar.Actions    ├── Sandbar.DiscardButton    └── Sandbar.SaveButton

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 through Field.Errors, which wires it into the control's aria-describedby and 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.Root owns two persistent visually-hidden live regions. A polite role="status" announcer speaks the message when the bar opens and "Saving changes…" while Sandbar.SaveButton is loading. An assertive role="alert" announcer speaks blocked-navigation attempts (from shake()). 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 inert the 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 that inert evicts 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 and shake() 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, or will-change becomes the containing block for position: fixed (common with page-transition wrappers). If you cannot render Root outside the transformed ancestor, portal it yourself with createPortal(<Sandbar.Root …/>, document.body), and accept that this severs the form→actions tab-order adjacency.
  • An ancestor that sets z-index or isolation starts 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:

PropTypeDefaultDescription
openboolean—Whether the bar is showing. Controlled-only; drive it from your pending state (e.g. a dirty flag).
handleRef?Ref<SandbarHandle>—Receives the imperative handle: shake(options?: { announcement?: string }) for blocked navigation.

Stamped on the panel:

Data AttributeValueDescription
data-slot"sandbar"Stable styling hook for the panel. Survives className overrides.
data-state"open" | "closed""open" only at the resting open pose — "closed" covers both the pre-enter frame and the exit transition, which is what drives the slide/fade.

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.

MethodSignatureDescription
shake(options?: { announcement?: string }) => voidWiggle the panel and announce that the attempted action was blocked.

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:

PropTypeDefaultDescription
asChild?booleanfalseRender as a different element by passing a child.
Data AttributeValueDescription
data-slot"sandbar-message"Stable styling hook. Survives className overrides and asChild.

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:

PropTypeDefaultDescription
asChild?booleanfalseRender as a different element by passing a child.
Data AttributeValueDescription
data-slot"sandbar-actions"Stable styling hook. Survives className overrides and asChild.

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:

PropTypeDefaultDescription
appearance?"ghost" | "filled" | "outlined" | "link""filled"The Button appearance, made optional with a default.
intent?"accent" | "danger" | "neutral""neutral"The Button intent, made optional with a default.
childrenReactNode—Required — the visible action label is app voice.
Data AttributeValueDescription
data-slot"sandbar-save-button button"Stable styling hook, joined ahead of Button's own button.
data-appearancethe resolved appearanceStamped by Button.
data-intentthe resolved intentStamped by Button.
data-sizethe resolved sizeStamped by Button; absent for appearance="link".
data-disabledpresent when disabledStamped by Button. Presence-only, never "false".
data-loadingpresent when isLoadingStamped by Button. Presence-only, never "false".

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:

PropTypeDefaultDescription
appearance?"ghost" | "filled" | "outlined" | "link""outlined"The Button appearance, made optional with a default.
intent?"accent" | "danger" | "neutral""neutral"The Button intent, made optional with a default.
childrenReactNode—Required — the visible action label is app voice.
Data AttributeValueDescription
data-slot"sandbar-discard-button button"Stable styling hook, joined ahead of Button's own button.
data-appearancethe resolved appearanceStamped by Button.
data-intentthe resolved intentStamped by Button.
data-sizethe resolved sizeStamped by Button; absent for appearance="link".
data-disabledpresent when disabledStamped by Button. Presence-only, never "false".
data-loadingpresent when isLoadingStamped by Button. Presence-only, never "false".