$easing: ease-in-out;

html, body {
  width: 100%;
  height: 100%;
  font-family: 'Montserrat', serif;
}

.app {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: url('https://cdn.yoavik.com/codepen/ios-notifications/background.jpg'); // iOS 16 default wallpaper :)
  background-size: cover;
  background-position: center;
}

.add-button {
  background-color: #0284c7;
  color: white;
  border: none;
  border-radius: 2rem;
  padding: 0.8rem 1.2rem 0.8rem 0.8rem;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: all 0.2s ease-out;
  font-size: 1.2rem;
  box-shadow: 0 0.2rem 0.2rem rgba(0, 0, 0, 0.1), 0 0.5rem 1rem rgba(0, 0, 0, 0.3);

  &:hover {
    background-color: #0ea5e9;
    transform: translateY(-0.1rem);
    box-shadow: 0 0.3rem 0.2rem rgba(0, 0, 0, 0.1), 0 0.6rem 1rem rgba(0, 0, 0, 0.3);

    svg {
      transform: rotate(90deg);
    }
  }

  &:active {
    transition: all 0.1s ease-out;
    transform: translateY(0rem);
  }

  svg {
    transition: transform 0.3s ease-out;
    margin-right: 0.3rem;
    font-size: 1.5rem;
  }
}

.notifications {
  --width: 20rem;
  --height: 4.5rem;
  --gap: 1rem;

  position: fixed;
  bottom: 0;
  pointer-events: none;

  &:hover {
    .notification {
      // Unstack notifications when hovering over the container
      transform: translateY(0) scale(1);

      .notification-inner {
        opacity: 1;
        background-color: hsl(0 0% 100% / 40%);
      }

      &.exit-active {
        // When the list is expanded, avoid animating the y position
        transform: translateY(0) scale(0.5);

        .notification-inner {
          background-color: hsl(100 0% 100% / 100%) !important;
        }
      }
    }
  }

  .notification {
    display: flex;
    transform: translateY(var(--y)) scale(var(--scale));
    transform-origin: center;
    transition: all var(--duration) $easing;
    pointer-events: auto;

    &.enter {
      transform: translateY(100%) scale(1);
      // Animate the first notification to slide in from the bottom
      margin-bottom: calc((var(--height) + var(--gap)) * -1);
    }

    &.enter-active {
      transform: translateY(var(--y)) scale(var(--scale));
      margin-bottom: 0;
    }

    &.exit-active {
      transform: translateY(calc(var(--y) - 10%)) scale(calc(var(--scale) - 0.1));
      margin-bottom: calc((var(--height) + var(--gap)) * -1);

      .notification-inner {
        opacity: 0;
      }
    }
  }

  .notification-inner {
    background-color: var(--bg);
    -webkit-backdrop-filter: blur(0.5rem);
    backdrop-filter: blur(0.5rem);
    padding: 0 1rem;
    border-radius: 0.5rem;
    width: var(--width);
    height: var(--height);
    margin-bottom: var(--gap);
    opacity: var(--opacity);
    transition: all var(--duration) $easing;
    display: flex;
    align-items: center;

    h2 {
      font-weight: bold;
      font-size: 0.9rem;
    }

    p {
      margin-top: 0.5rem;
      font-size: 0.8rem;
    }

    .close {
      background: none;
      border: none;
      position: absolute;
      right: 0;
      top: 0;
      font-size: 0.8rem;
      padding: 0.5rem;
      cursor: pointer;
      display: flex;
    }

    .icon {
      display: flex;
      align-items: center;
      justify-content: center;
      width: 2.5rem;
      height: 2.5rem;
      border-radius: 0.3rem;
      margin-right: 1rem;
      font-size: 1rem;
      color: white;

      &.error {
        background-color: #f87171;
      }

      &.success {
        background-color: #10b981;
      }

      &.info {
        background-color: #60a5fa;
      }

      &.warning {
        background-color: #f59e0b;
      }
    }
  }
}
View Compiled
import ReactDOM from 'https://cdn.skypack.dev/react-dom';
import React, { useCallback, useState, useRef, memo } from 'https://cdn.skypack.dev/react';
import { nanoid } from 'https://cdn.skypack.dev/nanoid@5.0.5';
import { BsCheckLg, BsXLg, BsInfoLg, BsExclamationLg } from 'https://cdn.skypack.dev/react-icons@4.12.0/bs';
import { MdClose, MdAdd } from 'https://cdn.skypack.dev/react-icons@4.12.0/md';
import { TransitionGroup, CSSTransition } from 'https://cdn.skypack.dev/react-transition-group';

const TIMEOUT = 5000; // Notifications will be removed automatically after 5 seconds, unless hovered over.
const ANIMATION_DURATION = 400;
const MAX_NOTIFICATIONS = 5;
const STACKING_OVERLAP = 0.9; // A range from 0 to 1 representing the percentage of the notification's height that should overlap the next notification
const NOTIFICATION_ICON = {
    success: BsCheckLg,
    error: BsXLg,
    info: BsInfoLg,
    warning: BsExclamationLg,
};

enum Type {
    success = 'success',
    error = 'error',
    info = 'info',
    warning = 'warning',
}

type Notification = {
    id?: string
    type: Type
    title: string
    content: string
    timeout: number
}

const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;

const useNotifications = () => {
    const timeouts = useRef<ReturnType<typeof setTimeout>[]>([]);
    const paused = useRef(null);
    const [notifications, setNotifications] = useState([] as Notification[]);

    const add = useCallback((n: Notification) => {
        const notification = { ...n };
        notification.id = nanoid();
        notification.timeout += Date.now();
        setNotifications(n => {
            const next = [notification, ...n];
            if (n.length >= MAX_NOTIFICATIONS) {
                next.pop();
            }
            return next;
        });
        timeouts.current.push(setTimeout(() => {
            remove(notification.id);
        }, notification.timeout - Date.now()));
    }, []);

    const pause = useCallback(() => {
        timeouts.current.forEach(clearTimeout);
        timeouts.current = [];
        paused.current = Date.now();
    }, []);

    const resume = useCallback(() => {
        setNotifications(n => {
            return n.map(notification => {
                notification.timeout += Date.now() - paused.current;
                timeouts.current.push(setTimeout(() => {
                    remove(notification.id);
                }, notification.timeout - Date.now()));
                return notification;
            });
        });
    }, [notifications]);

    const remove = useCallback((id: string) => {
        setNotifications(n => n.filter(n => n.id !== id));
    }, []);

    const props = { notifications, remove, pause, resume };

    return { props, add };
};

interface NotificationProps extends Notification {
    index: number
    total: number
    remove: (id: string) => void
}

const Notification = memo(({ id, title, content, type, index, total, remove }: NotificationProps) => {
    const Icon = NOTIFICATION_ICON[type];
    const inverseIndex = total - index - 1;
    const scale = 1 - inverseIndex * 0.05;
    const opacity = 1 - (inverseIndex / total) * 0.1;
    const bg = `hsl(0 0% ${100 - inverseIndex * 15}% / 40%)`;
    const y = inverseIndex * 100 * STACKING_OVERLAP;

    return (
        <div
            className='notification'
            style={{'--bg': bg, '--opacity': opacity, '--scale': scale, '--y': `${y}%`}}>
            <div className='notification-inner'>
                <div className={`icon ${type}`}>
                    <Icon/>
                </div>
                <div>
                    <h2>{title}</h2>
                    <p>{content}</p>
                </div>
                <button className='close' onClick={() => remove(id)}><MdClose/></button>
            </div>
        </div>
    );
});

interface NotificationsProps {
    notifications: Notification[]
    remove: (id: string) => void
    pause: () => void
    resume: () => void
    animationDuration: number
}

const Notifications = ({ notifications, remove, pause, resume, animationDuration }: NotificationsProps) => {

    return (
        <TransitionGroup className='notifications' style={{ '--duration': `${animationDuration}ms` }} onMouseEnter={pause} onMouseLeave={resume}>
            {[...notifications].reverse().map((notification, index) => (
                <CSSTransition key={notification.id} timeout={animationDuration}>
                    <Notification
                        {...notification}
                        remove={remove}
                        index={index}
                        total={notifications.length}/>
                </CSSTransition>
            ))}
        </TransitionGroup>
    );
}

export const App = () => {
    const { props, add } = useNotifications();
    return (
        <div className='app'>
            <Notifications {...props} animationDuration={ANIMATION_DURATION}/>
            <button className='add-button' onClick={() => {
                const types =  Object.keys(Type);
                const type = types[randomInt(0, types.length - 1)] as Type;
                const title = `${type[0].toUpperCase() + type.slice(1)} Notification`;
                add({ title, content: 'Some notification description', timeout: TIMEOUT, type })
            }}><MdAdd/>Add Notification</button>
        </div>
    );
};

ReactDOM.render(
  <App/>,
  document.body
);
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.