Snackbar
Brief, transient messages that appear at the bottom of the screen to provide feedback about an operation. Automatically dismisses after a short duration and supports optional actions.
Variants
Different visual styles for different types of feedback. Color helps convey meaning, but always include clear text messages.
i
DefaultNew message received
You have 1 unread notification
check
SuccessChanges saved
Your profile has been updated
!
ErrorUpload failed
File exceeds 10 MB limit
!
WarningSession expiring
You'll be logged out in 5 minutes
Demo
Snackbars appear temporarily to acknowledge an action. They can include an optional action button.
Anatomy
Behavior
| Aspect | Behavior | Notes |
|---|---|---|
| Auto-dismiss | After duration (default 4s) | Set duration=0 to disable |
| Hover | Pauses timer | Resumes on mouse leave |
| Focus | Pauses timer | Accessibility friendly |
| Multiple | Stack vertically | Most recent on top |
| Action click | Dismisses after | Then executes callback |
| Dismiss | Immediate close | No further animation |
Usage guidelines
✓ Do
- ·Use for non-critical, transient feedback
- ·Keep messages concise (1-2 lines max)
- ·Provide action for undoable operations
- ·Pause timer on hover/focus for accessibility
✗ Don't
- ·Don't use for critical errors (use banner/modal)
- ·Avoid stacking more than 2-3 at once
- ·Don't require reading for critical info
- ·Avoid long messages that wrap
Props
| Prop | Type | Default | Description |
|---|---|---|---|
open* | boolean | — | Controls snackbar visibility. |
onClose* | () => void | — | Called when snackbar closes. |
message* | string | — | Main message text to display. |
description | string | — | Optional secondary text below the message. |
variant | "default" | "success" | "error" | "warning" | "default" | Visual style variant. |
duration | number | 4000 | Auto-dismiss delay in milliseconds. 0 = never. |
action | { label: string; onClick: () => void } | — | Optional action button on the right. |
position | "bottom-left" | "bottom-center" | "bottom-right" | "bottom-right" | Screen position. |
Additional props
| Prop | Type | Default | Description |
|---|---|---|---|
dismissible | boolean | false | Show a dismiss (×) button. |
icon | ReactNode | — | Optional icon before message. |
Implementation
Snackbar.tsx
tsx
"use client";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { CheckCircle, AlertCircle, AlertTriangle, X } from "lucide-react";
import { cn } from "@/lib";
type SnackbarVariant = "default" | "success" | "error" | "warning";
type SnackbarPosition = "bottom-left" | "bottom-center" | "bottom-right";
export interface SnackbarProps {
open: boolean;
onClose: () => void;
message: string;
description?: string;
variant?: SnackbarVariant;
duration?: number;
action?: {
label: string;
onClick: () => void;
};
position?: SnackbarPosition;
dismissible?: boolean;
icon?: React.ReactNode;
}
const ICONS: Record<SnackbarVariant, React.ReactNode> = {
default: null,
success: <CheckCircle className="w-5 h-5" />,
error: <AlertCircle className="w-5 h-5" />,
warning: <AlertTriangle className="w-5 h-5" />,
};
const COLORS: Record<SnackbarVariant, string> = {
default: "rgb(var(--text-primary))",
success: "var(--nav-active-color)",
error: "#ef4444",
warning: "#f59e0b",
};
const POSITIONS: Record<SnackbarPosition, string> = {
"bottom-left": "bottom-4 left-4",
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
"bottom-right": "bottom-4 right-4",
};
export function Snackbar({
open,
onClose,
message,
description,
variant = "default",
duration = 4000,
action,
position = "bottom-right",
dismissible = false,
icon,
}: SnackbarProps) {
useEffect(() => {
if (!open || duration === 0) return;
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [open, duration, onClose]);
if (!open) return null;
const customIcon = icon ?? ICONS[variant];
return createPortal(
<div
className={cn(
"fixed z-[70] px-4",
POSITIONS[position]
)}
role="status"
aria-live="polite"
aria-atomic="true"
>
<div
className={cn(
"flex items-start gap-3 px-4 py-3 rounded-lg shadow-[0_4px_16px_rgba(0,0,0,0.15)]",
"border backdrop-blur-sm",
"bg-[rgb(var(--surface-raised))] border-[rgb(var(--border))]"
)}
style={{
borderLeftColor: customIcon ? COLORS[variant] : undefined,
borderLeftWidth: customIcon ? "4px" : undefined,
}}
>
{customIcon && (
<div className="flex-shrink-0 mt-0.5" style={{ color: COLORS[variant] }}>
{customIcon}
</div>
)}
<div className="flex-1 min-w-0 pr-2">
<p className="text-[13px] font-medium text-[rgb(var(--text-primary))] leading-snug">
{message}
</p>
{description && (
<p className="text-[12px] text-[rgb(var(--text-secondary))] mt-0.5 leading-snug">
{description}
</p>
)}
</div>
{action && (
<button
onClick={() => {
action.onClick();
onClose();
}}
className="text-[12px] font-medium text-[rgb(var(--accent))] hover:underline whitespace-nowrap ml-2"
>
{action.label}
</button>
)}
{dismissible && (
<button
onClick={onClose}
className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded text-[rgb(var(--text-tertiary))] hover:text-[rgb(var(--text-secondary))] transition-colors"
aria-label="Dismiss"
>
<X className="w-3 h-3" />
</button>
)}
</div>
</div>,
document.body
);
}Accessibility
- →Use role='status' with aria-live='polite' for informational messages.
- →Use role='alert' only for critical errors requiring immediate attention.
- →Provide aria-label on dismiss button for screen readers.
- →Don't rely on color alone — include text and icons.
- →Timer should pause on hover and focus for keyboard users.
- →Avoid snackbars for critical errors or required confirmations.