36 Tailwind Stacked Cards
25 / 36

All 36 in 36 Tailwind Stacked Cards

  1. 01Tailwind Scroll-Driven Scaling Card Stack
  2. 02Tailwind Swipe Card Deck Drag to Dismiss
  3. 03Tailwind Glassmorphism Testimonial Card Stack
  4. 04Tailwind Stacked Cards Expand Accordion on Click
  5. 05Tailwind Fan Out Photo Stack on Hover
  6. 06Tailwind 3D Perspective Tilt Card Stack
  7. 07Tailwind Multi Step Form Card Stack
  8. 08Tailwind Tabbed Card Stack Bring to Front
  9. 09Tailwind Click to Front Stacked Cards Cycle
  10. 10Tailwind Classic Card Deck Stack Shadow
  11. 11Tailwind Fan Spread Hand of Cards
  12. 12Tailwind Polaroid Photo Stack Scattered Rotate
  13. 13Tailwind Staircase Offset Stacked Cards Cascade
  14. 14Tailwind Cards Spread Out Horizontally on Hover
  15. 15Tailwind Stacked Cards Lift on Hover Sibling Push
  16. 16Tailwind Accordion Stacked Cards Slide Out Menu
  17. 17Tailwind Shuffle Cards Animation Loop Deck
  18. 18Tailwind File Folder Tab Stacked Cards
  19. 19Tailwind Scattered Photos Pinch to Centre on Hover
  20. 20Tailwind Mobile Swipe Card Stack Translate3d
  21. 21Tailwind Single Accordion Card Nested Layers
  22. 22Tailwind Isometric Tower Stacked Cards 3D Column
  23. 23Tailwind 3D Flip Card Stack Reveal Back
  24. 24Tailwind Card Stack Tilt on Hover Quadrant
  25. 25Tailwind Notification Stack Pile Dismiss
  26. 26Tailwind Receipt Roll Unroll Stacked Items
  27. 27Tailwind Scratch Card Reveal Canvas Mask
  28. 28Tailwind Stacked Cards Hover Reveal Spread
  29. 29Tailwind Scroll Activated Sticky Stacking Cards
  30. 30Tailwind Stacked Testimonial Slider Radio
  31. 31Tailwind Isometric Overlapping Stacked Cards
  32. 32Tailwind Bento Grid Stacked Cards Hover Fan
  33. 33Tailwind Perspective Card Deck Depth Tunnel
  34. 34Tailwind Peel Back Corner Card Reveal
  35. 35Tailwind Timeline Stacked Milestone Cards
  36. 36Tailwind Spiral Tower Rotating Stacked Cards
Tailwind v4MIT licensed

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.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

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>
@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();
})();

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<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.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@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;
  }
}
3Fonts used by this demooptional — skip if you have your own type stack
<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 in setup, not as an @import inside the stylesheet. Tailwind emits user @import rules after its own @layer blocks, and a late @import is 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 !important in it.
  • group-data-[open=true]:transform-none is required alongside relative: changing position does not clear the depth transform.
  • The spring entrance is an --animate-* token applied through data-[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-live on the pile and aria-expanded on the toggle do the work the animation cannot: a spring entrance is not an announcement.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
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 &mdash; 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-pop block — 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 @theme tokens; 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 Escape shortcut — 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-none is needed as well as relative: position alone leaves the depth transform applied.
  • The aria-expanded value has to be updated by the script; the attribute is not inferred from the class or the data attribute.

Browser support

ChromeSafariFirefoxEdge
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.

Techniques used in this demo

Theme tokensset in @theme, used by a utility

Arbitraryneeds bracket syntax

Plain CSSno Tailwind surface

Search CodeFronts

Loading…