Base Components
Notifications typically appear temporarily in corners, conveying information like payment and update notifications.
<Alert>
npm install @radix-ui/react-toast
notification.tsx
// AlignUI Notification v0.0.0 import * as React from 'react'; import * as NotificationPrimitives from '@radix-ui/react-toast'; import { RiAlertFill, RiCheckboxCircleFill, RiErrorWarningFill, RiInformationFill, RiMagicFill, } from '@remixicon/react'; import * as Alert from '@/components/ui/alert'; import { cnExt } from '@/utils/cn'; const NotificationProvider = NotificationPrimitives.Provider; const NotificationAction = NotificationPrimitives.Action; const NotificationViewport = React.forwardRef< React.ComponentRef<typeof NotificationPrimitives.Viewport>, React.ComponentPropsWithoutRef<typeof NotificationPrimitives.Viewport> >(({ className, ...rest }, forwardedRef) => ( <NotificationPrimitives.Viewport ref={forwardedRef} className={cnExt( 'fixed left-0 top-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-5 p-4 sm:bottom-0 sm:left-auto sm:right-0 sm:top-auto sm:max-w-[438px] sm:flex-col sm:p-6', className, )} {...rest} /> )); NotificationViewport.displayName = 'NotificationViewport'; type NotificationProps = React.ComponentPropsWithoutRef< typeof NotificationPrimitives.Root > & Pick< React.ComponentPropsWithoutRef<typeof Alert.Root>, 'status' | 'variant' > & { title?: string; description?: React.ReactNode; action?: React.ReactNode; disableDismiss?: boolean; }; const Notification = React.forwardRef< React.ComponentRef<typeof NotificationPrimitives.Root>, NotificationProps >( ( { className, status, variant = 'filled', title, description, action, disableDismiss = false, ...rest }: NotificationProps, forwardedRef, ) => { let Icon: React.ElementType; switch (status) { case 'success': Icon = RiCheckboxCircleFill; break; case 'warning': Icon = RiAlertFill; break; case 'error': Icon = RiErrorWarningFill; break; case 'information': Icon = RiInformationFill; break; case 'feature': Icon = RiMagicFill; break; default: Icon = RiErrorWarningFill; break; } return ( <NotificationPrimitives.Root ref={forwardedRef} className={cnExt( // open 'data-[state=open]:animate-in data-[state=open]:max-[639px]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-right-full', // close 'data-[state=closed]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:max-[639px]:slide-out-to-top-full data-[state=closed]:sm:slide-out-to-right-full', // swipe 'data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[swipe=end]:animate-out', className, )} asChild {...rest} > <Alert.Root variant={variant} status={status} size='large'> <Alert.Icon as={Icon} aria-hidden='true' /> <div className='flex w-full flex-col gap-2.5'> <div className='flex w-full flex-col gap-1'> {title && ( <NotificationPrimitives.Title className='text-label-sm'> {title} </NotificationPrimitives.Title> )} {description && ( <NotificationPrimitives.Description> {description} </NotificationPrimitives.Description> )} </div> {action && <div className='flex items-center gap-2'>{action}</div>} </div> {!disableDismiss && ( <NotificationPrimitives.Close aria-label='Close'> <Alert.CloseIcon /> </NotificationPrimitives.Close> )} </Alert.Root> </NotificationPrimitives.Root> ); }, ); Notification.displayName = 'Notification'; export { Notification as Root, NotificationProvider as Provider, NotificationAction as Action, NotificationViewport as Viewport, type NotificationProps, };
notification-provider.tsx
'use client'; import * as Notification from '@/components/ui/notification'; import { useNotification } from '@/hooks/use-notification'; const NotificationProvider = () => { const { notifications } = useNotification(); return ( <Notification.Provider> {notifications.map(({ id, ...rest }) => { return <Notification.Root key={id} {...rest} />; })} <Notification.Viewport /> </Notification.Provider> ); }; export { NotificationProvider };
use-notification.ts
'use client'; import * as React from 'react'; import type { NotificationProps } from '@/components/ui/notification'; const NOTIFICATION_LIMIT = 1; const NOTIFICATION_REMOVE_DELAY = 1000000; type NotificationPropsWithId = NotificationProps & { id: string; }; const actionTypes = { ADD_NOTIFICATION: 'ADD_NOTIFICATION', UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION', DISMISS_NOTIFICATION: 'DISMISS_NOTIFICATION', REMOVE_NOTIFICATION: 'REMOVE_NOTIFICATION', } as const; let count = 0; function genId() { count = (count + 1) % Number.MAX_SAFE_INTEGER; return count.toString(); } type ActionType = typeof actionTypes; type Action = | { type: ActionType['ADD_NOTIFICATION']; notification: NotificationPropsWithId; } | { type: ActionType['UPDATE_NOTIFICATION']; notification: Partial<NotificationPropsWithId>; } | { type: ActionType['DISMISS_NOTIFICATION']; notificationId?: NotificationPropsWithId['id']; } | { type: ActionType['REMOVE_NOTIFICATION']; notificationId?: NotificationPropsWithId['id']; }; interface State { notifications: NotificationPropsWithId[]; } const notificationTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); const addToRemoveQueue = (notificationId: string) => { if (notificationTimeouts.has(notificationId)) { return; } const timeout = setTimeout(() => { notificationTimeouts.delete(notificationId); dispatch({ type: 'REMOVE_NOTIFICATION', notificationId: notificationId, }); }, NOTIFICATION_REMOVE_DELAY); notificationTimeouts.set(notificationId, timeout); }; export const reducer = (state: State, action: Action): State => { switch (action.type) { case 'ADD_NOTIFICATION': return { ...state, notifications: [action.notification, ...state.notifications].slice( 0, NOTIFICATION_LIMIT, ), }; case 'UPDATE_NOTIFICATION': return { ...state, notifications: state.notifications.map((t) => t.id === action.notification.id ? { ...t, ...action.notification } : t, ), }; case 'DISMISS_NOTIFICATION': { const { notificationId } = action; if (notificationId) { addToRemoveQueue(notificationId); } else { state.notifications.forEach((notification) => { addToRemoveQueue(notification.id); }); } return { ...state, notifications: state.notifications.map((t) => t.id === notificationId || notificationId === undefined ? { ...t, open: false, } : t, ), }; } case 'REMOVE_NOTIFICATION': if (action.notificationId === undefined) { return { ...state, notifications: [], }; } return { ...state, notifications: state.notifications.filter( (t) => t.id !== action.notificationId, ), }; } }; const listeners: Array<(state: State) => void> = []; let memoryState: State = { notifications: [] }; function dispatch(action: Action) { if (action.type === 'ADD_NOTIFICATION') { const notificationExists = memoryState.notifications.some( (t) => t.id === action.notification.id, ); if (notificationExists) { return; } } memoryState = reducer(memoryState, action); listeners.forEach((listener) => { listener(memoryState); }); } type Notification = Omit<NotificationPropsWithId, 'id'>; function notification({ ...props }: Notification & { id?: string }) { const id = props?.id || genId(); const update = (props: Notification) => dispatch({ type: 'UPDATE_NOTIFICATION', notification: { ...props, id }, }); const dismiss = () => dispatch({ type: 'DISMISS_NOTIFICATION', notificationId: id }); dispatch({ type: 'ADD_NOTIFICATION', notification: { ...props, id, open: true, onOpenChange: (open: boolean) => { if (!open) dismiss(); }, }, }); return { id: id, dismiss, update, }; } function useNotification() { const [state, setState] = React.useState<State>(memoryState); React.useEffect(() => { listeners.push(setState); return () => { const index = listeners.indexOf(setState); if (index > -1) { listeners.splice(index, 1); } }; }, [state]); return { ...state, notification, dismiss: (notificationId?: string) => dispatch({ type: 'DISMISS_NOTIFICATION', notificationId }), }; } export { notification, useNotification };
NotificationProvider
layout.tsx
import { NotificationProvider } from '@/components/ui/notification-provider'; export default function RootLayout({ children }) { return ( <html lang='en'> <head /> <body> <main>{children}</main> <NotificationProvider /> </body> </html> ); }
This component is based on the Radix UI Toast primitives. Refer to their documentation for the API reference.