24 CSS Animated Cards19 / 24
Auto-Cycling Testimonial Deck
Testimonials in a physical stack: the front card slides away and drops to the back while the rest step forward, on a 6-second loop that pauses on hover, on focus, and whenever the tab is hidden. Depth comes from one transform per position (translateY + scale + a whisper of rotation) driven by a single --i variable, so any number of cards works with no extra CSS.
Published
The code
<section class="ac-19" aria-label="Auto cycling testimonial deck demo">
<div class="ac-19__stage">
<div class="ac-19__deck" data-deck aria-roledescription="carousel" aria-label="Customer testimonials">
<article class="ac-19__card" data-card>
<p class="ac-19__quote">We deleted three animation dependencies the week we moved to native CSS timelines. Bundle down 41 kB, and the scroll finally feels like the OS.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Marta Vieira</b><i>Principal Engineer · Kestrel</i></span></footer>
</article>
<article class="ac-19__card" data-card>
<p class="ac-19__quote">Our checkout conversion moved 3.2% after the card-to-detail morph shipped. Nobody could tell us why — it just felt faster than it was.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Idris Bello</b><i>Head of Product · Northbay</i></span></footer>
</article>
<article class="ac-19__card" data-card>
<p class="ac-19__quote">The accessibility review passed first time. Reduced motion, focus states and live regions were in the components from day one instead of bolted on in Q4.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Sunny Ellis</b><i>Design Systems Lead · Fathom</i></span></footer>
</article>
<article class="ac-19__card" data-card>
<p class="ac-19__quote">I teach front-end to career changers. This is the first card library where the students can read the CSS and explain it back to me.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1544005313-94ddf0286df2?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Priya Raman</b><i>Instructor · Kanto Academy</i></span></footer>
</article>
</div>
<div class="ac-19__ctl">
<button class="ac-19__nav" type="button" data-prev aria-label="Previous testimonial">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<button class="ac-19__play" type="button" data-play aria-pressed="true">
<span class="ac-19__pi" aria-hidden="true"></span>
<span data-playlabel>Pause</span>
</button>
<button class="ac-19__nav" type="button" data-next aria-label="Next testimonial">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
</button>
<span class="ac-19__dots" data-dots aria-hidden="true"></span>
</div>
<p class="ac-19__sr" role="status" aria-live="polite" data-status></p>
<p class="ac-19__chip" aria-hidden="true">--i driven depth transform · deal-to-back loop · pause on hover / focus / tab-hidden · WCAG 2.2.2 control</p>
</div>
</section><section class="ac-19" aria-label="Auto cycling testimonial deck demo">
<div class="ac-19__stage">
<div class="ac-19__deck" data-deck aria-roledescription="carousel" aria-label="Customer testimonials">
<article class="ac-19__card" data-card>
<p class="ac-19__quote">We deleted three animation dependencies the week we moved to native CSS timelines. Bundle down 41 kB, and the scroll finally feels like the OS.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Marta Vieira</b><i>Principal Engineer · Kestrel</i></span></footer>
</article>
<article class="ac-19__card" data-card>
<p class="ac-19__quote">Our checkout conversion moved 3.2% after the card-to-detail morph shipped. Nobody could tell us why — it just felt faster than it was.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Idris Bello</b><i>Head of Product · Northbay</i></span></footer>
</article>
<article class="ac-19__card" data-card>
<p class="ac-19__quote">The accessibility review passed first time. Reduced motion, focus states and live regions were in the components from day one instead of bolted on in Q4.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Sunny Ellis</b><i>Design Systems Lead · Fathom</i></span></footer>
</article>
<article class="ac-19__card" data-card>
<p class="ac-19__quote">I teach front-end to career changers. This is the first card library where the students can read the CSS and explain it back to me.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1544005313-94ddf0286df2?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Priya Raman</b><i>Instructor · Kanto Academy</i></span></footer>
</article>
</div>
<div class="ac-19__ctl">
<button class="ac-19__nav" type="button" data-prev aria-label="Previous testimonial">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<button class="ac-19__play" type="button" data-play aria-pressed="true">
<span class="ac-19__pi" aria-hidden="true"></span>
<span data-playlabel>Pause</span>
</button>
<button class="ac-19__nav" type="button" data-next aria-label="Next testimonial">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
</button>
<span class="ac-19__dots" data-dots aria-hidden="true"></span>
</div>
<p class="ac-19__sr" role="status" aria-live="polite" data-status></p>
<p class="ac-19__chip" aria-hidden="true">--i driven depth transform · deal-to-back loop · pause on hover / focus / tab-hidden · WCAG 2.2.2 control</p>
</div>
</section>.ac-19,
.ac-19 *,
.ac-19 *::before,
.ac-19 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ac-19 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
--ac-19-bg: oklch(0.96 0.012 75);
--ac-19-card: oklch(1 0 0);
--ac-19-ink: oklch(0.24 0.02 60);
--ac-19-mut: oklch(0.55 0.02 60);
--ac-19-acc: oklch(0.52 0.14 42);
--ac-19-line: oklch(0.24 0.02 60/.12);
background: var(--ac-19-bg);
color: var(--ac-19-ink);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
-webkit-font-smoothing: antialiased;
}
.ac-19__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: clamp(22px,5vw,58px);
}
.ac-19__deck {
position: relative;
width: min(100%,480px);
height: 265px;
}
.ac-19__card {
position: absolute;
inset: 0;
display: grid;
align-content: space-between;
gap: 16px;
padding: 26px;
border: 1px solid var(--ac-19-line);
border-radius: 22px;
background: var(--ac-19-card);
box-shadow: 0 22px 44px -30px oklch(0.24 0.02 60/.8);
transform-origin: 50% 100%;
translate: 0 calc(var(--ac-19-i,0) * 14px);
scale: calc(1 - var(--ac-19-i,0) * .05);
rotate: calc(var(--ac-19-i,0) * .7deg);
filter: brightness(calc(1 - var(--ac-19-i,0) * .04));
z-index: calc(10 - var(--ac-19-i,0));
opacity: 1;
transition: translate .62s cubic-bezier(.16,1,.3,1),scale .62s cubic-bezier(.16,1,.3,1),rotate .62s cubic-bezier(.16,1,.3,1),opacity .45s ease,filter .5s;
}
.ac-19__card[data-i="3"] {
opacity: .9;
}
.ac-19__card[data-out] {
translate: -118% -6%;
rotate: -9deg;
opacity: 0;
transition-duration: .5s;
}
.ac-19__quote {
font-size: clamp(16px,2.2vw,19px);
line-height: 1.5;
font-weight: 520;
letter-spacing: -.018em;
text-wrap: pretty;
}
.ac-19__quote::before {
content: '\201C';
display: block;
font-size: 44px;
line-height: .6;
color: color-mix(in oklch,var(--ac-19-acc) 40%,transparent);
margin-bottom: 8px;
}
.ac-19__by {
display: flex;
align-items: center;
gap: 12px;
}
.ac-19__by img {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
background: oklch(0.88 0.02 60);
}
.ac-19__by span {
display: grid;
gap: 2px;
}
.ac-19__by b {
font-size: 14px;
font-weight: 650;
letter-spacing: -.015em;
}
.ac-19__by i {
font-style: normal;
font-size: 12px;
color: var(--ac-19-mut);
}
.ac-19__ctl {
display: flex;
align-items: center;
gap: 9px;
}
.ac-19__nav,
.ac-19__play {
min-height: 44px;
border: 1px solid var(--ac-19-line);
border-radius: 99px;
background: var(--ac-19-card);
color: var(--ac-19-ink);
font-family: inherit;
cursor: pointer;
transition: background .2s,transform .2s;
}
.ac-19__nav {
width: 44px;
display: grid;
place-items: center;
}
.ac-19__play {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 0 18px;
font-size: 13.5px;
font-weight: 640;
}
.ac-19__nav:hover,
.ac-19__play:hover {
background: oklch(0.24 0.02 60/.05);
transform: translateY(-1px);
}
.ac-19__nav:focus-visible,
.ac-19__play:focus-visible {
outline: 2px solid var(--ac-19-acc);
outline-offset: 3px;
}
.ac-19__pi {
width: 11px;
height: 12px;
background: currentColor;
-webkit-mask: linear-gradient(#000 0 0) 0 0/3.5px 12px no-repeat,linear-gradient(#000 0 0) 100% 0/3.5px 12px no-repeat;
mask: linear-gradient(#000 0 0) 0 0/3.5px 12px no-repeat,linear-gradient(#000 0 0) 100% 0/3.5px 12px no-repeat;
}
.ac-19__play[aria-pressed="false"] .ac-19__pi {
-webkit-mask: none;
mask: none;
clip-path: polygon(0 0,100% 50%,0 100%);
width: 10px;
}
.ac-19__dots {
display: inline-flex;
gap: 6px;
margin-left: 6px;
}
.ac-19__dots i {
width: 7px;
height: 7px;
border-radius: 50%;
background: oklch(0.24 0.02 60/.2);
transition: background .3s,width .3s;
}
.ac-19__dots i[data-on] {
width: 20px;
border-radius: 99px;
background: var(--ac-19-acc);
}
.ac-19__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.ac-19__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
line-height: 1.6;
color: var(--ac-19-mut);
padding: 8px 15px;
border: 1px dashed var(--ac-19-line);
border-radius: 99px;
text-align: center;
max-width: min(100%,760px);
}
@media (prefers-reduced-motion: reduce) {
.ac-19__card {
transition-duration: .01ms;
}
}.ac-19,
.ac-19 *,
.ac-19 *::before,
.ac-19 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ac-19 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
--ac-19-bg: oklch(0.96 0.012 75);
--ac-19-card: oklch(1 0 0);
--ac-19-ink: oklch(0.24 0.02 60);
--ac-19-mut: oklch(0.55 0.02 60);
--ac-19-acc: oklch(0.52 0.14 42);
--ac-19-line: oklch(0.24 0.02 60/.12);
background: var(--ac-19-bg);
color: var(--ac-19-ink);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
-webkit-font-smoothing: antialiased;
}
.ac-19__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: clamp(22px,5vw,58px);
}
.ac-19__deck {
position: relative;
width: min(100%,480px);
height: 265px;
}
.ac-19__card {
position: absolute;
inset: 0;
display: grid;
align-content: space-between;
gap: 16px;
padding: 26px;
border: 1px solid var(--ac-19-line);
border-radius: 22px;
background: var(--ac-19-card);
box-shadow: 0 22px 44px -30px oklch(0.24 0.02 60/.8);
transform-origin: 50% 100%;
translate: 0 calc(var(--ac-19-i,0) * 14px);
scale: calc(1 - var(--ac-19-i,0) * .05);
rotate: calc(var(--ac-19-i,0) * .7deg);
filter: brightness(calc(1 - var(--ac-19-i,0) * .04));
z-index: calc(10 - var(--ac-19-i,0));
opacity: 1;
transition: translate .62s cubic-bezier(.16,1,.3,1),scale .62s cubic-bezier(.16,1,.3,1),rotate .62s cubic-bezier(.16,1,.3,1),opacity .45s ease,filter .5s;
}
.ac-19__card[data-i="3"] {
opacity: .9;
}
.ac-19__card[data-out] {
translate: -118% -6%;
rotate: -9deg;
opacity: 0;
transition-duration: .5s;
}
.ac-19__quote {
font-size: clamp(16px,2.2vw,19px);
line-height: 1.5;
font-weight: 520;
letter-spacing: -.018em;
text-wrap: pretty;
}
.ac-19__quote::before {
content: '\201C';
display: block;
font-size: 44px;
line-height: .6;
color: color-mix(in oklch,var(--ac-19-acc) 40%,transparent);
margin-bottom: 8px;
}
.ac-19__by {
display: flex;
align-items: center;
gap: 12px;
}
.ac-19__by img {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
background: oklch(0.88 0.02 60);
}
.ac-19__by span {
display: grid;
gap: 2px;
}
.ac-19__by b {
font-size: 14px;
font-weight: 650;
letter-spacing: -.015em;
}
.ac-19__by i {
font-style: normal;
font-size: 12px;
color: var(--ac-19-mut);
}
.ac-19__ctl {
display: flex;
align-items: center;
gap: 9px;
}
.ac-19__nav,
.ac-19__play {
min-height: 44px;
border: 1px solid var(--ac-19-line);
border-radius: 99px;
background: var(--ac-19-card);
color: var(--ac-19-ink);
font-family: inherit;
cursor: pointer;
transition: background .2s,transform .2s;
}
.ac-19__nav {
width: 44px;
display: grid;
place-items: center;
}
.ac-19__play {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 0 18px;
font-size: 13.5px;
font-weight: 640;
}
.ac-19__nav:hover,
.ac-19__play:hover {
background: oklch(0.24 0.02 60/.05);
transform: translateY(-1px);
}
.ac-19__nav:focus-visible,
.ac-19__play:focus-visible {
outline: 2px solid var(--ac-19-acc);
outline-offset: 3px;
}
.ac-19__pi {
width: 11px;
height: 12px;
background: currentColor;
-webkit-mask: linear-gradient(#000 0 0) 0 0/3.5px 12px no-repeat,linear-gradient(#000 0 0) 100% 0/3.5px 12px no-repeat;
mask: linear-gradient(#000 0 0) 0 0/3.5px 12px no-repeat,linear-gradient(#000 0 0) 100% 0/3.5px 12px no-repeat;
}
.ac-19__play[aria-pressed="false"] .ac-19__pi {
-webkit-mask: none;
mask: none;
clip-path: polygon(0 0,100% 50%,0 100%);
width: 10px;
}
.ac-19__dots {
display: inline-flex;
gap: 6px;
margin-left: 6px;
}
.ac-19__dots i {
width: 7px;
height: 7px;
border-radius: 50%;
background: oklch(0.24 0.02 60/.2);
transition: background .3s,width .3s;
}
.ac-19__dots i[data-on] {
width: 20px;
border-radius: 99px;
background: var(--ac-19-acc);
}
.ac-19__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.ac-19__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
line-height: 1.6;
color: var(--ac-19-mut);
padding: 8px 15px;
border: 1px dashed var(--ac-19-line);
border-radius: 99px;
text-align: center;
max-width: min(100%,760px);
}
@media (prefers-reduced-motion: reduce) {
.ac-19__card {
transition-duration: .01ms;
}
}(() => {
const DELAY = 6000;
document.querySelectorAll('.ac-19').forEach((root) => {
if (root.dataset.wired) return;
root.dataset.wired = '1';
const deck = root.querySelector('[data-deck]');
const cards = [...deck.querySelectorAll('[data-card]')];
const dots = root.querySelector('[data-dots]');
const status = root.querySelector('[data-status]');
const play = root.querySelector('[data-play]');
const playLabel = root.querySelector('[data-playlabel]');
let order = cards.map((_, i) => i); // order[position] = card index
let timer = 0, running = true;
dots.innerHTML = cards.map(() => '<i></i>').join('');
const pips = [...dots.querySelectorAll('i')];
const paint = (announce) => {
order.forEach((cardIdx, pos) => {
const c = cards[cardIdx];
c.style.setProperty('--ac-19-i', pos);
c.dataset.i = pos;
if (pos === 0) { c.removeAttribute('inert'); c.removeAttribute('aria-hidden'); }
else { c.setAttribute('inert', ''); c.setAttribute('aria-hidden', 'true'); }
});
pips.forEach((p, i) => (i === order[0] ? p.setAttribute('data-on', '') : p.removeAttribute('data-on')));
if (announce) {
const who = cards[order[0]].querySelector('b').textContent;
status.textContent = 'Testimonial ' + (order[0] + 1) + ' of ' + cards.length + ', ' + who + '.';
}
};
const next = (announce) => {
const front = cards[order[0]];
front.dataset.out = ''; // deal the front card away
setTimeout(() => { front.removeAttribute('data-out'); }, 480);
order = order.slice(1).concat(order[0]); // and wrap it to the back
paint(announce);
};
const prev = () => { order = [order[order.length - 1]].concat(order.slice(0, -1)); paint(true); };
const start = () => { clearInterval(timer); if (running) timer = setInterval(() => next(false), DELAY); };
const stop = () => clearInterval(timer);
play.addEventListener('click', () => {
running = !running;
play.setAttribute('aria-pressed', String(running));
playLabel.textContent = running ? 'Pause' : 'Play';
running ? start() : stop();
});
root.querySelector('[data-next]').addEventListener('click', () => { next(true); start(); });
root.querySelector('[data-prev]').addEventListener('click', () => { prev(); start(); });
deck.addEventListener('pointerenter', stop); // WCAG: motion must be stoppable
deck.addEventListener('pointerleave', start);
deck.addEventListener('focusin', stop);
deck.addEventListener('focusout', start);
document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()));
paint(false); start();
});
})();(() => {
const DELAY = 6000;
document.querySelectorAll('.ac-19').forEach((root) => {
if (root.dataset.wired) return;
root.dataset.wired = '1';
const deck = root.querySelector('[data-deck]');
const cards = [...deck.querySelectorAll('[data-card]')];
const dots = root.querySelector('[data-dots]');
const status = root.querySelector('[data-status]');
const play = root.querySelector('[data-play]');
const playLabel = root.querySelector('[data-playlabel]');
let order = cards.map((_, i) => i); // order[position] = card index
let timer = 0, running = true;
dots.innerHTML = cards.map(() => '<i></i>').join('');
const pips = [...dots.querySelectorAll('i')];
const paint = (announce) => {
order.forEach((cardIdx, pos) => {
const c = cards[cardIdx];
c.style.setProperty('--ac-19-i', pos);
c.dataset.i = pos;
if (pos === 0) { c.removeAttribute('inert'); c.removeAttribute('aria-hidden'); }
else { c.setAttribute('inert', ''); c.setAttribute('aria-hidden', 'true'); }
});
pips.forEach((p, i) => (i === order[0] ? p.setAttribute('data-on', '') : p.removeAttribute('data-on')));
if (announce) {
const who = cards[order[0]].querySelector('b').textContent;
status.textContent = 'Testimonial ' + (order[0] + 1) + ' of ' + cards.length + ', ' + who + '.';
}
};
const next = (announce) => {
const front = cards[order[0]];
front.dataset.out = ''; // deal the front card away
setTimeout(() => { front.removeAttribute('data-out'); }, 480);
order = order.slice(1).concat(order[0]); // and wrap it to the back
paint(announce);
};
const prev = () => { order = [order[order.length - 1]].concat(order.slice(0, -1)); paint(true); };
const start = () => { clearInterval(timer); if (running) timer = setInterval(() => next(false), DELAY); };
const stop = () => clearInterval(timer);
play.addEventListener('click', () => {
running = !running;
play.setAttribute('aria-pressed', String(running));
playLabel.textContent = running ? 'Pause' : 'Play';
running ? start() : stop();
});
root.querySelector('[data-next]').addEventListener('click', () => { next(true); start(); });
root.querySelector('[data-prev]').addEventListener('click', () => { prev(); start(); });
deck.addEventListener('pointerenter', stop); // WCAG: motion must be stoppable
deck.addEventListener('pointerleave', start);
deck.addEventListener('focusin', stop);
deck.addEventListener('focusout', start);
document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()));
paint(false); start();
});
})();Here's a working CSS Animated Card 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: Auto-Cycling Testimonial Deck
Source: https://codefronts.com/components/css-animated-cards/auto-cycling-testimonial-deck/
Testimonials in a physical stack: the front card slides away and drops to the back while the rest step forward, on a 6-second loop that pauses on hover, on focus, and whenever the tab is hidden. Depth comes from one transform per position (translateY + scale + a whisper of rotation) driven by a single --i variable, so any number of cards works with no extra CSS.
## HTML
```html
<section class="ac-19" aria-label="Auto cycling testimonial deck demo">
<div class="ac-19__stage">
<div class="ac-19__deck" data-deck aria-roledescription="carousel" aria-label="Customer testimonials">
<article class="ac-19__card" data-card>
<p class="ac-19__quote">We deleted three animation dependencies the week we moved to native CSS timelines. Bundle down 41 kB, and the scroll finally feels like the OS.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Marta Vieira</b><i>Principal Engineer · Kestrel</i></span></footer>
</article>
<article class="ac-19__card" data-card>
<p class="ac-19__quote">Our checkout conversion moved 3.2% after the card-to-detail morph shipped. Nobody could tell us why — it just felt faster than it was.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Idris Bello</b><i>Head of Product · Northbay</i></span></footer>
</article>
<article class="ac-19__card" data-card>
<p class="ac-19__quote">The accessibility review passed first time. Reduced motion, focus states and live regions were in the components from day one instead of bolted on in Q4.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Sunny Ellis</b><i>Design Systems Lead · Fathom</i></span></footer>
</article>
<article class="ac-19__card" data-card>
<p class="ac-19__quote">I teach front-end to career changers. This is the first card library where the students can read the CSS and explain it back to me.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1544005313-94ddf0286df2?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Priya Raman</b><i>Instructor · Kanto Academy</i></span></footer>
</article>
</div>
<div class="ac-19__ctl">
<button class="ac-19__nav" type="button" data-prev aria-label="Previous testimonial">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<button class="ac-19__play" type="button" data-play aria-pressed="true">
<span class="ac-19__pi" aria-hidden="true"></span>
<span data-playlabel>Pause</span>
</button>
<button class="ac-19__nav" type="button" data-next aria-label="Next testimonial">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
</button>
<span class="ac-19__dots" data-dots aria-hidden="true"></span>
</div>
<p class="ac-19__sr" role="status" aria-live="polite" data-status></p>
<p class="ac-19__chip" aria-hidden="true">--i driven depth transform · deal-to-back loop · pause on hover / focus / tab-hidden · WCAG 2.2.2 control</p>
</div>
</section>
```
## CSS
```css
.ac-19,
.ac-19 *,
.ac-19 *::before,
.ac-19 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ac-19 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
--ac-19-bg: oklch(0.96 0.012 75);
--ac-19-card: oklch(1 0 0);
--ac-19-ink: oklch(0.24 0.02 60);
--ac-19-mut: oklch(0.55 0.02 60);
--ac-19-acc: oklch(0.52 0.14 42);
--ac-19-line: oklch(0.24 0.02 60/.12);
background: var(--ac-19-bg);
color: var(--ac-19-ink);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
-webkit-font-smoothing: antialiased;
}
.ac-19__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: clamp(22px,5vw,58px);
}
.ac-19__deck {
position: relative;
width: min(100%,480px);
height: 265px;
}
.ac-19__card {
position: absolute;
inset: 0;
display: grid;
align-content: space-between;
gap: 16px;
padding: 26px;
border: 1px solid var(--ac-19-line);
border-radius: 22px;
background: var(--ac-19-card);
box-shadow: 0 22px 44px -30px oklch(0.24 0.02 60/.8);
transform-origin: 50% 100%;
translate: 0 calc(var(--ac-19-i,0) * 14px);
scale: calc(1 - var(--ac-19-i,0) * .05);
rotate: calc(var(--ac-19-i,0) * .7deg);
filter: brightness(calc(1 - var(--ac-19-i,0) * .04));
z-index: calc(10 - var(--ac-19-i,0));
opacity: 1;
transition: translate .62s cubic-bezier(.16,1,.3,1),scale .62s cubic-bezier(.16,1,.3,1),rotate .62s cubic-bezier(.16,1,.3,1),opacity .45s ease,filter .5s;
}
.ac-19__card[data-i="3"] {
opacity: .9;
}
.ac-19__card[data-out] {
translate: -118% -6%;
rotate: -9deg;
opacity: 0;
transition-duration: .5s;
}
.ac-19__quote {
font-size: clamp(16px,2.2vw,19px);
line-height: 1.5;
font-weight: 520;
letter-spacing: -.018em;
text-wrap: pretty;
}
.ac-19__quote::before {
content: '\201C';
display: block;
font-size: 44px;
line-height: .6;
color: color-mix(in oklch,var(--ac-19-acc) 40%,transparent);
margin-bottom: 8px;
}
.ac-19__by {
display: flex;
align-items: center;
gap: 12px;
}
.ac-19__by img {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
background: oklch(0.88 0.02 60);
}
.ac-19__by span {
display: grid;
gap: 2px;
}
.ac-19__by b {
font-size: 14px;
font-weight: 650;
letter-spacing: -.015em;
}
.ac-19__by i {
font-style: normal;
font-size: 12px;
color: var(--ac-19-mut);
}
.ac-19__ctl {
display: flex;
align-items: center;
gap: 9px;
}
.ac-19__nav,
.ac-19__play {
min-height: 44px;
border: 1px solid var(--ac-19-line);
border-radius: 99px;
background: var(--ac-19-card);
color: var(--ac-19-ink);
font-family: inherit;
cursor: pointer;
transition: background .2s,transform .2s;
}
.ac-19__nav {
width: 44px;
display: grid;
place-items: center;
}
.ac-19__play {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 0 18px;
font-size: 13.5px;
font-weight: 640;
}
.ac-19__nav:hover,
.ac-19__play:hover {
background: oklch(0.24 0.02 60/.05);
transform: translateY(-1px);
}
.ac-19__nav:focus-visible,
.ac-19__play:focus-visible {
outline: 2px solid var(--ac-19-acc);
outline-offset: 3px;
}
.ac-19__pi {
width: 11px;
height: 12px;
background: currentColor;
-webkit-mask: linear-gradient(#000 0 0) 0 0/3.5px 12px no-repeat,linear-gradient(#000 0 0) 100% 0/3.5px 12px no-repeat;
mask: linear-gradient(#000 0 0) 0 0/3.5px 12px no-repeat,linear-gradient(#000 0 0) 100% 0/3.5px 12px no-repeat;
}
.ac-19__play[aria-pressed="false"] .ac-19__pi {
-webkit-mask: none;
mask: none;
clip-path: polygon(0 0,100% 50%,0 100%);
width: 10px;
}
.ac-19__dots {
display: inline-flex;
gap: 6px;
margin-left: 6px;
}
.ac-19__dots i {
width: 7px;
height: 7px;
border-radius: 50%;
background: oklch(0.24 0.02 60/.2);
transition: background .3s,width .3s;
}
.ac-19__dots i[data-on] {
width: 20px;
border-radius: 99px;
background: var(--ac-19-acc);
}
.ac-19__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.ac-19__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
line-height: 1.6;
color: var(--ac-19-mut);
padding: 8px 15px;
border: 1px dashed var(--ac-19-line);
border-radius: 99px;
text-align: center;
max-width: min(100%,760px);
}
@media (prefers-reduced-motion: reduce) {
.ac-19__card {
transition-duration: .01ms;
}
}
```
## JavaScript
```js
(() => {
const DELAY = 6000;
document.querySelectorAll('.ac-19').forEach((root) => {
if (root.dataset.wired) return;
root.dataset.wired = '1';
const deck = root.querySelector('[data-deck]');
const cards = [...deck.querySelectorAll('[data-card]')];
const dots = root.querySelector('[data-dots]');
const status = root.querySelector('[data-status]');
const play = root.querySelector('[data-play]');
const playLabel = root.querySelector('[data-playlabel]');
let order = cards.map((_, i) => i); // order[position] = card index
let timer = 0, running = true;
dots.innerHTML = cards.map(() => '<i></i>').join('');
const pips = [...dots.querySelectorAll('i')];
const paint = (announce) => {
order.forEach((cardIdx, pos) => {
const c = cards[cardIdx];
c.style.setProperty('--ac-19-i', pos);
c.dataset.i = pos;
if (pos === 0) { c.removeAttribute('inert'); c.removeAttribute('aria-hidden'); }
else { c.setAttribute('inert', ''); c.setAttribute('aria-hidden', 'true'); }
});
pips.forEach((p, i) => (i === order[0] ? p.setAttribute('data-on', '') : p.removeAttribute('data-on')));
if (announce) {
const who = cards[order[0]].querySelector('b').textContent;
status.textContent = 'Testimonial ' + (order[0] + 1) + ' of ' + cards.length + ', ' + who + '.';
}
};
const next = (announce) => {
const front = cards[order[0]];
front.dataset.out = ''; // deal the front card away
setTimeout(() => { front.removeAttribute('data-out'); }, 480);
order = order.slice(1).concat(order[0]); // and wrap it to the back
paint(announce);
};
const prev = () => { order = [order[order.length - 1]].concat(order.slice(0, -1)); paint(true); };
const start = () => { clearInterval(timer); if (running) timer = setInterval(() => next(false), DELAY); };
const stop = () => clearInterval(timer);
play.addEventListener('click', () => {
running = !running;
play.setAttribute('aria-pressed', String(running));
playLabel.textContent = running ? 'Pause' : 'Play';
running ? start() : stop();
});
root.querySelector('[data-next]').addEventListener('click', () => { next(true); start(); });
root.querySelector('[data-prev]').addEventListener('click', () => { prev(); start(); });
deck.addEventListener('pointerenter', stop); // WCAG: motion must be stoppable
deck.addEventListener('pointerleave', start);
deck.addEventListener('focusin', stop);
deck.addEventListener('focusout', start);
document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()));
paint(false); start();
});
})();
```Here's a working CSS Animated Card 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: Auto-Cycling Testimonial Deck
Source: https://codefronts.com/components/css-animated-cards/auto-cycling-testimonial-deck/
Testimonials in a physical stack: the front card slides away and drops to the back while the rest step forward, on a 6-second loop that pauses on hover, on focus, and whenever the tab is hidden. Depth comes from one transform per position (translateY + scale + a whisper of rotation) driven by a single --i variable, so any number of cards works with no extra CSS.
## HTML
```html
<section class="ac-19" aria-label="Auto cycling testimonial deck demo">
<div class="ac-19__stage">
<div class="ac-19__deck" data-deck aria-roledescription="carousel" aria-label="Customer testimonials">
<article class="ac-19__card" data-card>
<p class="ac-19__quote">We deleted three animation dependencies the week we moved to native CSS timelines. Bundle down 41 kB, and the scroll finally feels like the OS.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Marta Vieira</b><i>Principal Engineer · Kestrel</i></span></footer>
</article>
<article class="ac-19__card" data-card>
<p class="ac-19__quote">Our checkout conversion moved 3.2% after the card-to-detail morph shipped. Nobody could tell us why — it just felt faster than it was.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Idris Bello</b><i>Head of Product · Northbay</i></span></footer>
</article>
<article class="ac-19__card" data-card>
<p class="ac-19__quote">The accessibility review passed first time. Reduced motion, focus states and live regions were in the components from day one instead of bolted on in Q4.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Sunny Ellis</b><i>Design Systems Lead · Fathom</i></span></footer>
</article>
<article class="ac-19__card" data-card>
<p class="ac-19__quote">I teach front-end to career changers. This is the first card library where the students can read the CSS and explain it back to me.</p>
<footer class="ac-19__by"><img src="https://images.unsplash.com/photo-1544005313-94ddf0286df2?q=80&w=120&h=120&fit=crop&auto=format" alt="" width="44" height="44"><span><b>Priya Raman</b><i>Instructor · Kanto Academy</i></span></footer>
</article>
</div>
<div class="ac-19__ctl">
<button class="ac-19__nav" type="button" data-prev aria-label="Previous testimonial">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<button class="ac-19__play" type="button" data-play aria-pressed="true">
<span class="ac-19__pi" aria-hidden="true"></span>
<span data-playlabel>Pause</span>
</button>
<button class="ac-19__nav" type="button" data-next aria-label="Next testimonial">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
</button>
<span class="ac-19__dots" data-dots aria-hidden="true"></span>
</div>
<p class="ac-19__sr" role="status" aria-live="polite" data-status></p>
<p class="ac-19__chip" aria-hidden="true">--i driven depth transform · deal-to-back loop · pause on hover / focus / tab-hidden · WCAG 2.2.2 control</p>
</div>
</section>
```
## CSS
```css
.ac-19,
.ac-19 *,
.ac-19 *::before,
.ac-19 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ac-19 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
--ac-19-bg: oklch(0.96 0.012 75);
--ac-19-card: oklch(1 0 0);
--ac-19-ink: oklch(0.24 0.02 60);
--ac-19-mut: oklch(0.55 0.02 60);
--ac-19-acc: oklch(0.52 0.14 42);
--ac-19-line: oklch(0.24 0.02 60/.12);
background: var(--ac-19-bg);
color: var(--ac-19-ink);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
-webkit-font-smoothing: antialiased;
}
.ac-19__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: clamp(22px,5vw,58px);
}
.ac-19__deck {
position: relative;
width: min(100%,480px);
height: 265px;
}
.ac-19__card {
position: absolute;
inset: 0;
display: grid;
align-content: space-between;
gap: 16px;
padding: 26px;
border: 1px solid var(--ac-19-line);
border-radius: 22px;
background: var(--ac-19-card);
box-shadow: 0 22px 44px -30px oklch(0.24 0.02 60/.8);
transform-origin: 50% 100%;
translate: 0 calc(var(--ac-19-i,0) * 14px);
scale: calc(1 - var(--ac-19-i,0) * .05);
rotate: calc(var(--ac-19-i,0) * .7deg);
filter: brightness(calc(1 - var(--ac-19-i,0) * .04));
z-index: calc(10 - var(--ac-19-i,0));
opacity: 1;
transition: translate .62s cubic-bezier(.16,1,.3,1),scale .62s cubic-bezier(.16,1,.3,1),rotate .62s cubic-bezier(.16,1,.3,1),opacity .45s ease,filter .5s;
}
.ac-19__card[data-i="3"] {
opacity: .9;
}
.ac-19__card[data-out] {
translate: -118% -6%;
rotate: -9deg;
opacity: 0;
transition-duration: .5s;
}
.ac-19__quote {
font-size: clamp(16px,2.2vw,19px);
line-height: 1.5;
font-weight: 520;
letter-spacing: -.018em;
text-wrap: pretty;
}
.ac-19__quote::before {
content: '\201C';
display: block;
font-size: 44px;
line-height: .6;
color: color-mix(in oklch,var(--ac-19-acc) 40%,transparent);
margin-bottom: 8px;
}
.ac-19__by {
display: flex;
align-items: center;
gap: 12px;
}
.ac-19__by img {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
background: oklch(0.88 0.02 60);
}
.ac-19__by span {
display: grid;
gap: 2px;
}
.ac-19__by b {
font-size: 14px;
font-weight: 650;
letter-spacing: -.015em;
}
.ac-19__by i {
font-style: normal;
font-size: 12px;
color: var(--ac-19-mut);
}
.ac-19__ctl {
display: flex;
align-items: center;
gap: 9px;
}
.ac-19__nav,
.ac-19__play {
min-height: 44px;
border: 1px solid var(--ac-19-line);
border-radius: 99px;
background: var(--ac-19-card);
color: var(--ac-19-ink);
font-family: inherit;
cursor: pointer;
transition: background .2s,transform .2s;
}
.ac-19__nav {
width: 44px;
display: grid;
place-items: center;
}
.ac-19__play {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 0 18px;
font-size: 13.5px;
font-weight: 640;
}
.ac-19__nav:hover,
.ac-19__play:hover {
background: oklch(0.24 0.02 60/.05);
transform: translateY(-1px);
}
.ac-19__nav:focus-visible,
.ac-19__play:focus-visible {
outline: 2px solid var(--ac-19-acc);
outline-offset: 3px;
}
.ac-19__pi {
width: 11px;
height: 12px;
background: currentColor;
-webkit-mask: linear-gradient(#000 0 0) 0 0/3.5px 12px no-repeat,linear-gradient(#000 0 0) 100% 0/3.5px 12px no-repeat;
mask: linear-gradient(#000 0 0) 0 0/3.5px 12px no-repeat,linear-gradient(#000 0 0) 100% 0/3.5px 12px no-repeat;
}
.ac-19__play[aria-pressed="false"] .ac-19__pi {
-webkit-mask: none;
mask: none;
clip-path: polygon(0 0,100% 50%,0 100%);
width: 10px;
}
.ac-19__dots {
display: inline-flex;
gap: 6px;
margin-left: 6px;
}
.ac-19__dots i {
width: 7px;
height: 7px;
border-radius: 50%;
background: oklch(0.24 0.02 60/.2);
transition: background .3s,width .3s;
}
.ac-19__dots i[data-on] {
width: 20px;
border-radius: 99px;
background: var(--ac-19-acc);
}
.ac-19__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.ac-19__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
line-height: 1.6;
color: var(--ac-19-mut);
padding: 8px 15px;
border: 1px dashed var(--ac-19-line);
border-radius: 99px;
text-align: center;
max-width: min(100%,760px);
}
@media (prefers-reduced-motion: reduce) {
.ac-19__card {
transition-duration: .01ms;
}
}
```
## JavaScript
```js
(() => {
const DELAY = 6000;
document.querySelectorAll('.ac-19').forEach((root) => {
if (root.dataset.wired) return;
root.dataset.wired = '1';
const deck = root.querySelector('[data-deck]');
const cards = [...deck.querySelectorAll('[data-card]')];
const dots = root.querySelector('[data-dots]');
const status = root.querySelector('[data-status]');
const play = root.querySelector('[data-play]');
const playLabel = root.querySelector('[data-playlabel]');
let order = cards.map((_, i) => i); // order[position] = card index
let timer = 0, running = true;
dots.innerHTML = cards.map(() => '<i></i>').join('');
const pips = [...dots.querySelectorAll('i')];
const paint = (announce) => {
order.forEach((cardIdx, pos) => {
const c = cards[cardIdx];
c.style.setProperty('--ac-19-i', pos);
c.dataset.i = pos;
if (pos === 0) { c.removeAttribute('inert'); c.removeAttribute('aria-hidden'); }
else { c.setAttribute('inert', ''); c.setAttribute('aria-hidden', 'true'); }
});
pips.forEach((p, i) => (i === order[0] ? p.setAttribute('data-on', '') : p.removeAttribute('data-on')));
if (announce) {
const who = cards[order[0]].querySelector('b').textContent;
status.textContent = 'Testimonial ' + (order[0] + 1) + ' of ' + cards.length + ', ' + who + '.';
}
};
const next = (announce) => {
const front = cards[order[0]];
front.dataset.out = ''; // deal the front card away
setTimeout(() => { front.removeAttribute('data-out'); }, 480);
order = order.slice(1).concat(order[0]); // and wrap it to the back
paint(announce);
};
const prev = () => { order = [order[order.length - 1]].concat(order.slice(0, -1)); paint(true); };
const start = () => { clearInterval(timer); if (running) timer = setInterval(() => next(false), DELAY); };
const stop = () => clearInterval(timer);
play.addEventListener('click', () => {
running = !running;
play.setAttribute('aria-pressed', String(running));
playLabel.textContent = running ? 'Pause' : 'Play';
running ? start() : stop();
});
root.querySelector('[data-next]').addEventListener('click', () => { next(true); start(); });
root.querySelector('[data-prev]').addEventListener('click', () => { prev(); start(); });
deck.addEventListener('pointerenter', stop); // WCAG: motion must be stoppable
deck.addEventListener('pointerleave', start);
deck.addEventListener('focusin', stop);
deck.addEventListener('focusout', start);
document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()));
paint(false); start();
});
})();
```How this works
Position in the stack is data, not markup. Each card gets an index and derives its whole appearance from it:
.card { --i: 0;
translate: 0 calc(var(--i) * 14px);
scale: calc(1 - var(--i) * .05);
rotate: calc(var(--i) * .7deg);
filter: brightness(calc(1 - var(--i) * .08));
z-index: calc(10 - var(--i));
transition: translate .6s cubic-bezier(.16,1,.3,1), scale .6s, rotate .6s, opacity .5s }Advancing is then one line of JS per card: decrement every index and wrap the front card to the back. No DOM reordering, no clones, no measuring.
The exit uses the same transition with an overridden state (translate: -120% -8%; rotate: -8deg; opacity: 0), and the card is put back at the highest index once it is invisible — the classic "deal to the bottom of the deck" illusion.
document.addEventListener('visibilitychange', () => hidden ? stop() : start());
deck.addEventListener('pointerenter', stop);
deck.addEventListener('focusin', stop);Autoplay is the accessibility risk here, so it is handled properly: a real pause/play button (WCAG 2.2.2 requires a mechanism to stop moving content), pause on hover and keyboard focus, a hidden timer while the tab is backgrounded, and previous/next buttons that work independently. The live region announces the author, not the animation.
Make it yours
- Swipe to advance: reuse the pointer logic from demo 10 — drag the front card past 30% to deal it, snap back below.
- Fan layout: add
rotate: calc((var(--i) - 1) * 4deg)and a horizontal offset for a hand-of-cards spread instead of a vertical stack. - Progress ring on the play button using the conic technique from demo 14, so the user can see when the next card is due.
- Crossfade instead of deal: for text-heavy quotes, fade and scale in place — the deal motion is best when the cards look physical.
Gotchas — read before shipping
- Autoplay without a pause control fails WCAG 2.2.2 for anything that moves for more than five seconds. The button is not optional.
- Pause on
focusin, not just hover — a keyboard user tabbing into a moving card cannot read it otherwise. - Never announce every rotation in a live region; screen-reader users get a constant stream. Announce on manual navigation only, or use aria-live="off" during autoplay.
- Cards behind the front one must be
inertoraria-hidden, or the tab order walks into invisible content. - Restarting the interval on every interaction without clearing it leaves multiple timers running and the deck starts flickering.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by oklch(), color-mix(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 104+.
Individual transform properties, custom-property-driven calc and the Page Visibility API are Baseline Widely Available. The inert attribute (Chrome 102, Safari 15.5, Firefox 112) degrades to aria-hidden where missing.