V1.0

Base Components

Notification

Notifications typically appear temporarily in corners, conveying information like payment and update notifications.

This component is intended to be used with the <Alert> component. Refer to the Alert documentation for setup instructions before using this component.

Install the following dependencies:

terminal
npm install @radix-ui/react-toast

Create a notification.tsx file and paste the following code into it.

/components/ui/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,
};

Create a notification-provider.tsx file and paste the following code into it.

/components/ui/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 };

Create a use-notification.ts file and paste the following code into it.

/hooks/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 };

Add the NotificationProvider component in layout.tsx:

/app/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>
  );
}

Update the import paths to match your project setup.

Examples

Variants

With Action

With Secondary Action

API Reference

This component is based on the Radix UI Toast primitives. Refer to their documentation for the API reference.

© 2024 AlignUI Design System. All rights reserved.