25 / 36
All 36 in 36 Tailwind Stacked Cards
- 01Tailwind Scroll-Driven Scaling Card Stack
- 02Tailwind Swipe Card Deck Drag to Dismiss
- 03Tailwind Glassmorphism Testimonial Card Stack
- 04Tailwind Stacked Cards Expand Accordion on Click
- 05Tailwind Fan Out Photo Stack on Hover
- 06Tailwind 3D Perspective Tilt Card Stack
- 07Tailwind Multi Step Form Card Stack
- 08Tailwind Tabbed Card Stack Bring to Front
- 09Tailwind Click to Front Stacked Cards Cycle
- 10Tailwind Classic Card Deck Stack Shadow
- 11Tailwind Fan Spread Hand of Cards
- 12Tailwind Polaroid Photo Stack Scattered Rotate
- 13Tailwind Staircase Offset Stacked Cards Cascade
- 14Tailwind Cards Spread Out Horizontally on Hover
- 15Tailwind Stacked Cards Lift on Hover Sibling Push
- 16Tailwind Accordion Stacked Cards Slide Out Menu
- 17Tailwind Shuffle Cards Animation Loop Deck
- 18Tailwind File Folder Tab Stacked Cards
- 19Tailwind Scattered Photos Pinch to Centre on Hover
- 20Tailwind Mobile Swipe Card Stack Translate3d
- 21Tailwind Single Accordion Card Nested Layers
- 22Tailwind Isometric Tower Stacked Cards 3D Column
- 23Tailwind 3D Flip Card Stack Reveal Back
- 24Tailwind Card Stack Tilt on Hover Quadrant
- 25Tailwind Notification Stack Pile Dismiss
- 26Tailwind Receipt Roll Unroll Stacked Items
- 27Tailwind Scratch Card Reveal Canvas Mask
- 28Tailwind Stacked Cards Hover Reveal Spread
- 29Tailwind Scroll Activated Sticky Stacking Cards
- 30Tailwind Stacked Testimonial Slider Radio
- 31Tailwind Isometric Overlapping Stacked Cards
- 32Tailwind Bento Grid Stacked Cards Hover Fan
- 33Tailwind Perspective Card Deck Depth Tunnel
- 34Tailwind Peel Back Corner Card Reveal
- 35Tailwind Timeline Stacked Milestone Cards
- 36Tailwind Spiral Tower Rotating Stacked Cards
Tailwind Notification Stack Pile Dismiss
An OS-style alert pile: new notifications spring onto the top, older ones compress below, and one toggle switches the whole thing between a compact deck and a spaced list. The two layouts are one data-open attribute on the pile — data-[open=true]: variants flip every card from absolute-and-scaled to relative-and-flat, so there is no second stylesheet for the expanded state.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="stk-25 w-full min-h-svh flex items-center justify-center px-5 py-10 font-stk25-sans bg-stk25-bg" aria-label="Notification pile">
<div class="w-[min(340px,100%)]">
<div class="mb-4 flex items-center justify-between text-stk25-ink">
<b class="text-base font-extrabold">Notifications</b>
<button class="rounded-[9px] border border-stk25-line bg-transparent px-3 py-1.5 font-stk25-mono text-xs font-medium text-stk25-body cursor-pointer transition-colors duration-200 hover:bg-stk25-btn hover:text-white focus-visible:outline-3 focus-visible:outline-stk25-ring focus-visible:outline-offset-2" id="stk-25-exp" type="button" aria-expanded="false">Expand</button>
</div>
<div class="group relative h-30 transition-[height] duration-400 data-[open=true]:h-auto" id="stk-25-pile" data-open="false" aria-live="polite">
<article class="absolute inset-x-0 top-0 flex flex-col gap-1 rounded-[14px] border border-stk25-line border-l-4 border-l-stk25-n1 bg-stk25-note px-4.5 py-4 text-stk25-ink shadow-[0_14px_30px_-18px_rgba(0,0,0,0.8)] [transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(20-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk25 data-[gone=true]:opacity-0 data-[enter=true]:animate-stk25-pop group-data-[open=true]:relative group-data-[open=true]:transform-none group-data-[open=true]:mb-2.5 motion-reduce:transition-opacity motion-reduce:duration-200 motion-reduce:animate-none">
<b class="text-sm font-extrabold">New comment</b>
<span class="text-[13px] text-stk25-body">Dana replied to your thread.</span>
</article>
<article class="absolute inset-x-0 top-0 flex flex-col gap-1 rounded-[14px] border border-stk25-line border-l-4 border-l-stk25-n2 bg-stk25-note px-4.5 py-4 text-stk25-ink shadow-[0_14px_30px_-18px_rgba(0,0,0,0.8)] [transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(20-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk25 data-[gone=true]:opacity-0 data-[enter=true]:animate-stk25-pop group-data-[open=true]:relative group-data-[open=true]:transform-none group-data-[open=true]:mb-2.5 motion-reduce:transition-opacity motion-reduce:duration-200 motion-reduce:animate-none">
<b class="text-sm font-extrabold">Build passed</b>
<span class="text-[13px] text-stk25-body">main deployed in 2m 14s.</span>
</article>
<article class="absolute inset-x-0 top-0 flex flex-col gap-1 rounded-[14px] border border-stk25-line border-l-4 border-l-stk25-n3 bg-stk25-note px-4.5 py-4 text-stk25-ink shadow-[0_14px_30px_-18px_rgba(0,0,0,0.8)] [transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(20-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk25 data-[gone=true]:opacity-0 data-[enter=true]:animate-stk25-pop group-data-[open=true]:relative group-data-[open=true]:transform-none group-data-[open=true]:mb-2.5 motion-reduce:transition-opacity motion-reduce:duration-200 motion-reduce:animate-none">
<b class="text-sm font-extrabold">Reminder</b>
<span class="text-[13px] text-stk25-body">Standup starts in 10 minutes.</span>
</article>
</div>
<div class="mt-4 flex gap-2.5">
<button class="flex-1 rounded-[11px] border-0 bg-stk25-btn px-4 py-2.75 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk25-btnh focus-visible:outline-3 focus-visible:outline-stk25-ring focus-visible:outline-offset-2" id="stk-25-add" type="button">Simulate alert</button>
<button class="flex-1 rounded-[11px] border-0 bg-stk25-btn px-4 py-2.75 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk25-btnh focus-visible:outline-3 focus-visible:outline-stk25-ring focus-visible:outline-offset-2" id="stk-25-dismiss" type="button">Dismiss top</button>
</div>
</div>
</section><section class="stk-25 w-full min-h-svh flex items-center justify-center px-5 py-10 font-stk25-sans bg-stk25-bg" aria-label="Notification pile">
<div class="w-[min(340px,100%)]">
<div class="mb-4 flex items-center justify-between text-stk25-ink">
<b class="text-base font-extrabold">Notifications</b>
<button class="rounded-[9px] border border-stk25-line bg-transparent px-3 py-1.5 font-stk25-mono text-xs font-medium text-stk25-body cursor-pointer transition-colors duration-200 hover:bg-stk25-btn hover:text-white focus-visible:outline-3 focus-visible:outline-stk25-ring focus-visible:outline-offset-2" id="stk-25-exp" type="button" aria-expanded="false">Expand</button>
</div>
<div class="group relative h-30 transition-[height] duration-400 data-[open=true]:h-auto" id="stk-25-pile" data-open="false" aria-live="polite">
<article class="absolute inset-x-0 top-0 flex flex-col gap-1 rounded-[14px] border border-stk25-line border-l-4 border-l-stk25-n1 bg-stk25-note px-4.5 py-4 text-stk25-ink shadow-[0_14px_30px_-18px_rgba(0,0,0,0.8)] [transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(20-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk25 data-[gone=true]:opacity-0 data-[enter=true]:animate-stk25-pop group-data-[open=true]:relative group-data-[open=true]:transform-none group-data-[open=true]:mb-2.5 motion-reduce:transition-opacity motion-reduce:duration-200 motion-reduce:animate-none">
<b class="text-sm font-extrabold">New comment</b>
<span class="text-[13px] text-stk25-body">Dana replied to your thread.</span>
</article>
<article class="absolute inset-x-0 top-0 flex flex-col gap-1 rounded-[14px] border border-stk25-line border-l-4 border-l-stk25-n2 bg-stk25-note px-4.5 py-4 text-stk25-ink shadow-[0_14px_30px_-18px_rgba(0,0,0,0.8)] [transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(20-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk25 data-[gone=true]:opacity-0 data-[enter=true]:animate-stk25-pop group-data-[open=true]:relative group-data-[open=true]:transform-none group-data-[open=true]:mb-2.5 motion-reduce:transition-opacity motion-reduce:duration-200 motion-reduce:animate-none">
<b class="text-sm font-extrabold">Build passed</b>
<span class="text-[13px] text-stk25-body">main deployed in 2m 14s.</span>
</article>
<article class="absolute inset-x-0 top-0 flex flex-col gap-1 rounded-[14px] border border-stk25-line border-l-4 border-l-stk25-n3 bg-stk25-note px-4.5 py-4 text-stk25-ink shadow-[0_14px_30px_-18px_rgba(0,0,0,0.8)] [transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(20-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk25 data-[gone=true]:opacity-0 data-[enter=true]:animate-stk25-pop group-data-[open=true]:relative group-data-[open=true]:transform-none group-data-[open=true]:mb-2.5 motion-reduce:transition-opacity motion-reduce:duration-200 motion-reduce:animate-none">
<b class="text-sm font-extrabold">Reminder</b>
<span class="text-[13px] text-stk25-body">Standup starts in 10 minutes.</span>
</article>
</div>
<div class="mt-4 flex gap-2.5">
<button class="flex-1 rounded-[11px] border-0 bg-stk25-btn px-4 py-2.75 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk25-btnh focus-visible:outline-3 focus-visible:outline-stk25-ring focus-visible:outline-offset-2" id="stk-25-add" type="button">Simulate alert</button>
<button class="flex-1 rounded-[11px] border-0 bg-stk25-btn px-4 py-2.75 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk25-btnh focus-visible:outline-3 focus-visible:outline-stk25-ring focus-visible:outline-offset-2" id="stk-25-dismiss" type="button">Dismiss top</button>
</div>
</div>
</section>@import "tailwindcss";
@theme {
--color-stk25-bg: #0e0f16;
--color-stk25-bg: oklch(0.17 0.012 275);
--color-stk25-note: #1b1e2a;
--color-stk25-note: oklch(0.24 0.016 275);
--color-stk25-line: #2a2e3c;
--color-stk25-line: oklch(0.32 0.018 275);
--color-stk25-btn: #242838;
--color-stk25-btn: oklch(0.28 0.020 275);
--color-stk25-btnh: #2e3346;
--color-stk25-btnh: oklch(0.33 0.022 275);
--color-stk25-ink: #eef0f6;
--color-stk25-ink: oklch(0.95 0.006 275);
--color-stk25-body: #a9adc0;
--color-stk25-body: oklch(0.74 0.018 275);
--color-stk25-ring: #5aa2e0;
--color-stk25-ring: oklch(0.66 0.15 250);
--color-stk25-n1: #4d8ade;
--color-stk25-n1: oklch(0.66 0.15 250);
--color-stk25-n2: #4bbd8c;
--color-stk25-n2: oklch(0.68 0.15 150);
--color-stk25-n3: #e0a03f;
--color-stk25-n3: oklch(0.70 0.15 60);
--color-stk25-n4: #b07ce0;
--color-stk25-n4: oklch(0.66 0.15 300);
--font-stk25-sans: 'Archivo', system-ui, sans-serif;
--font-stk25-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-stk25: cubic-bezier(0.34, 1.4, 0.5, 1);
--animate-stk25-pop: stk-25-pop 0.5s cubic-bezier(0.34, 1.56, 0.5, 1);
}
@keyframes stk-25-pop {
from {
transform: translateY(-40px) scale(0.9);
opacity: 0;
}
}@import "tailwindcss";
@theme {
--color-stk25-bg: #0e0f16;
--color-stk25-bg: oklch(0.17 0.012 275);
--color-stk25-note: #1b1e2a;
--color-stk25-note: oklch(0.24 0.016 275);
--color-stk25-line: #2a2e3c;
--color-stk25-line: oklch(0.32 0.018 275);
--color-stk25-btn: #242838;
--color-stk25-btn: oklch(0.28 0.020 275);
--color-stk25-btnh: #2e3346;
--color-stk25-btnh: oklch(0.33 0.022 275);
--color-stk25-ink: #eef0f6;
--color-stk25-ink: oklch(0.95 0.006 275);
--color-stk25-body: #a9adc0;
--color-stk25-body: oklch(0.74 0.018 275);
--color-stk25-ring: #5aa2e0;
--color-stk25-ring: oklch(0.66 0.15 250);
--color-stk25-n1: #4d8ade;
--color-stk25-n1: oklch(0.66 0.15 250);
--color-stk25-n2: #4bbd8c;
--color-stk25-n2: oklch(0.68 0.15 150);
--color-stk25-n3: #e0a03f;
--color-stk25-n3: oklch(0.70 0.15 60);
--color-stk25-n4: #b07ce0;
--color-stk25-n4: oklch(0.66 0.15 300);
--font-stk25-sans: 'Archivo', system-ui, sans-serif;
--font-stk25-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-stk25: cubic-bezier(0.34, 1.4, 0.5, 1);
--animate-stk25-pop: stk-25-pop 0.5s cubic-bezier(0.34, 1.56, 0.5, 1);
}
@keyframes stk-25-pop {
from {
transform: translateY(-40px) scale(0.9);
opacity: 0;
}
}(() => {
const root = document.querySelector('.stk-25');
if (!root) return;
const pile = root.querySelector('#stk-25-pile');
const toggle = root.querySelector('#stk-25-exp');
const samples = [
['New follower', 'Sam started following you.', 'stk25-n4'],
['Payment received', '$49 from Acme Co.', 'stk25-n2'],
['Mention', 'You were tagged in #design.', 'stk25-n1'],
['Storage low', '92% of your quota used.', 'stk25-n3'],
];
let s = 0;
/* The depth cap is a rendering-cost decision, so it lives where the loop is:
the fifth and the hundredth notification share a depth. */
const paint = () => [...pile.children].forEach((n, i) => n.style.setProperty('--d', Math.min(i, 4)));
function add() {
const [title, body, tone] = samples[s++ % samples.length];
const el = pile.firstElementChild.cloneNode(true);
el.className = el.className.replace(/border-l-stk25-n\d/, 'border-l-' + tone);
el.querySelector('b').textContent = title;
el.querySelector('span').textContent = body;
el.dataset.enter = 'true';
pile.insertBefore(el, pile.firstElementChild);
paint();
el.addEventListener('animationend', () => { delete el.dataset.enter; }, { once: true });
}
function dismiss() {
const top = pile.firstElementChild;
if (!top) return;
top.dataset.gone = 'true';
top.addEventListener('transitionend', () => { top.remove(); paint(); }, { once: true });
}
root.querySelector('#stk-25-add').addEventListener('click', add);
root.querySelector('#stk-25-dismiss').addEventListener('click', dismiss);
toggle.addEventListener('click', () => {
const open = pile.dataset.open !== 'true';
pile.dataset.open = String(open);
toggle.textContent = open ? 'Collapse' : 'Expand';
toggle.setAttribute('aria-expanded', String(open));
});
root.addEventListener('keydown', (e) => { if (e.key === 'Escape') dismiss(); });
paint();
})();(() => {
const root = document.querySelector('.stk-25');
if (!root) return;
const pile = root.querySelector('#stk-25-pile');
const toggle = root.querySelector('#stk-25-exp');
const samples = [
['New follower', 'Sam started following you.', 'stk25-n4'],
['Payment received', '$49 from Acme Co.', 'stk25-n2'],
['Mention', 'You were tagged in #design.', 'stk25-n1'],
['Storage low', '92% of your quota used.', 'stk25-n3'],
];
let s = 0;
/* The depth cap is a rendering-cost decision, so it lives where the loop is:
the fifth and the hundredth notification share a depth. */
const paint = () => [...pile.children].forEach((n, i) => n.style.setProperty('--d', Math.min(i, 4)));
function add() {
const [title, body, tone] = samples[s++ % samples.length];
const el = pile.firstElementChild.cloneNode(true);
el.className = el.className.replace(/border-l-stk25-n\d/, 'border-l-' + tone);
el.querySelector('b').textContent = title;
el.querySelector('span').textContent = body;
el.dataset.enter = 'true';
pile.insertBefore(el, pile.firstElementChild);
paint();
el.addEventListener('animationend', () => { delete el.dataset.enter; }, { once: true });
}
function dismiss() {
const top = pile.firstElementChild;
if (!top) return;
top.dataset.gone = 'true';
top.addEventListener('transitionend', () => { top.remove(); paint(); }, { once: true });
}
root.querySelector('#stk-25-add').addEventListener('click', add);
root.querySelector('#stk-25-dismiss').addEventListener('click', dismiss);
toggle.addEventListener('click', () => {
const open = pile.dataset.open !== 'true';
pile.dataset.open = String(open);
toggle.textContent = open ? 'Collapse' : 'Expand';
toggle.setAttribute('aria-expanded', String(open));
});
root.addEventListener('keydown', (e) => { if (e.key === 'Escape') dismiss(); });
paint();
})();Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-stk25-bg: #0e0f16;
--color-stk25-bg: oklch(0.17 0.012 275);
--color-stk25-note: #1b1e2a;
--color-stk25-note: oklch(0.24 0.016 275);
--color-stk25-line: #2a2e3c;
--color-stk25-line: oklch(0.32 0.018 275);
--color-stk25-btn: #242838;
--color-stk25-btn: oklch(0.28 0.020 275);
--color-stk25-btnh: #2e3346;
--color-stk25-btnh: oklch(0.33 0.022 275);
--color-stk25-ink: #eef0f6;
--color-stk25-ink: oklch(0.95 0.006 275);
--color-stk25-body: #a9adc0;
--color-stk25-body: oklch(0.74 0.018 275);
--color-stk25-ring: #5aa2e0;
--color-stk25-ring: oklch(0.66 0.15 250);
--color-stk25-n1: #4d8ade;
--color-stk25-n1: oklch(0.66 0.15 250);
--color-stk25-n2: #4bbd8c;
--color-stk25-n2: oklch(0.68 0.15 150);
--color-stk25-n3: #e0a03f;
--color-stk25-n3: oklch(0.70 0.15 60);
--color-stk25-n4: #b07ce0;
--color-stk25-n4: oklch(0.66 0.15 300);
--font-stk25-sans: 'Archivo', system-ui, sans-serif;
--font-stk25-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-stk25: cubic-bezier(0.34, 1.4, 0.5, 1);
--animate-stk25-pop: stk-25-pop 0.5s cubic-bezier(0.34, 1.56, 0.5, 1);
}
@keyframes stk-25-pop {
from {
transform: translateY(-40px) scale(0.9);
opacity: 0;
}
}@import "tailwindcss";
@theme {
--color-stk25-bg: #0e0f16;
--color-stk25-bg: oklch(0.17 0.012 275);
--color-stk25-note: #1b1e2a;
--color-stk25-note: oklch(0.24 0.016 275);
--color-stk25-line: #2a2e3c;
--color-stk25-line: oklch(0.32 0.018 275);
--color-stk25-btn: #242838;
--color-stk25-btn: oklch(0.28 0.020 275);
--color-stk25-btnh: #2e3346;
--color-stk25-btnh: oklch(0.33 0.022 275);
--color-stk25-ink: #eef0f6;
--color-stk25-ink: oklch(0.95 0.006 275);
--color-stk25-body: #a9adc0;
--color-stk25-body: oklch(0.74 0.018 275);
--color-stk25-ring: #5aa2e0;
--color-stk25-ring: oklch(0.66 0.15 250);
--color-stk25-n1: #4d8ade;
--color-stk25-n1: oklch(0.66 0.15 250);
--color-stk25-n2: #4bbd8c;
--color-stk25-n2: oklch(0.68 0.15 150);
--color-stk25-n3: #e0a03f;
--color-stk25-n3: oklch(0.70 0.15 60);
--color-stk25-n4: #b07ce0;
--color-stk25-n4: oklch(0.66 0.15 300);
--font-stk25-sans: 'Archivo', system-ui, sans-serif;
--font-stk25-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-stk25: cubic-bezier(0.34, 1.4, 0.5, 1);
--animate-stk25-pop: stk-25-pop 0.5s cubic-bezier(0.34, 1.56, 0.5, 1);
}
@keyframes stk-25-pop {
from {
transform: translateY(-40px) scale(0.9);
opacity: 0;
}
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@75..125,400;75..125,600;75..125,800&family=JetBrains+Mono:wght@400;500&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@75..125,400;75..125,600;75..125,800&family=JetBrains+Mono:wght@400;500&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The webfonts load as
<link>tags insetup, not as an@importinside the stylesheet. Tailwind emits user@importrules after its own@layerblocks, and a late@importis invalid CSS — the browser drops it and the demo silently falls back to system fonts. - The collapsed deck and the expanded list are two sets of utilities in one class list, switched by
group-data-[open=true]:— the CSS version needs a second block of overrides with an!importantin it. group-data-[open=true]:transform-noneis required alongsiderelative: changing position does not clear the depth transform.- The spring entrance is an
--animate-*token applied throughdata-[enter=true]:animate-stk25-pop, so the script sets an attribute rather than adding a class. - Depth is capped in the script rather than the stylesheet, because how many layers to render is a cost decision and belongs next to the loop.
- New alerts are cloned from an existing card, which keeps the long class list in exactly one place — the practical answer to utility duplication in JS-generated markup.
aria-liveon the pile andaria-expandedon the toggle do the work the animation cannot: a spring entrance is not an announcement.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Notification Stack Pile Dismiss
Source: https://codefronts.com/components/tailwind-stacked-cards/tailwind-notification-stack-pile-dismiss/
An OS-style alert pile: new notifications spring onto the top, older ones compress below, and one toggle switches the whole thing between a compact deck and a spaced list. The two layouts are one data-open attribute on the pile — data-[open=true]: variants flip every card from absolute-and-scaled to relative-and-flat, so there is no second stylesheet for the expanded state.
## HTML
```html
<section class="stk-25 w-full min-h-svh flex items-center justify-center px-5 py-10 font-stk25-sans bg-stk25-bg" aria-label="Notification pile">
<div class="w-[min(340px,100%)]">
<div class="mb-4 flex items-center justify-between text-stk25-ink">
<b class="text-base font-extrabold">Notifications</b>
<button class="rounded-[9px] border border-stk25-line bg-transparent px-3 py-1.5 font-stk25-mono text-xs font-medium text-stk25-body cursor-pointer transition-colors duration-200 hover:bg-stk25-btn hover:text-white focus-visible:outline-3 focus-visible:outline-stk25-ring focus-visible:outline-offset-2" id="stk-25-exp" type="button" aria-expanded="false">Expand</button>
</div>
<div class="group relative h-30 transition-[height] duration-400 data-[open=true]:h-auto" id="stk-25-pile" data-open="false" aria-live="polite">
<article class="absolute inset-x-0 top-0 flex flex-col gap-1 rounded-[14px] border border-stk25-line border-l-4 border-l-stk25-n1 bg-stk25-note px-4.5 py-4 text-stk25-ink shadow-[0_14px_30px_-18px_rgba(0,0,0,0.8)] [transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(20-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk25 data-[gone=true]:opacity-0 data-[enter=true]:animate-stk25-pop group-data-[open=true]:relative group-data-[open=true]:transform-none group-data-[open=true]:mb-2.5 motion-reduce:transition-opacity motion-reduce:duration-200 motion-reduce:animate-none">
<b class="text-sm font-extrabold">New comment</b>
<span class="text-[13px] text-stk25-body">Dana replied to your thread.</span>
</article>
<article class="absolute inset-x-0 top-0 flex flex-col gap-1 rounded-[14px] border border-stk25-line border-l-4 border-l-stk25-n2 bg-stk25-note px-4.5 py-4 text-stk25-ink shadow-[0_14px_30px_-18px_rgba(0,0,0,0.8)] [transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(20-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk25 data-[gone=true]:opacity-0 data-[enter=true]:animate-stk25-pop group-data-[open=true]:relative group-data-[open=true]:transform-none group-data-[open=true]:mb-2.5 motion-reduce:transition-opacity motion-reduce:duration-200 motion-reduce:animate-none">
<b class="text-sm font-extrabold">Build passed</b>
<span class="text-[13px] text-stk25-body">main deployed in 2m 14s.</span>
</article>
<article class="absolute inset-x-0 top-0 flex flex-col gap-1 rounded-[14px] border border-stk25-line border-l-4 border-l-stk25-n3 bg-stk25-note px-4.5 py-4 text-stk25-ink shadow-[0_14px_30px_-18px_rgba(0,0,0,0.8)] [transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(20-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk25 data-[gone=true]:opacity-0 data-[enter=true]:animate-stk25-pop group-data-[open=true]:relative group-data-[open=true]:transform-none group-data-[open=true]:mb-2.5 motion-reduce:transition-opacity motion-reduce:duration-200 motion-reduce:animate-none">
<b class="text-sm font-extrabold">Reminder</b>
<span class="text-[13px] text-stk25-body">Standup starts in 10 minutes.</span>
</article>
</div>
<div class="mt-4 flex gap-2.5">
<button class="flex-1 rounded-[11px] border-0 bg-stk25-btn px-4 py-2.75 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk25-btnh focus-visible:outline-3 focus-visible:outline-stk25-ring focus-visible:outline-offset-2" id="stk-25-add" type="button">Simulate alert</button>
<button class="flex-1 rounded-[11px] border-0 bg-stk25-btn px-4 py-2.75 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk25-btnh focus-visible:outline-3 focus-visible:outline-stk25-ring focus-visible:outline-offset-2" id="stk-25-dismiss" type="button">Dismiss top</button>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-stk25-bg: #0e0f16;
--color-stk25-bg: oklch(0.17 0.012 275);
--color-stk25-note: #1b1e2a;
--color-stk25-note: oklch(0.24 0.016 275);
--color-stk25-line: #2a2e3c;
--color-stk25-line: oklch(0.32 0.018 275);
--color-stk25-btn: #242838;
--color-stk25-btn: oklch(0.28 0.020 275);
--color-stk25-btnh: #2e3346;
--color-stk25-btnh: oklch(0.33 0.022 275);
--color-stk25-ink: #eef0f6;
--color-stk25-ink: oklch(0.95 0.006 275);
--color-stk25-body: #a9adc0;
--color-stk25-body: oklch(0.74 0.018 275);
--color-stk25-ring: #5aa2e0;
--color-stk25-ring: oklch(0.66 0.15 250);
--color-stk25-n1: #4d8ade;
--color-stk25-n1: oklch(0.66 0.15 250);
--color-stk25-n2: #4bbd8c;
--color-stk25-n2: oklch(0.68 0.15 150);
--color-stk25-n3: #e0a03f;
--color-stk25-n3: oklch(0.70 0.15 60);
--color-stk25-n4: #b07ce0;
--color-stk25-n4: oklch(0.66 0.15 300);
--font-stk25-sans: 'Archivo', system-ui, sans-serif;
--font-stk25-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-stk25: cubic-bezier(0.34, 1.4, 0.5, 1);
--animate-stk25-pop: stk-25-pop 0.5s cubic-bezier(0.34, 1.56, 0.5, 1);
}
@keyframes stk-25-pop {
from {
transform: translateY(-40px) scale(0.9);
opacity: 0;
}
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.stk-25');
if (!root) return;
const pile = root.querySelector('#stk-25-pile');
const toggle = root.querySelector('#stk-25-exp');
const samples = [
['New follower', 'Sam started following you.', 'stk25-n4'],
['Payment received', '$49 from Acme Co.', 'stk25-n2'],
['Mention', 'You were tagged in #design.', 'stk25-n1'],
['Storage low', '92% of your quota used.', 'stk25-n3'],
];
let s = 0;
/* The depth cap is a rendering-cost decision, so it lives where the loop is:
the fifth and the hundredth notification share a depth. */
const paint = () => [...pile.children].forEach((n, i) => n.style.setProperty('--d', Math.min(i, 4)));
function add() {
const [title, body, tone] = samples[s++ % samples.length];
const el = pile.firstElementChild.cloneNode(true);
el.className = el.className.replace(/border-l-stk25-n\d/, 'border-l-' + tone);
el.querySelector('b').textContent = title;
el.querySelector('span').textContent = body;
el.dataset.enter = 'true';
pile.insertBefore(el, pile.firstElementChild);
paint();
el.addEventListener('animationend', () => { delete el.dataset.enter; }, { once: true });
}
function dismiss() {
const top = pile.firstElementChild;
if (!top) return;
top.dataset.gone = 'true';
top.addEventListener('transitionend', () => { top.remove(); paint(); }, { once: true });
}
root.querySelector('#stk-25-add').addEventListener('click', add);
root.querySelector('#stk-25-dismiss').addEventListener('click', dismiss);
toggle.addEventListener('click', () => {
const open = pile.dataset.open !== 'true';
pile.dataset.open = String(open);
toggle.textContent = open ? 'Collapse' : 'Expand';
toggle.setAttribute('aria-expanded', String(open));
});
root.addEventListener('keydown', (e) => { if (e.key === 'Escape') dismiss(); });
paint();
})();
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Notification Stack Pile Dismiss
Source: https://codefronts.com/components/tailwind-stacked-cards/tailwind-notification-stack-pile-dismiss/
An OS-style alert pile: new notifications spring onto the top, older ones compress below, and one toggle switches the whole thing between a compact deck and a spaced list. The two layouts are one data-open attribute on the pile — data-[open=true]: variants flip every card from absolute-and-scaled to relative-and-flat, so there is no second stylesheet for the expanded state.
## HTML
```html
<section class="stk-25 w-full min-h-svh flex items-center justify-center px-5 py-10 font-stk25-sans bg-stk25-bg" aria-label="Notification pile">
<div class="w-[min(340px,100%)]">
<div class="mb-4 flex items-center justify-between text-stk25-ink">
<b class="text-base font-extrabold">Notifications</b>
<button class="rounded-[9px] border border-stk25-line bg-transparent px-3 py-1.5 font-stk25-mono text-xs font-medium text-stk25-body cursor-pointer transition-colors duration-200 hover:bg-stk25-btn hover:text-white focus-visible:outline-3 focus-visible:outline-stk25-ring focus-visible:outline-offset-2" id="stk-25-exp" type="button" aria-expanded="false">Expand</button>
</div>
<div class="group relative h-30 transition-[height] duration-400 data-[open=true]:h-auto" id="stk-25-pile" data-open="false" aria-live="polite">
<article class="absolute inset-x-0 top-0 flex flex-col gap-1 rounded-[14px] border border-stk25-line border-l-4 border-l-stk25-n1 bg-stk25-note px-4.5 py-4 text-stk25-ink shadow-[0_14px_30px_-18px_rgba(0,0,0,0.8)] [transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(20-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk25 data-[gone=true]:opacity-0 data-[enter=true]:animate-stk25-pop group-data-[open=true]:relative group-data-[open=true]:transform-none group-data-[open=true]:mb-2.5 motion-reduce:transition-opacity motion-reduce:duration-200 motion-reduce:animate-none">
<b class="text-sm font-extrabold">New comment</b>
<span class="text-[13px] text-stk25-body">Dana replied to your thread.</span>
</article>
<article class="absolute inset-x-0 top-0 flex flex-col gap-1 rounded-[14px] border border-stk25-line border-l-4 border-l-stk25-n2 bg-stk25-note px-4.5 py-4 text-stk25-ink shadow-[0_14px_30px_-18px_rgba(0,0,0,0.8)] [transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(20-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk25 data-[gone=true]:opacity-0 data-[enter=true]:animate-stk25-pop group-data-[open=true]:relative group-data-[open=true]:transform-none group-data-[open=true]:mb-2.5 motion-reduce:transition-opacity motion-reduce:duration-200 motion-reduce:animate-none">
<b class="text-sm font-extrabold">Build passed</b>
<span class="text-[13px] text-stk25-body">main deployed in 2m 14s.</span>
</article>
<article class="absolute inset-x-0 top-0 flex flex-col gap-1 rounded-[14px] border border-stk25-line border-l-4 border-l-stk25-n3 bg-stk25-note px-4.5 py-4 text-stk25-ink shadow-[0_14px_30px_-18px_rgba(0,0,0,0.8)] [transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(20-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk25 data-[gone=true]:opacity-0 data-[enter=true]:animate-stk25-pop group-data-[open=true]:relative group-data-[open=true]:transform-none group-data-[open=true]:mb-2.5 motion-reduce:transition-opacity motion-reduce:duration-200 motion-reduce:animate-none">
<b class="text-sm font-extrabold">Reminder</b>
<span class="text-[13px] text-stk25-body">Standup starts in 10 minutes.</span>
</article>
</div>
<div class="mt-4 flex gap-2.5">
<button class="flex-1 rounded-[11px] border-0 bg-stk25-btn px-4 py-2.75 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk25-btnh focus-visible:outline-3 focus-visible:outline-stk25-ring focus-visible:outline-offset-2" id="stk-25-add" type="button">Simulate alert</button>
<button class="flex-1 rounded-[11px] border-0 bg-stk25-btn px-4 py-2.75 text-[13px] font-bold text-white cursor-pointer transition-colors duration-200 hover:bg-stk25-btnh focus-visible:outline-3 focus-visible:outline-stk25-ring focus-visible:outline-offset-2" id="stk-25-dismiss" type="button">Dismiss top</button>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-stk25-bg: #0e0f16;
--color-stk25-bg: oklch(0.17 0.012 275);
--color-stk25-note: #1b1e2a;
--color-stk25-note: oklch(0.24 0.016 275);
--color-stk25-line: #2a2e3c;
--color-stk25-line: oklch(0.32 0.018 275);
--color-stk25-btn: #242838;
--color-stk25-btn: oklch(0.28 0.020 275);
--color-stk25-btnh: #2e3346;
--color-stk25-btnh: oklch(0.33 0.022 275);
--color-stk25-ink: #eef0f6;
--color-stk25-ink: oklch(0.95 0.006 275);
--color-stk25-body: #a9adc0;
--color-stk25-body: oklch(0.74 0.018 275);
--color-stk25-ring: #5aa2e0;
--color-stk25-ring: oklch(0.66 0.15 250);
--color-stk25-n1: #4d8ade;
--color-stk25-n1: oklch(0.66 0.15 250);
--color-stk25-n2: #4bbd8c;
--color-stk25-n2: oklch(0.68 0.15 150);
--color-stk25-n3: #e0a03f;
--color-stk25-n3: oklch(0.70 0.15 60);
--color-stk25-n4: #b07ce0;
--color-stk25-n4: oklch(0.66 0.15 300);
--font-stk25-sans: 'Archivo', system-ui, sans-serif;
--font-stk25-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-stk25: cubic-bezier(0.34, 1.4, 0.5, 1);
--animate-stk25-pop: stk-25-pop 0.5s cubic-bezier(0.34, 1.56, 0.5, 1);
}
@keyframes stk-25-pop {
from {
transform: translateY(-40px) scale(0.9);
opacity: 0;
}
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.stk-25');
if (!root) return;
const pile = root.querySelector('#stk-25-pile');
const toggle = root.querySelector('#stk-25-exp');
const samples = [
['New follower', 'Sam started following you.', 'stk25-n4'],
['Payment received', '$49 from Acme Co.', 'stk25-n2'],
['Mention', 'You were tagged in #design.', 'stk25-n1'],
['Storage low', '92% of your quota used.', 'stk25-n3'],
];
let s = 0;
/* The depth cap is a rendering-cost decision, so it lives where the loop is:
the fifth and the hundredth notification share a depth. */
const paint = () => [...pile.children].forEach((n, i) => n.style.setProperty('--d', Math.min(i, 4)));
function add() {
const [title, body, tone] = samples[s++ % samples.length];
const el = pile.firstElementChild.cloneNode(true);
el.className = el.className.replace(/border-l-stk25-n\d/, 'border-l-' + tone);
el.querySelector('b').textContent = title;
el.querySelector('span').textContent = body;
el.dataset.enter = 'true';
pile.insertBefore(el, pile.firstElementChild);
paint();
el.addEventListener('animationend', () => { delete el.dataset.enter; }, { once: true });
}
function dismiss() {
const top = pile.firstElementChild;
if (!top) return;
top.dataset.gone = 'true';
top.addEventListener('transitionend', () => { top.remove(); paint(); }, { once: true });
}
root.querySelector('#stk-25-add').addEventListener('click', add);
root.querySelector('#stk-25-dismiss').addEventListener('click', dismiss);
toggle.addEventListener('click', () => {
const open = pile.dataset.open !== 'true';
pile.dataset.open = String(open);
toggle.textContent = open ? 'Collapse' : 'Expand';
toggle.setAttribute('aria-expanded', String(open));
});
root.addEventListener('keydown', (e) => { if (e.key === 'Escape') dismiss(); });
paint();
})();
```How this works
Two layouts, one attribute. Collapsed, the notes are absolutely stacked and scaled by depth. Expanded, they are in flow with margins. Both live on the card:
class="absolute inset-x-0 top-0
[transform:translateY(calc(var(--d,0)*12px))_scale(calc(1-var(--d,0)*0.05))]
group-data-[open=true]:relative
group-data-[open=true]:transform-none
group-data-[open=true]:mb-2.5"The controller sets data-open on the pile and nothing else. In the CSS version this is a .open class plus a second block of overrides with an !important in it; here the expanded state is a peer of the collapsed one in the same class list.
Cap the depth in the script, not the CSS. Math.min(i, 4) means the fifth and hundredth notification share a depth, so a busy pile renders five distinct layers rather than a hundred scaled boxes. This is a rendering-cost decision that belongs where the loop is.
Motion is not an announcement. A notification that springs into view tells a sighted user something arrived and tells a screen-reader user nothing. The pile is an aria-live="polite" region, so each new alert's text is read; the toggle carries aria-expanded; and Dismiss is a real button with an Escape shortcut rather than a swipe.
Make it yours
- Change how many cards peek by editing the
Math.min(i,4)cap. - Tune the spring in the
@keyframes stk-25-popblock — the one part that cannot be a utility. - Add alert types by extending the samples array and giving each its own border colour.
- Change the collapsed pile height (
h-30); the expanded state ignores it. - Recolour the panel chrome through the
@themetokens; each alert's accent is per-card.
Gotchas — read before shipping
- Cap the visible depth. Without it a long pile renders every notification as its own scaled, shadowed layer.
- Announce arrivals with
aria-live. A spring animation is not an accessible notification. - Keep the Dismiss button and the
Escapeshortcut — a swipe-only pile excludes keyboard and switch users. - The entrance animation must be removed on
animationend, or a re-rendered card replays it. group-data-[open=true]:transform-noneis needed as well asrelative: position alone leaves the depth transform applied.- The
aria-expandedvalue has to be updated by the script; the attribute is not inferred from the class or the data attribute.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Custom properties, keyframes and data attributes are universal (Chrome 49+, Safari 10+, Firefox 52+). The floor is Tailwind v4's own generated sheet.