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>
}