$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
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.