Snackbar
Snackbars show short updates about app processes at the bottom of the screen
Usage
Snackbars inform users of a process that an app has performed or will perform. They appear temporarily, towards the bottom of the screen. They shouldn’t interrupt the user experience, and they don’t require user input to disappear.
Instalation
Run the following command:
npm i @radix-ui/react-toast
Copy and paste the following code into your project.
snackbar.tsx
'use client'
import * as React from 'react'
import * as SnackbarPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { useSnackbar } from '@/hooks/use-snackbar'
import { Button } from '@/components/ui/button'
import { Icon } from '@/components/ui/icon'
import { IconButton } from '@/components/ui/icon-button'
const SnackbarPrimitiveProvider = SnackbarPrimitives.Provider
const SnackbarViewport = React.forwardRef<
React.ElementRef<typeof SnackbarPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof SnackbarPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<SnackbarPrimitives.Viewport
ref={ref}
className={cn(
'sm:gap- fixed inset-x-0 bottom-0 z-[100] mx-auto flex max-h-screen w-fit min-w-full flex-col items-start px-2 md:min-w-[380px] md:max-w-[600px]',
// Uncomment this line for snackbar from the top right in desktop
// 'md:bottom-[inherit] md:right-0 md:top-0',
className
)}
{...props}
/>
))
SnackbarViewport.displayName = SnackbarPrimitives.Viewport.displayName
const snackbarVariants = cva(
[
'bg-inverseSurface min-h-[48px] text-inverseOnSurface data-[swipe=move]:transition-none rounded-xs relative pointer-events-auto flex w-full items-stretch justify-between gap-3 overflow-hidden py-2 px-4 shadow-lg transition-[transform,opacity] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-snackbar-in origin-bottom data-[swipe=end]:slide-out-to-right-full data-[swipe=end]:slide-out-to-bottom-0 before:inset-y-0 before:w-1 before:left-0 before:m-2 before:rounded-full',
// Uncomment this line for snackbar from the top right in desktop
// 'data-[state=open]:md:animate-in data-[state=open]:md:slide-in-from-right-full data-[state=open]:md:slide-in-from-bottom-0 data-[state=closed]:md:slide-out-to-right-full data-[state=closed]:md:slide-out-to-bottom-0',
],
{
variants: {
severity: {
default: 'before:hidden',
success: 'pl-6 before:absolute before:bg-successContainer',
error: 'pl-6 before:absolute before:bg-errorContainer',
info: 'pl-6 before:absolute before:bg-infoContainer',
warning: 'pl-6 before:absolute before:bg-warningContainer',
},
},
defaultVariants: {
severity: 'default',
},
}
)
const Snackbar = React.forwardRef<
React.ElementRef<typeof SnackbarPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SnackbarPrimitives.Root> &
VariantProps<typeof snackbarVariants>
>(({ className, severity, ...props }, ref) => {
return (
<SnackbarPrimitives.Root
ref={ref}
className={cn(snackbarVariants({ severity }), className)}
{...props}
/>
)
})
Snackbar.displayName = SnackbarPrimitives.Root.displayName
const SnackbarAction = React.forwardRef<
React.ElementRef<typeof SnackbarPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof SnackbarPrimitives.Action>
>(({ ...props }, ref) => (
<Button
variant="text"
size="small"
asChild
className="shrink-0 text-inversePrimary"
>
<SnackbarPrimitives.Action ref={ref} {...props} />
</Button>
))
SnackbarAction.displayName = SnackbarPrimitives.Action.displayName
const SnackbarClose = React.forwardRef<
React.ElementRef<typeof SnackbarPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof SnackbarPrimitives.Close>
>(({ className, ...props }, ref) => (
<IconButton
asChild
variant="standard"
className={cn(
'm-0 -mr-1 mb-0 mt-auto h-8 w-8 shrink-0 text-inverseOnSurface hover:bg-inverseOnSurface/10',
className
)}
>
<SnackbarPrimitives.Close ref={ref} toast-close="" {...props}>
<Icon symbol="close" />
</SnackbarPrimitives.Close>
</IconButton>
))
SnackbarClose.displayName = SnackbarPrimitives.Close.displayName
const SnackbarTitle = React.forwardRef<
React.ElementRef<typeof SnackbarPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof SnackbarPrimitives.Title>
>(({ className, ...props }, ref) => (
<SnackbarPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
))
SnackbarTitle.displayName = SnackbarPrimitives.Title.displayName
const SnackbarDescription = React.forwardRef<
React.ElementRef<typeof SnackbarPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof SnackbarPrimitives.Description>
>(({ className, ...props }, ref) => (
<SnackbarPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
))
SnackbarDescription.displayName = SnackbarPrimitives.Description.displayName
type SnackbarProps = React.ComponentPropsWithoutRef<typeof Snackbar>
type SnackbarActionElement = React.ReactElement<typeof SnackbarAction>
const SnackbarProvider = () => {
const { snackbars, elevated } = useSnackbar()
return (
<SnackbarPrimitiveProvider>
{snackbars.map(function ({
id,
title,
description,
action,
closeable = true,
...props
}) {
return (
<Snackbar key={id} {...props}>
<div className="flex grow flex-wrap items-center justify-end gap-x-3 gap-y-1">
<div className="grow">
{title && <SnackbarTitle>{title}</SnackbarTitle>}
{description && (
<SnackbarDescription>{description}</SnackbarDescription>
)}
</div>
{action}
</div>
{closeable && <SnackbarClose />}
</Snackbar>
)
})}
<SnackbarViewport
className={elevated ? 'bottom-[88px] md:bottom-4' : undefined}
/>
</SnackbarPrimitiveProvider>
)
}
export {
SnackbarProvider,
SnackbarPrimitiveProvider,
SnackbarViewport,
Snackbar,
SnackbarTitle,
SnackbarDescription,
SnackbarClose,
SnackbarAction,
type SnackbarProps,
type SnackbarActionElement,
}
use-snackbar.ts
// Inspired by react-hot-toast library
// From shadcn/ui
import * as React from 'react'
import type {
SnackbarActionElement,
SnackbarProps,
} from '@/components/ui/snackbar'
const SNACKBAR_LIMIT = 1
const SNACKBAR_REMOVE_DELAY = 1000000
type SnackbarManagerType = SnackbarProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: SnackbarActionElement
closeable?: boolean
}
const actionTypes = {
ADD_SNACKBAR: 'ADD_SNACKBAR',
UPDATE_SNACKBAR: 'UPDATE_SNACKBAR',
DISMISS_SNACKBAR: 'DISMISS_SNACKBAR',
REMOVE_SNACKBAR: 'REMOVE_SNACKBAR',
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType['ADD_SNACKBAR']
snackbar: SnackbarManagerType
}
| {
type: ActionType['UPDATE_SNACKBAR']
snackbar: Partial<SnackbarManagerType>
}
| {
type: ActionType['DISMISS_SNACKBAR']
snackbarId?: SnackbarManagerType['id']
}
| {
type: ActionType['REMOVE_SNACKBAR']
snackbarId?: SnackbarManagerType['id']
}
interface State {
snackbars: SnackbarManagerType[]
}
const snackbarTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (snackbarId: string) => {
if (snackbarTimeouts.has(snackbarId)) {
return
}
const timeout = setTimeout(() => {
snackbarTimeouts.delete(snackbarId)
dispatch({
type: 'REMOVE_SNACKBAR',
snackbarId: snackbarId,
})
}, SNACKBAR_REMOVE_DELAY)
snackbarTimeouts.set(snackbarId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_SNACKBAR':
return {
...state,
snackbars: [action.snackbar, ...state.snackbars].slice(
0,
SNACKBAR_LIMIT
),
}
case 'UPDATE_SNACKBAR':
return {
...state,
snackbars: state.snackbars.map((t) =>
t.id === action.snackbar.id ? { ...t, ...action.snackbar } : t
),
}
case 'DISMISS_SNACKBAR': {
const { snackbarId } = action
if (snackbarId) {
addToRemoveQueue(snackbarId)
} else {
state.snackbars.forEach((snackbar) => {
addToRemoveQueue(snackbar.id)
})
}
return {
...state,
snackbars: state.snackbars.map((t) =>
t.id === snackbarId || snackbarId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case 'REMOVE_SNACKBAR':
if (action.snackbarId === undefined) {
return {
...state,
snackbars: [],
}
}
return {
...state,
snackbars: state.snackbars.filter((t) => t.id !== action.snackbarId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { snackbars: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
interface Snackbar extends Omit<SnackbarManagerType, 'id'> {}
function snackbar({ ...props }: Snackbar) {
const id = genId()
const update = (props: SnackbarManagerType) =>
dispatch({
type: 'UPDATE_SNACKBAR',
snackbar: { ...props, id },
})
const dismiss = () => dispatch({ type: 'DISMISS_SNACKBAR', snackbarId: id })
dispatch({
type: 'ADD_SNACKBAR',
snackbar: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useSnackbar() {
const [state, setState] = React.useState<State>(memoryState)
let bottomNavigationRef = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
listeners.push(setState)
const railElement = document.querySelector('.bottom-bar')
bottomNavigationRef.current = railElement as HTMLDivElement
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
snackbar,
dismiss: (snackbarId?: string) =>
dispatch({ type: 'DISMISS_SNACKBAR', snackbarId }),
elevated: bottomNavigationRef !== null,
}
}
export { useSnackbar, snackbar }
Update the import paths to match your project setup.
Add the SnackbarProvider
to your root layout.
import { SnackbarProvider } from '@/components/ui/snackbar'
export default function RootLayout({ children }) {
return (
<html lang="en">
<head />
<body>
<main>{children}</main>
<SnackbarProvider />
</body>
</html>
)
}
Examples
Snackbar example
The useSnackbar
hook returns a snackbar
function that you can use to display a snackbar.
import { useSnackbar } from '@/hooks/use-snackbar'
To display multiple snackbars at the same time, you can update the SNACKBAR_LIMIT
in use-snackbar.tsx
, but this is not recommended.
import { useSnackbar } from '@/hooks/use-snackbar'
import { Button } from '@/components/ui/button'
export const SnackbarExample = () => {
const { snackbar } = useSnackbar()
const showSnackbar = () => {
snackbar({
description: 'Saved in "Vacation" album.',
})
}
return <Button onClick={showSnackbar}>Show snackbar</Button>
}
Snackbar with action
Snackbars can display a single text button that lets users take action on a process performed by the app.
import { useSnackbar } from '@/hooks/use-snackbar'
import { Button } from '@/components/ui/button'
import { SnackbarAction } from '@/components/ui/snackbar'
export const ActionSnackbar = () => {
const { snackbar } = useSnackbar()
const showSnackbar = () => {
snackbar({
description:
'Connection timed out. Showing the lates locally served version.',
action: <SnackbarAction altText="Retry">Retry</SnackbarAction>,
})
}
return <Button onClick={showSnackbar}>Show snackbar</Button>
}
Snackbar without close button
You also have the option to hide the close button on the snackbar by using the param closeable
set to false
.
Note: it will still being dragable.
import { useSnackbar } from '@/hooks/use-snackbar'
import { Button } from '@/components/ui/button'
export const SnackbarWithoutCloseButton = () => {
const { snackbar } = useSnackbar()
const showSnackbar = () => {
snackbar({
description: 'Photo have been saved to your album.',
closeable: false,
})
}
return <Button onClick={showSnackbar}>Show snackbar</Button>
}
Snackbar with severity
Using the param severity
you can set the severity of the snackbar if necessary.
Severity
import { useSnackbar } from '@/hooks/use-snackbar'
import { Button } from '@/components/ui/button'
export const SeveritySnackbar = () => {
const { snackbar } = useSnackbar()
const showSnackbar = () => {
snackbar({
description: 'This is a success snackbar',
severity: 'success',
})
}
return <Button onClick={showSnackbar}>Show snackbar</Button>
}
Snackbar with title
Using the param title
you can set a title to the snackbar if necessary.
import { useSnackbar } from '@/hooks/use-snackbar'
import { Button } from '@/components/ui/button'
export const SnackbarWithTitle = () => {
const { snackbar } = useSnackbar()
const showSnackbar = () => {
snackbar({
title: 'Oopsie-Daisy Alert!',
description:
"It seems you pressed the 'Do not press' button. Don't worry, I won't tell anyone!",
closeable: false,
duration: 5000,
})
}
return <Button onClick={showSnackbar}>Do not press</Button>
}