Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"

/** Enter / exit duration (ms). Also how long unmount is deferred after a close. */
const SETTLE_MS = 260
/** Quiet beat between one message leaving and the next arriving, so they read as two events. */
const PROMOTE_GAP_MS = 120
/** Movement (px) before a press becomes a swipe. Below it a tap is still a tap. */
const DRAG_START_PX = 6
/** Released past this fraction of the bar's own width, a sideways swipe dismisses. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/snackbar.json

Prompt

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 +N tag 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.

On This Page