Snackbar
A bottom-edge message bar with one action, swipe-to-dismiss and a single-slot queue that never lets two share the thumb arc.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/snackbar.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Snackbar" for phones: a bottom-edge message
bar with at most one action, a swipe that throws it away, and a queue that shows
one message at a time. lucide-react for the dismiss glyph, createPortal for the
layer, no toast library.
Contract
- <Snackbar message description? action?={label,onClick?,keepOpen?} variant?
tone? icon? open? defaultOpen? onOpenChange? onDismiss? onExited? duration?
resetKey? dismissible? swipeToDismiss? offset? avoidKeyboard? keyboardInset?
pending? container? hotkey? label? dismissLabel? /> — forwardRef to the bar,
spreads the rest onto it.
- variant is "floating" | "edge" | "inverted"; tone is "default" |
"destructive"; defaults are floating / default / duration 5000 /
dismissible true / swipeToDismiss true / avoidKeyboard true / hotkey "F8".
- Controlled and uncontrolled: pass `open` and the consumer owns visibility (a
refused close springs the bar back to rest); pass `defaultOpen` and the bar
owns it. onDismiss reports a reason: "timeout" | "swipe" | "action" |
"dismiss" | "replaced" | "clear".
- <SnackbarProvider {...barProps} duration?> puts a queue on top of it and
useSnackbar() returns { show, dismiss, clear, current, pending }.
show({ message, description?, action?, tone?, icon?, duration?, collapseKey?,
replace?, onDismiss? }) returns an id. Called outside a provider, useSnackbar()
throws a message naming <SnackbarProvider> — a silent no-op would hide the bug
until the first message nobody saw. The collapse key is deliberately not called
`key`: these items end up spread into props and React would eat it.
- `message` is a string, not a node: it is also what the live region announces.
- Every action is the consumer's; the bar only decides whether to stay up after
it (keepOpen).
Behavior
- Queue, not stack. One slot. show() while a message is up puts the new one in
line; the visible one must finish its exit before the next enters, plus a
~120ms beat so they read as two events. The queue lives in refs mirrored into
state, because show() runs in event handlers: three messages pushed in one
tick have to see each other. `pending` renders as a "+N" tag so the user knows
another is coming.
- Collapse key. show({collapseKey}) matching the visible message updates it in
place and restarts its window; matching a queued one replaces that entry. Five
taps on Copy are one snackbar, not five. `replace: true` jumps the line: the
visible message leaves at once with reason "replaced".
- The window is spent, not counted down. A single timeout is torn down and
rebuilt whenever it pauses, deducting the elapsed time, so it resumes with the
remainder. It pauses while a finger is on the bar, while focus is inside it,
and while the tab is hidden (a five-second window burned in a background tab
is a message nobody ever saw). duration 0 or Infinity pins it up.
- Swipe. Pointer Events only, never separate mouse/touch handlers.
setPointerCapture on the bar at the axis lock — never at pointerdown, because a
captured pointer retargets the click to the capture element and every tap on the
action and dismiss buttons would land on the bar instead of on them. A press that
never locks is caught by a window pointerup/pointercancel listener, so a finger
lifted just off the bar cannot leave the window paused forever. touch-action:
none while swiping is enabled, so the browser never fights the drag and
preventDefault is never needed. The axis is frozen on the first 6px of movement
and never re-evaluated. Sideways follows the finger
1:1 both ways; downwards too; upwards is rubber-banded and capped at ~24px,
because there is nothing above the bar to throw it at. Release dismisses past
35% of the bar's width sideways, 50% of its height downwards, or on a fling
over 0.5px/ms in the same direction as the travel; anything else springs back.
Opacity tracks the progress, so a dismissal that does not complete visibly
comes back. A released swipe suppresses the click it would otherwise land on
the action button (onClickCapture, reset on the next pointerdown).
- Safe area and the keyboard. The layer pads itself with
max(var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)), 12px)
plus `offset` (the height of a tab bar it must clear). The keyboard is read
from visualViewport as innerHeight - viewport.height - viewport.offsetTop,
subscribed through useSyncExternalStore, thresholded at 120px so a floating
Safari toolbar is not mistaken for one; `keyboardInset` overrides it for a
native bridge or a preview. Keyboard and safe area do NOT stack — the keyboard
already covers the home indicator and the tab bar, so the two combine with
max(), never a sum. The `edge` variant carries the safe area in its own padding
instead, so its surface runs under the indicator rather than stopping short.
- Focus. The bar NEVER takes focus by itself: it would yank the user out of the
field they were typing in. `hotkey` (F8) moves focus into it from anywhere and
pauses the window; Esc dismisses while focus is inside (bound on the bar, not
on window, so it cannot steal Esc from a dialog behind it); when the bar
unmounts with focus inside, focus goes back to the element it came from if that
is still connected, and to a sr-only sentinel in the layer otherwise — never to
<body>.
- ARIA. A persistent polite live region lives in the always-mounted layer and
outlives the messages, so it is genuinely empty between two of them and an
identical second message is announced again. The bar itself is role="status"
with aria-live="off" so the same text is not announced twice.
- Lifecycle. Mount, park off-screen, then two requestAnimationFrames before the
resting transform, or the browser coalesces both into one style change and the
entrance never runs. Unmount is deferred by the exit duration (immediately
under reduced motion). Every timeout, frame, listener, pointer capture and
matchMedia/visualViewport subscription is released on unmount and on
dependency change.
- Refusals and edge cases: no message renders nothing at all (no reserved strip);
a one-word message renders as one word; a long message wraps and grows the bar
in floating/edge and truncates in the single-line inverted variant; the dismiss
control is never natively disabled.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground / border / shadow-lg for
the two card variants, bg-foreground + text-background for the inverted one and
for the floating action button (the highest-priority surface inverts, it does
not take a colour), bg-muted + text-muted-foreground for the description and
the +N tag, bg-destructive + text-destructive-foreground only for tone
"destructive", ring / ring-ring for focus. No hex, rgb, hsl or oklch anywhere.
- Radius ladder rounded-2xl for the card, rounded-t-2xl for the edge variant,
rounded-lg for the buttons, rounded for the tag. Type is small and tight:
13px/500 message, 11px description, 10px tag with tabular-nums.
- Every interactive target is 44px in its hit area (h-11 / size-11); the padding
does the work so the 12px label does not shrink the target with it.
- cn() merges every className. prefers-reduced-motion is subscribed, not read
once: under it the transitions are dropped and the exit delay collapses to 0 —
the swipe, the queue and the window all keep working.
- Complete and comfortable at 390px wide; the floating bar caps at 420px and
centres itself.
Customization levers
- Variant is the big one: floating (inset card in the thumb arc), edge
(full-bleed, flush, safe-area inside), inverted (dense, single-line, priority).
Add a fourth by giving it a surface and an action treatment; keep the geometry.
- Timing: duration (window), the exit duration, the promote beat between two
messages, the swipe ratios and the fling velocity are all single constants.
- Placement: `offset` for a tab bar, `container` to scope the layer to a phone
frame instead of the screen, `avoidKeyboard` / `keyboardInset` for how it
reacts to the software keyboard.
- Content: drop the icon, drop the description, drop the +N tag, or swap the
dismiss glyph. Turn `dismissible` or `swipeToDismiss` off independently —
turning both off leaves the window and the API as the only ways out, which is
a legitimate choice for a message that must be read.
- Tone: keep colour for destructive only; anything else that needs emphasis
should invert instead.Concepts
- Single-slot queue — the mobile answer to a toaster. A phone screen cannot spare the room a stack of three needs, so the visible message finishes leaving before the next arrives, and a
+Ntag tells the user more is coming. - Collapse key — repeat presses of the same button coalesce into one message that restarts its window, instead of a queue of five identical messages the user has to sit through.
- Spent window — the auto-dismiss timer is rebuilt on every pause and deducts the time it actually ran, so a finger on the bar, a focused action button or a backgrounded tab hold the message rather than reset it.
- Axis freeze — the first 6px of movement picks the swipe axis and nothing re-evaluates it, so a sideways throw cannot become a downward one halfway and jump under the thumb.
- max(), not a sum — the keyboard already covers the home indicator and the tab bar, so the bottom inset is the largest of them and not their total; summing leaves a dead band above every keyboard.
- Focus on request — the bar never takes focus, because a message that grabs it interrupts typing. F8 hands focus in, Esc hands it back, and an unmounting bar returns focus to where it came from rather than dropping it on the body.
Stream Stop Bar
A bottom-edge run bar for a streaming answer — it mints the AbortSignal, stops the run from the thumb arc, climbs above the soft keyboard, and flicks away to uncover the text it covers.
Offline Banner
A safe-area connectivity banner for phones that lists the changes waiting, sends them when the link returns, and can be swiped into a peek tab that keeps the count.