Transitions & Animations
Page transitions, view transitions, theme toggles and entry/exit animations.
Updated
What is the difference between a CSS transition, an animation and a view transition?
A CSS transition animates a property between two states when it changes, on hover or when a class is toggled, while a CSS animation plays keyframes on its own. View transitions go further: the View Transitions API captures the old and new state of the page and animates between them, so a card can grow into its detail view.
How to choose, performance and accessibility
- Use Crossfade Transition or Fade Through between content states, Circle Reveal or Curtain Open for dramatic section changes, Dark Mode for a theme toggle, and Popover Bloom or Dialog Curtain for menus and modals.
- Keep most interface transitions between about 150 and 400 ms, animate
transformandopacityto avoid layout shifts, and let browsers without the View Transitions API switch instantly. - Fade Through, Crossfade Transition, Dark Mode and several other transitions are free with their code; the rest come with Premium.
View source code
<div class="ft-stage">
<div class="transition-preview">
<div class="transition-content old-content">Page A</div>
<div class="transition-content new-content">Page B</div>
<div class="transition-overlay fade-through-overlay"></div>
</div>
<button type="button" class="trigger-btn">Trigger Transition</button>
</div>.ft-stage,
.ft-stage *,
.ft-stage *::before,
.ft-stage *::after {
box-sizing: border-box
}
/* Racine de l'effet : écran en haut, bouton dessous. */
.ft-stage {
--space-4: 1rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
min-height: 300px;
padding: 16px;
position: relative;
background: rgb(26, 26, 36);
color: #fff;
font-family: sans-serif
}
.ft-stage .transition-preview {
position: relative;
width: 100%;
height: 200px;
border-radius: 12px;
overflow: hidden;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)
}
.ft-stage .transition-content {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
font-weight: 600;
color: white
}
.ft-stage .transition-content.old-content {
background: linear-gradient(135deg, #4f46e5, #7c3aed)
}
.ft-stage .transition-content.new-content {
background: linear-gradient(135deg, #0e7490, #047857);
opacity: 0;
visibility: hidden
}
/* Page B affichée : la classe .is-b est posée sur l'écran au milieu de la transition, sous le calque,
et retirée au clic suivant (retour à A). */
.ft-stage .transition-preview.is-b .old-content {
opacity: 0;
visibility: hidden
}
.ft-stage .transition-preview.is-b .new-content {
opacity: 1;
visibility: visible
}
.ft-stage .trigger-btn {
padding: 12px 24px;
font-size: 0.9rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
color: white;
transition: all 0.3s ease;
margin-top: var(--space-4)
}
.ft-stage .trigger-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.4)
}
.ft-stage .trigger-btn[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
transform: none
}
.ft-stage .transition-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 10
}
.ft-stage .fade-through-overlay {
background: #0a0a0f;
opacity: 0
}
.ft-stage .fade-through-overlay.animating {
animation: fadeThrough 1.2s ease-in-out
}
@keyframes fadeThrough {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
60% {
opacity: 1;
}
100% {
opacity: 0;
}
}
/* Mouvement réduit : pas de calque, les deux pages s'échangent en fondu de 150 ms. */
@media (prefers-reduced-motion: reduce) {
.ft-stage .transition-content {
transition: opacity .15s, visibility .15s
}
}(function () {
/* fx-0685 — Fade Through : un voile noir monte jusqu'à couvrir la page A, la page change dessous,
puis le calque se retire sur la nouvelle page. Un clic mène à la page B et y reste ; le clic suivant ramène à A. */
var SWAP_AT = 600; // échange A ⇄ B, au milieu : le calque couvre tout l'écran
var END_AT = 1200; // fin de l'animation du calque (même durée que le CSS)
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('.ft-stage').forEach(function (stage) {
var screen = stage.querySelector('.transition-preview');
var layer = stage.querySelector('.fade-through-overlay');
var btn = stage.querySelector('.trigger-btn');
if (!screen || !layer || !btn) return;
var busy = false;
btn.addEventListener('click', function () {
if (busy) return;
// Mouvement réduit : pas de calque, échange direct (fondu de 150 ms dans le CSS)
if (reduceMotion.matches) {
screen.classList.toggle('is-b');
return;
}
busy = true;
btn.setAttribute('aria-disabled', 'true'); // et non disabled, qui ferait perdre le focus clavier
layer.classList.add('animating');
setTimeout(function () {
screen.classList.toggle('is-b');
}, SWAP_AT);
setTimeout(function () {
layer.classList.remove('animating');
btn.removeAttribute('aria-disabled');
busy = false;
}, END_AT);
});
});
})();Flip Page
The page flips over like a book.
Bubble Morph
A shape morphs and expands to cover the screen then shrinks back.
Crossfade Transition
Smooth crossfade between two states. Simulates the native crossfade of the View Transitions API.
Effect page →View source code
<div class="crossfade-stage">
<div class="crossfade-frame" role="button" tabindex="0" aria-pressed="false">
<div class="crossfade-state crossfade-state-a">
<div class="crossfade-icon" aria-hidden="true">★</div>
<div>
<div style="font-size: 1rem; font-weight: 800;">State A</div>
<div style="font-size: 0.65rem; opacity: 0.7; margin-top: 2px;">Home page</div>
</div>
</div>
<div class="crossfade-state crossfade-state-b" aria-hidden="true">
<div class="crossfade-icon" aria-hidden="true">♥</div>
<div>
<div style="font-size: 1rem; font-weight: 800;">State B</div>
<div style="font-size: 0.65rem; opacity: 0.7; margin-top: 2px;">Profile page</div>
</div>
</div>
</div>
<span class="crossfade-hint">Click to toggle</span>
</div>.crossfade-stage,
.crossfade-stage *,
.crossfade-stage *::before,
.crossfade-stage *::after {
box-sizing: border-box
}
.crossfade-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 250px;
overflow: hidden;
position: relative;
background: rgb(26, 26, 36);
color: #fff;
font-family: sans-serif
}
.crossfade-hint {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
font-size: .65rem;
color: rgba(255, 255, 255, .35);
text-transform: uppercase;
letter-spacing: 2px;
white-space: nowrap;
pointer-events: none
}
.crossfade-frame {
width: 280px;
height: 180px;
border-radius: 16px;
position: relative;
overflow: hidden;
cursor: pointer
}
.crossfade-frame:focus-visible {
outline: 2px solid #f9a8d4;
outline-offset: 4px
}
.crossfade-state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.1rem;
letter-spacing: 1px;
transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 16px
}
.crossfade-state-a {
background: linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7);
opacity: 1
}
.crossfade-state-b {
background: linear-gradient(135deg, #f43f5e, #ec4899, #d946ef);
opacity: 0
}
.crossfade-frame.active .crossfade-state-a {
opacity: 0
}
.crossfade-frame.active .crossfade-state-b {
opacity: 1
}
.crossfade-icon {
width: 50px;
height: 50px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.2);
margin-right: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
backdrop-filter: blur(10px)
}
/* Mouvement réduit : le fondu reste (aucun déplacement), raccourci à 0,15 s */
@media (prefers-reduced-motion: reduce) {
.crossfade-state {
transition-duration: .15s
}
}(function() {
/* fx-0695 : Crossfade Transition — chaque .crossfade-frame de la page bascule entre l'état A et l'état B
(clic, Entrée ou Espace) et reste sur l'état atteint ; l'action suivante fait le chemin inverse. */
document.querySelectorAll('.crossfade-frame').forEach(function(frame) {
var a = frame.querySelector('.crossfade-state-a');
var b = frame.querySelector('.crossfade-state-b');
function toggle() {
var on = frame.classList.toggle('active');
frame.setAttribute('aria-pressed', on ? 'true' : 'false');
if (a) a.setAttribute('aria-hidden', on ? 'true' : 'false');
if (b) b.setAttribute('aria-hidden', on ? 'false' : 'true');
}
frame.addEventListener('click', toggle);
frame.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
});
});
})();Slide Transition
Content slides left while new content comes in from the right, like page-to-page navigation.
Effect page →Scale Morph
The element changes size, shape and color with an elastic animation curve.
Effect page →Fade & Slide Up
Content fades out upward and new content rises from the bottom. Ideal for profiles and cards.
Effect page →Flip Transition
The card performs a 3D rotation to reveal different content on the back. Classic and elegant effect.
Effect page →Zoom Transition
Zoom-in effect that enlarges the view to transition to a new detailed state. Perfect for galleries.
Effect page →Circular Reveal
On click, a circle grows from the center of the card (clip-path) and reveals the second state; a second click closes it. Ideal for theme toggles.
Stagger List
On click, list items slide out one by one with growing delays, then cascade back in on the second click. Works with your own items.
Page Turn
Simulates a 3D page turn effect to reveal the next page. Inspired by e-readers and digital books.
Effect page →Dark Mode
Dark theme active
Dark Mode
Theme toggle with circular reveal via clip-path: circle() from the top-right button.
Effect page →View source code
<div class="th-circular-root">
<div class="th-circular-scene">
<button class="th-circular-toggle" type="button" aria-pressed="false" aria-label="Light theme">
<svg class="th-circular-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<svg class="th-circular-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<div class="th-circular-dark">
<div class="th-circular-card">
<h3>Dark Mode</h3>
<p>Dark theme active</p>
<div class="th-circular-card-bar"><div class="th-circular-card-bar-fill"></div></div>
</div>
</div>
<div class="th-circular-light" aria-hidden="true">
<div class="th-circular-card">
<h3>Light Mode</h3>
<p>Light theme active</p>
<div class="th-circular-card-bar"><div class="th-circular-card-bar-fill"></div></div>
</div>
</div>
</div>
</div>.th-circular-root,
.th-circular-root *,
.th-circular-root *::before,
.th-circular-root *::after {
box-sizing: border-box
}
.th-circular-root {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 250px;
overflow: hidden;
position: relative;
background: rgb(26, 26, 36);
color: rgb(255, 255, 255);
font-family: sans-serif;
}
.th-circular-scene {
width: 300px;
height: 220px;
position: relative;
border-radius: 16px;
overflow: hidden;
}
/* Les deux thèmes sont deux calques superposés de la taille de la scène */
.th-circular-dark,
.th-circular-light {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.th-circular-dark {
background: #0f172a;
z-index: 1;
}
/* Le clair attend, réduit à un cercle de rayon 0 centré sur le bouton (30 px du bord haut et du bord droit) */
.th-circular-light {
background: #f8fafc;
z-index: 2;
clip-path: circle(0% at calc(100% - 30px) 30px);
transition: clip-path 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.th-circular-scene.active .th-circular-light {
clip-path: circle(150% at calc(100% - 30px) 30px);
}
.th-circular-dark h3 {
color: #fff;
font-size: 1rem;
font-weight: 700;
margin: 0;
}
.th-circular-dark p {
color: rgba(255, 255, 255, 0.5);
font-size: 0.75rem;
margin: 0;
}
.th-circular-light h3 {
color: #0f172a;
font-size: 1rem;
font-weight: 700;
margin: 0;
}
.th-circular-light p {
color: #64748b;
font-size: 0.75rem;
margin: 0;
}
.th-circular-toggle {
position: absolute;
top: 12px;
right: 12px;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
background: rgba(255, 255, 255, 0.1);
transition: background-color 0.3s ease;
}
.th-circular-scene.active .th-circular-toggle {
background: rgba(0, 0, 0, 0.08);
}
.th-circular-toggle:focus-visible {
outline: 2px solid #a78bfa;
outline-offset: 2px;
}
.th-circular-toggle svg {
width: 18px;
height: 18px;
stroke-width: 2;
fill: none;
}
.th-circular-sun {
stroke: #fbbf24;
}
.th-circular-moon {
stroke: #6366f1;
display: none;
}
.th-circular-scene.active .th-circular-sun {
display: none;
}
.th-circular-scene.active .th-circular-moon {
display: block;
}
.th-circular-card {
width: 220px;
padding: 16px;
border-radius: 12px;
}
.th-circular-dark .th-circular-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.th-circular-light .th-circular-card {
background: #fff;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.th-circular-card-bar {
height: 6px;
border-radius: 3px;
margin-top: 10px;
}
.th-circular-dark .th-circular-card-bar {
background: rgba(255, 255, 255, 0.08);
}
.th-circular-light .th-circular-card-bar {
background: #e2e8f0;
}
.th-circular-card-bar-fill {
height: 100%;
width: 65%;
border-radius: 3px;
background: linear-gradient(90deg, #8b5cf6, #a78bfa);
}
/* Mouvement réduit : pas de cercle qui grandit, un fondu de 150 ms */
@media (prefers-reduced-motion: reduce) {
.th-circular-light,
.th-circular-scene.active .th-circular-light {
clip-path: none;
}
.th-circular-light {
opacity: 0;
transition: opacity 0.15s linear;
}
.th-circular-scene.active .th-circular-light {
opacity: 1;
}
}(function () {
/* fx-0705 Dark Mode : le bouton de chaque .th-circular-scene pose ou retire .active (le CSS fait la transition) */
document.querySelectorAll('.th-circular-scene').forEach(function (scene) {
var btn = scene.querySelector('.th-circular-toggle');
var dark = scene.querySelector('.th-circular-dark');
var light = scene.querySelector('.th-circular-light');
if (!btn) return;
function sync() {
var on = scene.classList.contains('active');
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
if (dark) dark.setAttribute('aria-hidden', on ? 'true' : 'false');
if (light) light.setAttribute('aria-hidden', on ? 'false' : 'true');
}
btn.addEventListener('click', function () {
scene.classList.toggle('active');
sync();
});
sync();
});
})();Smooth Fade
Light/dark theme transition via cross-fade opacity; the moon icon spins away as the sun takes its place.
Wipe Transition
Horizontal wipe with clip-path: inset() and a luminous separation line crossing the screen.
Effect page →CSS Variables
Theme transition driven by six CSS custom properties: the colors that read them change smoothly.
Effect page →Icon Toggle
Theme button with rotational morphing between sun and moon icons. Combined rotation and scale.
Effect page →Card Theme
Complete card with avatar, text and buttons where each element transitions between the two themes.
Effect page →Gradient Shift
Gradient background shifting from cool nighttime hues to warm golden tones in 0.8s.
Effect page →Adaptive UI
Miniature interface (nav, content, blocks) that switches between dark and light theme with the sun/moon button in its bar.
Split Compare
Both themes stacked, with a "VS" divider you drag (mouse, touch, keyboard) to compare.
Effect page →Pixel Transition
Pixelated transition: colored blocks appear in random cascade to reveal the new theme.
Popover Bloom
The element blooms open from its anchor point with scale + opacity via @starting-style.
Effect page →View source code
<div class="popover-bloom-stage">
<button class="popover-bloom-btn" type="button" popovertarget="popoverBloomBox">Show the bubble</button>
<div class="popover-bloom-box" id="popoverBloomBox" popover>The bubble blooms from its anchor</div>
</div>.popover-bloom-stage,
.popover-bloom-stage *,
.popover-bloom-stage *::before,
.popover-bloom-stage *::after {
box-sizing: border-box
}
.popover-bloom-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 250px;
overflow: hidden;
position: relative;
background: rgb(26, 26, 36);
color: rgb(255, 255, 255);
font-family: sans-serif;
}
.popover-bloom-btn {
padding: 10px 24px;
font-size: 0.85rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
background: #4f46e5;
color: white;
transition: background 0.2s, transform 0.1s;
}
.popover-bloom-btn:hover {
background: #4338ca;
}
.popover-bloom-btn:active {
transform: scale(0.96);
}
/* La bulle est un popover : elle vit dans la couche supérieure (jamais coupée par un overflow: hidden,
jamais sous un z-index voisin) et se place au-dessus du bouton qui l'ouvre grâce à l'ancrage CSS
(position-area). Sans ancrage CSS, le script la place lui-même. */
.popover-bloom-box {
position: fixed;
inset: auto;
margin: 0 0 10px;
position-area: top;
border: 0;
overflow: visible;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
color: white;
padding: 14px 22px;
border-radius: 12px;
font-family: sans-serif;
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
box-shadow: 0 10px 40px rgba(99, 102, 241, 0.4);
opacity: 0;
scale: 0.6;
pointer-events: none;
transform-origin: bottom center;
transition: opacity 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
scale 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
display 0.4s allow-discrete,
overlay 0.4s allow-discrete;
}
.popover-bloom-box::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 8px solid transparent;
border-top-color: #7c3aed;
}
/* Ouverte : état d'arrivée. @starting-style donne l'état de départ de l'entrée (le popover
passe de display: none à affiché, il n'a pas d'état « avant » sans cette règle). */
.popover-bloom-box:popover-open {
opacity: 1;
scale: 1;
pointer-events: auto;
}
/* Repli des navigateurs sans API Popover (règle séparée : un sélecteur :popover-open inconnu
invaliderait toute la liste). */
.popover-bloom-box.popover-bloom-open {
opacity: 1;
scale: 1;
pointer-events: auto;
}
@starting-style {
.popover-bloom-box:popover-open {
opacity: 0;
scale: 0.6;
}
}
@media (prefers-reduced-motion: reduce) {
.popover-bloom-box {
scale: 1;
transition: opacity 0.15s ease, display 0.15s allow-discrete, overlay 0.15s allow-discrete;
}
@starting-style {
.popover-bloom-box:popover-open {
scale: 1;
}
}
}(function() {
/* Popover Bloom : l'attribut popovertarget ouvre et ferme la bulle sans script (Échap et clic
à l'extérieur compris). Ce script ajoute aria-expanded, ancre chaque bulle à son bouton et
prend le relais là où le navigateur ne connaît pas l'ancrage CSS ou l'API Popover. */
var hasPopover = HTMLElement.prototype.hasOwnProperty('popover');
var hasAnchor = !!(window.CSS && CSS.supports('position-area', 'top'));
var count = 0;
document.querySelectorAll('.popover-bloom-box').forEach(function(box) {
var btn = box.id && document.querySelector('[popovertarget="' + box.id + '"]');
if (!btn) return;
btn.setAttribute('aria-expanded', 'false');
function place() {
var r = btn.getBoundingClientRect();
box.style.left = (r.left + r.width / 2) + 'px';
box.style.top = (r.top - 10) + 'px';
box.style.translate = '-50% -100%';
box.style.margin = '0';
}
if (!hasPopover) {
/* Navigateur sans API Popover : bascule par classe, Échap ferme. */
box.setAttribute('aria-hidden', 'true');
var setOpen = function(open) {
if (open) place();
box.classList.toggle('popover-bloom-open', open);
box.setAttribute('aria-hidden', String(!open));
btn.setAttribute('aria-expanded', String(open));
};
btn.addEventListener('click', function() {
setOpen(!box.classList.contains('popover-bloom-open'));
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') setOpen(false);
});
return;
}
if (hasAnchor) {
/* Nom d'ancre propre à chaque bulle : plusieurs bulles cohabitent dans la page. */
var name = '--popover-bloom-' + (++count);
btn.style.setProperty('anchor-name', name);
box.style.setProperty('position-anchor', name);
}
box.addEventListener('beforetoggle', function(e) {
if (e.newState === 'open' && !hasAnchor) place();
});
box.addEventListener('toggle', function(e) {
btn.setAttribute('aria-expanded', String(e.newState === 'open'));
});
if (!hasAnchor) {
var follow = function() {
if (box.matches(':popover-open')) place();
};
window.addEventListener('scroll', follow, true);
window.addEventListener('resize', follow);
}
});
})();Dialog Curtain
Dialog with stage curtains: the curtains meet in the center, then the content appears.
Effect page →View source code
<div class="dialog-curtain-stage">
<div class="dialog-curtain-preview" aria-hidden="true">
<div class="dialog-curtain-preview-left"></div>
<div class="dialog-curtain-preview-right"></div>
<span class="dialog-curtain-preview-label">Stage Curtain</span>
</div>
<button class="dialog-curtain-btn" type="button" aria-haspopup="dialog" aria-controls="dialogCurtainModal">Open the dialog</button>
<dialog class="dialog-curtain-modal" id="dialogCurtainModal" aria-labelledby="dialogCurtainTitle">
<div class="dialog-curtain-backdrop"></div>
<div class="dialog-curtain-left"></div>
<div class="dialog-curtain-right"></div>
<div class="dialog-curtain-content">
<h3 id="dialogCurtainTitle">Stage Curtain</h3>
<p>The content appears once the curtains have closed.</p>
<button class="dialog-curtain-close" type="button">Close</button>
</div>
</dialog>
</div>.dialog-curtain-stage,
.dialog-curtain-stage *,
.dialog-curtain-stage *::before,
.dialog-curtain-stage *::after {
box-sizing: border-box
}
.dialog-curtain-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 250px;
overflow: hidden;
position: relative;
background: rgb(26, 26, 36);
color: rgb(255, 255, 255);
font-family: sans-serif;
}
.dialog-curtain-btn {
padding: 10px 24px;
font-size: 0.85rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
background: #4f46e5;
color: white;
transition: background 0.2s, transform 0.1s;
}
.dialog-curtain-btn:hover {
background: #4338ca;
}
.dialog-curtain-btn:active {
transform: scale(0.96);
}
.dialog-curtain-preview {
position: relative;
width: 200px;
height: 120px;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
background: #0a0a1a;
}
.dialog-curtain-preview-left,
.dialog-curtain-preview-right {
position: absolute;
top: 0;
width: 50%;
height: 100%;
transition: transform 0.6s cubic-bezier(0.65, 0, 0.35, 1);
}
.dialog-curtain-preview-left {
left: 0;
background: linear-gradient(135deg, #312e81, #4338ca);
transform: translateX(-100%);
}
.dialog-curtain-preview-right {
right: 0;
background: linear-gradient(225deg, #312e81, #4338ca);
transform: translateX(100%);
}
.dialog-curtain-preview.dialog-curtain-active .dialog-curtain-preview-left {
transform: translateX(0);
}
.dialog-curtain-preview.dialog-curtain-active .dialog-curtain-preview-right {
transform: translateX(0);
}
.dialog-curtain-preview-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.8);
opacity: 0;
color: white;
font-weight: 600;
font-size: 0.75rem;
z-index: 2;
transition: opacity 0.3s ease 0.4s, transform 0.3s ease 0.4s;
white-space: nowrap;
}
.dialog-curtain-preview.dialog-curtain-active .dialog-curtain-preview-label {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.dialog-curtain-left,
.dialog-curtain-right {
width: 50%;
height: 100%;
background: linear-gradient(135deg, #1a1a2e, #0f172a);
}
.dialog-curtain-left {
transform-origin: left center;
}
.dialog-curtain-right {
transform-origin: right center;
}
.dialog-curtain-modal {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
margin: 0;
padding: 0;
border: none;
background: transparent;
color: #fff;
overflow: hidden;
font-family: sans-serif;
}
/* Le fond assombri animé est .dialog-curtain-backdrop : celui du navigateur reste transparent. */
.dialog-curtain-modal::backdrop {
background: transparent;
}
.dialog-curtain-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0);
transition: background 0.6s ease;
}
.dialog-curtain-modal.dialog-curtain-open .dialog-curtain-backdrop {
background: rgba(0, 0, 0, 0.5);
}
.dialog-curtain-modal .dialog-curtain-left,
.dialog-curtain-modal .dialog-curtain-right {
position: absolute;
top: 0;
width: 50%;
height: 100%;
transition: transform 0.6s cubic-bezier(0.65, 0, 0.35, 1);
}
.dialog-curtain-modal .dialog-curtain-left {
left: 0;
background: linear-gradient(135deg, #1e1b4b, #312e81);
transform: translateX(-100%);
}
.dialog-curtain-modal .dialog-curtain-right {
right: 0;
background: linear-gradient(225deg, #1e1b4b, #312e81);
transform: translateX(100%);
}
.dialog-curtain-modal.dialog-curtain-open .dialog-curtain-left {
transform: translateX(0);
}
.dialog-curtain-modal.dialog-curtain-open .dialog-curtain-right {
transform: translateX(0);
}
.dialog-curtain-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.8);
opacity: 0;
background: linear-gradient(135deg, #1e1e2e, #2d2b55);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 32px;
color: white;
text-align: center;
z-index: 2;
transition: opacity 0.4s ease 0.5s, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.5s;
min-width: 200px;
}
.dialog-curtain-modal.dialog-curtain-open .dialog-curtain-content {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.dialog-curtain-modal.dialog-curtain-closing .dialog-curtain-content {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.dialog-curtain-modal.dialog-curtain-closing .dialog-curtain-left {
transform: translateX(-100%);
transition-delay: 0.15s;
}
.dialog-curtain-modal.dialog-curtain-closing .dialog-curtain-right {
transform: translateX(100%);
transition-delay: 0.15s;
}
.dialog-curtain-modal.dialog-curtain-closing .dialog-curtain-backdrop {
background: rgba(0, 0, 0, 0);
transition-delay: 0.15s;
}
.dialog-curtain-content h3 {
font-size: 1.2rem;
margin: 0 0 8px;
}
.dialog-curtain-content p {
margin: 0 0 16px;
opacity: 0.7;
font-size: 0.85rem;
}
.dialog-curtain-close {
padding: 8px 20px;
background: #4f46e5;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-weight: 600;
font-size: 0.85rem;
}
.dialog-curtain-close:focus-visible {
outline: 2px solid #c7d2fe;
outline-offset: 3px;
}
/* Mouvement réduit : pas de rideaux qui glissent, chaque élément prend directement son état. */
@media (prefers-reduced-motion: reduce) {
.dialog-curtain-stage *,
.dialog-curtain-modal *,
.dialog-curtain-modal *::before,
.dialog-curtain-modal *::after {
transition: none !important;
}
}(function() {
/* Dialog Curtain : un vrai <dialog> ouvert en modale (showModal) — le reste de la page devient
inerte, le focus entre dans la fenêtre et revient au bouton à la fermeture. Les classes
ne pilotent que l'animation en trois temps : fond, rideaux, puis contenu. */
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('.dialog-curtain-btn[aria-controls]').forEach(function(btn) {
var dialog = document.getElementById(btn.getAttribute('aria-controls'));
if (!dialog || typeof dialog.showModal !== 'function') return;
var stage = btn.closest('.dialog-curtain-stage');
var preview = stage && stage.querySelector('.dialog-curtain-preview');
var timer = null;
function reset() {
clearTimeout(timer);
timer = null;
dialog.classList.remove('dialog-curtain-open', 'dialog-curtain-closing');
}
function open() {
if (dialog.open) return;
reset();
dialog.showModal();
void dialog.offsetWidth; /* point de départ des transitions : rideaux ouverts, contenu caché */
dialog.classList.add('dialog-curtain-open');
if (preview) preview.classList.add('dialog-curtain-active');
}
function close() {
if (!dialog.open || timer) return;
dialog.classList.add('dialog-curtain-closing');
if (preview) preview.classList.remove('dialog-curtain-active');
timer = setTimeout(function() {
dialog.close();
}, reduce.matches ? 0 : 700);
}
btn.addEventListener('click', open);
/* Une fois fermés, les rideaux recouvrent tout le fond : un clic dessus ferme aussi. */
dialog.querySelectorAll('.dialog-curtain-close, .dialog-curtain-backdrop, .dialog-curtain-left, .dialog-curtain-right').forEach(function(el) {
el.addEventListener('click', close);
});
/* Échap : la fermeture passe par l'animation inverse au lieu d'être immédiate. */
dialog.addEventListener('cancel', function(e) {
e.preventDefault();
close();
});
/* Quelle que soit la façon dont la fenêtre se ferme, elle repart propre. */
dialog.addEventListener('close', function() {
reset();
if (preview) preview.classList.remove('dialog-curtain-active');
});
});
})();Toast Cascade
Stacked toast notifications with fluid entry, reordering and graceful exit.
Effect page →Card Vanish
The card dissolves into particles on removal and materializes from particles on entry.
Effect page →List Item Slide
Elements slide in from the side with staggered delays and collapse on removal.
Effect page →Accordion Fold
Content sections that fold/unfold like paper with 3D perspective transforms.
Effect page →Morph Transition
The element morphs shape/position transitioning from display:none to display:block with allow-discrete.
Effect page →Portal Open
Content appears through an expanding circular mask, like a portal opening.
Effect page →Flip Card Entry
The card enters edge-on with a 3D rotation, then leaves by turning on in the same direction.
Effect page →Fade Through Color
Elements pass through a brand color (not just opacity) during entry/exit.
Motion Design
Staggered Reveal
Elements appearing in cascade with progressive delay for a coordinated effect.
Effect page →View source code
<div class="md-stagger-stage">
<div class="md-stagger"></div>
</div>.md-stagger-stage,
.md-stagger-stage *,
.md-stagger-stage *::before,
.md-stagger-stage *::after {
box-sizing: border-box
}
.md-stagger-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 250px;
overflow: hidden;
position: relative;
background: rgb(26, 26, 36);
color: rgb(255, 255, 255);
font-family: sans-serif;
}
.md-stagger {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px;
flex-wrap: wrap;
}
.md-stagger-item {
width: 40px;
height: 40px;
border-radius: 8px;
background: linear-gradient(135deg, #6366f1, #a855f7);
opacity: 0;
transform: translateY(20px) scale(0.8);
animation: mdStaggerIn 0.6s forwards;
}
@keyframes mdStaggerIn {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Mouvement réduit : les éléments sont posés d'emblée, sans montée ni agrandissement */
@media (prefers-reduced-motion: reduce) {
.md-stagger-item {
animation: none;
opacity: 1;
transform: none;
}
}(function() {
// Staggered Reveal : chaque .md-stagger de la page entre en cascade quand il
// arrive à l'écran, puis reste affiché. Il se rejoue s'il sort de l'écran et y revient.
var once = false; // true : la cascade ne se joue qu'à la première entrée
document.querySelectorAll('.md-stagger').forEach(function(container) {
// Vos éléments s'il y en a dans le conteneur, sinon 12 carrés de démonstration
var own = container.children.length > 0;
function play() {
if (!own) {
container.innerHTML = '';
for (var i = 0; i < 12; i++) container.appendChild(document.createElement('div'));
}
Array.prototype.forEach.call(container.children, function(item, i) {
item.classList.remove('md-stagger-item');
void item.offsetWidth; // relance l'animation
item.classList.add('md-stagger-item');
item.style.animationDelay = (i * 0.1) + 's';
});
}
if (!('IntersectionObserver' in window)) {
play();
return;
}
var obs = new IntersectionObserver(function(entries) {
if (!entries[0].isIntersecting) return;
play();
if (once) obs.disconnect();
}, {
threshold: 0.3
});
obs.observe(container);
});
})();Elastic Spring
Spring animation with physical bounce and realistic elastic deformation.
Effect page →View source code
<div class="md-spring-stage">
<div class="md-spring">
<div class="md-spring-ball"></div>
</div>
</div>.md-spring-stage,
.md-spring-stage *,
.md-spring-stage *::before,
.md-spring-stage *::after {
box-sizing: border-box
}
.md-spring-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 250px;
overflow: hidden;
position: relative;
background: rgb(26, 26, 36);
color: rgb(255, 255, 255);
font-family: sans-serif;
}
.md-spring {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.md-spring-ball {
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #ec4899);
animation: mdSpring 2s cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
}
@keyframes mdSpring {
0%,
100% {
transform: translateY(0) scaleX(1) scaleY(1);
}
20% {
transform: translateY(-60px) scaleX(0.9) scaleY(1.1);
}
40% {
transform: translateY(0) scaleX(1.15) scaleY(0.85);
}
55% {
transform: translateY(-25px) scaleX(0.95) scaleY(1.05);
}
70% {
transform: translateY(0) scaleX(1.05) scaleY(0.95);
}
85% {
transform: translateY(-8px);
}
}
/* Mouvement réduit : ni saut ni déformation, une pulsation d'opacité garde le signal */
@media (prefers-reduced-motion: reduce) {
.md-spring-ball {
animation: mdSpringFade 2s ease-in-out infinite;
}
}
@keyframes mdSpringFade {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}/* Pure CSS — no JavaScript required */Orchestrated Motion
Eight bars that stretch in turn, each offset by 0.1 s, for a continuous wave in pure CSS.
Inertia Momentum
Movement with inertia and natural deceleration: drag the cube and watch it slow down.
Effect page →View source code
<div class="md-inertia-stage">
<div class="md-inertia">
<div class="md-inertia-box"></div>
</div>
</div>.md-inertia-stage,
.md-inertia-stage *,
.md-inertia-stage *::before,
.md-inertia-stage *::after {
box-sizing: border-box
}
.md-inertia-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 250px;
overflow: hidden;
position: relative;
background: rgb(26, 26, 36);
color: rgb(255, 255, 255);
font-family: sans-serif;
}
.md-inertia {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
/* Centré par le CSS (et non mesuré au chargement) : juste même si le bloc est masqué au départ.
touch-action: none laisse le doigt saisir le cube au lieu de faire défiler la page. */
.md-inertia-box {
width: 50px;
height: 50px;
border-radius: 10px;
background: linear-gradient(135deg, #6366f1, #06b6d4);
cursor: grab;
position: absolute;
left: calc(50% - 25px);
top: calc(50% - 25px);
touch-action: none;
user-select: none;
transition: none;
}(function() {
// Inertia Momentum : saisir, lancer, l'élément glisse puis ralentit (souris, doigt, stylet)
var friction = 0.95;
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('.md-inertia').forEach(function(container) {
var box = container.querySelector('.md-inertia-box');
if (!box) return;
var x = 0,
y = 0,
vx = 0,
vy = 0;
var isDragging = false,
lastX = 0,
lastY = 0,
raf = 0;
function place() {
box.style.left = x + 'px';
box.style.top = y + 'px';
}
box.addEventListener('pointerdown', function(e) {
isDragging = true;
cancelAnimationFrame(raf);
x = box.offsetLeft; // position réelle au moment de la saisie
y = box.offsetTop;
lastX = e.clientX;
lastY = e.clientY;
vx = 0;
vy = 0;
try { box.setPointerCapture(e.pointerId); } catch (err) {}
box.style.cursor = 'grabbing';
e.preventDefault();
});
box.addEventListener('pointermove', function(e) {
if (!isDragging) return;
vx = e.clientX - lastX;
vy = e.clientY - lastY;
x += vx;
y += vy;
place();
lastX = e.clientX;
lastY = e.clientY;
});
function release() {
if (!isDragging) return;
isDragging = false;
box.style.cursor = 'grab';
if (reduceMotion.matches) { // mouvement réduit : pas de glissade, l'élément est seulement ramené dans la piste
x = Math.max(0, Math.min(container.clientWidth - box.offsetWidth, x));
y = Math.max(0, Math.min(container.clientHeight - box.offsetHeight, y));
place();
return;
}
raf = requestAnimationFrame(animate);
}
box.addEventListener('pointerup', release);
box.addEventListener('pointercancel', release);
function animate() {
if (isDragging) return;
if (Math.abs(vx) < 0.1 && Math.abs(vy) < 0.1) return;
vx *= friction;
vy *= friction;
x += vx;
y += vy;
var maxX = container.clientWidth - box.offsetWidth;
var maxY = container.clientHeight - box.offsetHeight;
if (x < 0 || x > maxX) vx *= -0.5;
if (y < 0 || y > maxY) vy *= -0.5;
x = Math.max(0, Math.min(maxX, x));
y = Math.max(0, Math.min(maxY, y));
place();
raf = requestAnimationFrame(animate);
}
});
})();SVG Path Follow
Element following a complex SVG path with a fluid animation along the curve.
Effect page →Character Cascade
Text with character-by-character animation, each letter appears individually.
Effect page →View source code
<div class="md-char-stage">
<div class="md-char-cascade"></div>
</div>.md-char-stage,
.md-char-stage *,
.md-char-stage *::before,
.md-char-stage *::after {
box-sizing: border-box
}
.md-char-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 250px;
overflow: hidden;
position: relative;
background: rgb(26, 26, 36);
color: rgb(255, 255, 255);
font-family: sans-serif;
}
.md-char-cascade {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.md-char {
display: inline-block;
font-size: 2rem;
font-weight: 700;
color: #fff;
opacity: 0;
transform: translateY(20px);
animation: mdCharIn 0.4s forwards;
}
@keyframes mdCharIn {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Le mot entier, lu par les lecteurs d'écran, invisible à l'œil */
.md-char-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
/* Mouvement réduit : les lettres sont posées d'emblée, le mot change sans mouvement */
@media (prefers-reduced-motion: reduce) {
.md-char {
animation: none;
opacity: 1;
transform: none;
}
}(function() {
// Character Cascade : le mot monte lettre par lettre, puis cède la place au suivant toutes les 2,5 s
document.querySelectorAll('.md-char-cascade').forEach(function(container) {
// Les mots : un élément enfant par mot (<span>VANNES</span><span>LORIENT</span>),
// sinon la liste de démonstration
var words = Array.prototype.map.call(container.children, function(el) {
return el.textContent.trim();
}).filter(Boolean);
if (!words.length) words = ['MOTION', 'DESIGN', 'EFFECT', 'SMOOTH'];
var currentWord = 0;
var intervalId = null;
function showWord() {
// Animations mises en pause (bouton Pause de la page, outils d'accessibilité) : on garde le mot affiché
var first = container.querySelector('.md-char');
if (first && getComputedStyle(first).animationPlayState === 'paused') return;
container.innerHTML = '';
var word = words[currentWord];
// Le mot entier pour les lecteurs d'écran ; les lettres, masquées pour eux, sont pour l'œil
var label = document.createElement('span');
label.className = 'md-char-sr';
label.textContent = word;
container.appendChild(label);
word.split('').forEach(function(char, i) {
var span = document.createElement('span');
span.className = 'md-char';
span.setAttribute('aria-hidden', 'true');
span.textContent = char === ' ' ? '\u00a0' : char;
span.style.animationDelay = (i * 0.08) + 's';
container.appendChild(span);
});
currentWord = (currentWord + 1) % words.length;
}
showWord();
if (words.length < 2) return;
// Le cycle ne tourne que lorsque le bloc est visible
if ('IntersectionObserver' in window) {
var obs = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting) {
if (!intervalId) intervalId = setInterval(showWord, 2500);
} else {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
}
}, {
threshold: 0
});
obs.observe(container);
} else {
// Repli : cycle permanent
intervalId = setInterval(showWord, 2500);
}
});
})();3D Flip Sequence
On click, the cards flip in 3D one after another, with a delay between each card for a domino effect.
Shape Morph Transition
Fluid transition between geometric shapes: circle, organic blob, square, and back.
Effect page →Timeline Choreography
Multi-step synchronized timeline sequence with animated progress bars.
Effect page →View Transitions Page Reveal
Smooth morph between a grid and its detail view using the browser's native View Transitions API. Automatic fallback if unsupported.
Effect page →Cross-Document Page Morph
Smooth transition between two real pages (multi-page): the shared element morphs from one page to the other via the native View Transitions API, without a single line of JavaScript. The SPA feel, without the SPA cost.
💡 Perfect for: blog list→article, e-commerce listing→product page, portfolio→case study, docs, any multi-page site.
Effect page →