- Calendarnow
Standup moved to 10:30
Notifications
import { useEffect, useRef, useState } from 'react';
const ITEMS = [
{ app: 'Calendar', when: 'now', text: 'Standup moved to 10:30' },
{ app: 'Messages', when: '2m', text: 'Ana: the prints are in' },
{ app: 'Deploys', when: '5m', text: 'Production is live in 41s' },
];
const H = 52;
const GAP = 8;
const css = `
.card-notifications { box-sizing: border-box; width: 240px; padding: 12px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-notifications__head { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
.card-notifications__head b { margin-right: auto; font-size: 12px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-notifications__btn { height: 22px; padding: 0 8px; border: 0; border-radius: 999px; background: light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font: inherit; font-size: 10.5px; font-weight: 600; cursor: pointer; }
.card-notifications__btn:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.card-notifications__stack { position: relative; transition: height 0.45s cubic-bezier(0.3, 1.2, 0.5, 1); }
.card-notifications__list { margin: 0; padding: 0; list-style: none; }
.card-notifications__item {
position: absolute;
left: 0;
right: 0;
top: 0;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
height: ${H}px;
padding: 0 10px;
border-radius: 12px;
background: light-dark(#ebedf0, #232529);
box-shadow: 0 4px 10px -4px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
transition: transform 0.45s cubic-bezier(0.3, 1.25, 0.5, 1), opacity 0.3s;
}
.card-notifications__icon { flex: none; width: 26px; height: 26px; border-radius: 8px; background: light-dark(#cbced3, #393c41); }
.card-notifications__item:nth-child(1) .card-notifications__icon { background: light-dark(#50535b, #c6c8cd); }
.card-notifications__item:nth-child(2) .card-notifications__icon { background: light-dark(#6d7179, #7d8087); }
.card-notifications__body { min-width: 0; font-size: 11px; }
.card-notifications__app { display: flex; gap: 6px; color: light-dark(#5f626a, #a7abb2); }
.card-notifications__text { margin: 1px 0 0; overflow: hidden; color: light-dark(#373a40, #f1f2f3); white-space: nowrap; text-overflow: ellipsis; }
.card-notifications__empty { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; font-size: 11px; color: light-dark(#5f626a, #a7abb2); opacity: 0; transition: opacity 0.3s 0.3s; }
.card-notifications--clear .card-notifications__empty { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.card-notifications__stack, .card-notifications__item { transition: opacity 0.2s; }
}
`;
export function NotificationsCard() {
const [open, setOpen] = useState(false);
const [clear, setClear] = useState(false);
const timer = useRef(0);
useEffect(() => () => clearTimeout(timer.current), []);
// Stacked, each one behind sits lower, smaller and fainter; open, they lay out as a list, a beat apart.
const place = (i: number): React.CSSProperties => {
if (clear) return { transform: `translateX(110%)`, opacity: 0, transitionDelay: `${i * 70}ms`, zIndex: 3 - i };
if (open) return { transform: `translateY(${i * (H + GAP)}px)`, transitionDelay: `${i * 50}ms`, zIndex: 3 - i };
return { transform: `translateY(${i * 9}px) scale(${1 - i * 0.06})`, opacity: 1 - i * 0.3, transitionDelay: `${(2 - i) * 40}ms`, zIndex: 3 - i };
};
return (
<>
<style>
{css}
</style>
<article className={`card-notifications${clear ? ' card-notifications--clear' : ''}`}>
<div className="card-notifications__head">
<b>Notifications</b>
<button type="button" className="card-notifications__btn" aria-expanded={open} onClick={() => setOpen((o) => !o)} disabled={clear}>
{open ? 'Show less' : 'Show all'}
</button>
<button
type="button"
className="card-notifications__btn"
disabled={clear}
onClick={() => {
setClear(true);
// The demo deals them back after a moment.
timer.current = window.setTimeout(() => {
setClear(false);
setOpen(false);
}, 1800);
}}
>
Clear
</button>
</div>
<div className="card-notifications__stack" style={{ height: open && !clear ? ITEMS.length * (H + GAP) - GAP : H + 18 }}>
<ul className="card-notifications__list">
{ITEMS.map((n, i) => (
<li key={n.app} className="card-notifications__item" style={place(i)} aria-hidden={clear || (!open && i > 0)}>
<span className="card-notifications__icon" aria-hidden="true" />
<div className="card-notifications__body">
<span className="card-notifications__app">
{n.app}
<span>{n.when}</span>
</span>
<p className="card-notifications__text">{n.text}</p>
</div>
</li>
))}
</ul>
<p className="card-notifications__empty" aria-live="polite">
{clear ? 'No new notifications' : ''}
</p>
</div>
</article>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardNotifications(el) on the mounted element instead.
<article class="card-notifications">
<div class="card-notifications__head">
<b>Notifications</b>
<button type="button" class="card-notifications__btn" aria-expanded="false">Show all</button>
<button type="button" class="card-notifications__btn">Clear</button>
</div>
<div class="card-notifications__stack" style="height:70px">
<ul class="card-notifications__list">
<li class="card-notifications__item" style="transform:translateY(0px) scale(1);opacity:1;transition-delay:80ms;z-index:3" aria-hidden="false">
<span class="card-notifications__icon" aria-hidden="true"></span>
<div class="card-notifications__body">
<span class="card-notifications__app">
Calendar
<span>now</span>
</span>
<p class="card-notifications__text">Standup moved to 10:30</p>
</div>
</li>
<li class="card-notifications__item" style="transform:translateY(9px) scale(0.94);opacity:0.7;transition-delay:40ms;z-index:2" aria-hidden="true">
<span class="card-notifications__icon" aria-hidden="true"></span>
<div class="card-notifications__body">
<span class="card-notifications__app">
Messages
<span>2m</span>
</span>
<p class="card-notifications__text">Ana: the prints are in</p>
</div>
</li>
<li class="card-notifications__item" style="transform:translateY(18px) scale(0.88);opacity:0.4;transition-delay:0ms;z-index:1" aria-hidden="true">
<span class="card-notifications__icon" aria-hidden="true"></span>
<div class="card-notifications__body">
<span class="card-notifications__app">
Deploys
<span>5m</span>
</span>
<p class="card-notifications__text">Production is live in 41s</p>
</div>
</li>
</ul>
<p class="card-notifications__empty" aria-live="polite"></p>
</div>
</article>.card-notifications { box-sizing: border-box; width: 240px; padding: 12px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-notifications__head { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
.card-notifications__head b { margin-right: auto; font-size: 12px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-notifications__btn { height: 22px; padding: 0 8px; border: 0; border-radius: 999px; background: light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font: inherit; font-size: 10.5px; font-weight: 600; cursor: pointer; }
.card-notifications__btn:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.card-notifications__stack { position: relative; transition: height 0.45s cubic-bezier(0.3, 1.2, 0.5, 1); }
.card-notifications__list { margin: 0; padding: 0; list-style: none; }
.card-notifications__item {
position: absolute;
left: 0;
right: 0;
top: 0;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
height: 52px;
padding: 0 10px;
border-radius: 12px;
background: light-dark(#ebedf0, #232529);
box-shadow: 0 4px 10px -4px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
transition: transform 0.45s cubic-bezier(0.3, 1.25, 0.5, 1), opacity 0.3s;
}
.card-notifications__icon { flex: none; width: 26px; height: 26px; border-radius: 8px; background: light-dark(#cbced3, #393c41); }
.card-notifications__item:nth-child(1) .card-notifications__icon { background: light-dark(#50535b, #c6c8cd); }
.card-notifications__item:nth-child(2) .card-notifications__icon { background: light-dark(#6d7179, #7d8087); }
.card-notifications__body { min-width: 0; font-size: 11px; }
.card-notifications__app { display: flex; gap: 6px; color: light-dark(#5f626a, #a7abb2); }
.card-notifications__text { margin: 1px 0 0; overflow: hidden; color: light-dark(#373a40, #f1f2f3); white-space: nowrap; text-overflow: ellipsis; }
.card-notifications__empty { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; font-size: 11px; color: light-dark(#5f626a, #a7abb2); opacity: 0; transition: opacity 0.3s 0.3s; }
.card-notifications--clear .card-notifications__empty { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.card-notifications__stack, .card-notifications__item { transition: opacity 0.2s; }
}function initCardNotifications(root) {
var H = 52;
var GAP = 8;
var btns = root.querySelectorAll('.card-notifications__btn');
var stack = root.querySelector('.card-notifications__stack');
var items = root.querySelectorAll('.card-notifications__item');
var empty = root.querySelector('.card-notifications__empty');
var open = false;
var clear = false;
var timer = 0;
// Stacked, each one behind sits lower, smaller and fainter; open, they lay out as a list, a beat apart.
function render() {
root.classList.toggle('card-notifications--clear', clear);
btns[0].setAttribute('aria-expanded', String(open));
btns[0].textContent = open ? 'Show less' : 'Show all';
btns[0].disabled = clear;
btns[1].disabled = clear;
stack.style.height = (open && !clear ? items.length * (H + GAP) - GAP : H + 18) + 'px';
items.forEach(function (item, i) {
var s = item.style;
s.zIndex = 3 - i;
if (clear) {
s.transform = 'translateX(110%)';
s.opacity = 0;
s.transitionDelay = i * 70 + 'ms';
} else if (open) {
s.transform = 'translateY(' + i * (H + GAP) + 'px)';
s.opacity = '';
s.transitionDelay = i * 50 + 'ms';
} else {
s.transform = 'translateY(' + i * 9 + 'px) scale(' + (1 - i * 0.06) + ')';
s.opacity = 1 - i * 0.3;
s.transitionDelay = (2 - i) * 40 + 'ms';
}
item.setAttribute('aria-hidden', String(clear || (!open && i > 0)));
});
empty.textContent = clear ? 'No new notifications' : '';
}
function onToggle() {
open = !open;
render();
}
function onClear() {
clear = true;
render();
// The demo deals them back after a moment.
timer = setTimeout(function () {
clear = false;
open = false;
render();
}, 1800);
}
btns[0].addEventListener('click', onToggle);
btns[1].addEventListener('click', onClear);
return function () {
clearTimeout(timer);
btns[0].removeEventListener('click', onToggle);
btns[1].removeEventListener('click', onClear);
};
}
document.querySelectorAll('.card-notifications').forEach(initCardNotifications);Goal: Create a "Notifications" card.
Visual behavior: Three notifications stacked into one: open the stack and they spring apart into a list, a beat apart; clear them and they slide away one after another before the empty state fades in.
Technique:
- Every notification is absolutely placed and moved only by transform. Stacked, each one behind is lower, smaller and fainter; open, each sits at its row in a list.
- The transition delay is set per item and per direction, so they fan out top first and gather back bottom first; clearing slides them off to the right with its own stagger.
- The stack's height animates with them, so the card grows and shrinks around the list.
Accessibility: Show all is a button with aria-expanded. Notifications behind the top one are aria-hidden while stacked, and the empty message is announced in a polite live region. Under prefers-reduced-motion they swap places without moving and only fade.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Notifications" card.
Visual behavior: Three notifications stacked into one: open the stack and they spring apart into a list, a beat apart; clear them and they slide away one after another before the empty state fades in.
Technique:
- Every notification is absolutely placed and moved only by transform. Stacked, each one behind is lower, smaller and fainter; open, each sits at its row in a list.
- The transition delay is set per item and per direction, so they fan out top first and gather back bottom first; clearing slides them off to the right with its own stagger.
- The stack's height animates with them, so the card grows and shrinks around the list.
Accessibility: Show all is a button with aria-expanded. Notifications behind the top one are aria-hidden while stacked, and the empty message is announced in a polite live region. Under prefers-reduced-motion they swap places without moving and only fade.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Three notifications stacked into one: open the stack and they spring apart into a list, a beat apart; clear them and they slide away one after another before the empty state fades in.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, delay stagger, spring, card
- Techniques
- transition delay stagger, transform only, css transition
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Show all is a button with aria-expanded. Notifications behind the top one are aria-hidden while stacked, and the empty message is announced in a polite live region. Under prefers-reduced-motion they swap places without moving and only fade.
More cards
See all1. Collect
Save anything you like into a collection as you browse.
Files
Press and hold to peek
Which easing do you reach for first?
100 votes
Billed monthly
- Unlimited projects
- Version history
- Priority support