Motion natif
sans librairie
Le navigateur anime tout seul, mieux que jamais : lié au scroll, transitions de vues, propriétés animables, entrées/sorties fluides. Six techniques, zéro dépendance.
Scroll-driven — animation-timeline
Lier une animation à la progression du scroll, sans JS ni
requestAnimationFrame. scroll-timeline
nomme un axe de défilement ; l'animation le consomme via
animation-timeline. Fais défiler la zone ci-dessous.
Défile ici ↓
La barre dorée en haut suit ta progression…
… pilotée uniquement par CSS.
Aucun écouteur d'événement.
Aucun JavaScript.
Juste animation-timeline.
Fin. 🦉
<div class="scroller">
<div class="track"><div class="progress"></div></div>
<div class="content">
<p>Défile ici ↓</p>
<p>La barre dorée suit ta progression…</p>
<p>… pilotée uniquement par CSS.</p>
<p>Aucun JavaScript. 🦉</p>
</div>
</div>
.scroller {
height: 160px; overflow-y: auto;
scroll-timeline: --sc block; /* nomme l'axe de scroll de cette zone */
border: 1px solid #1e2d44; border-radius: 8px;
}
.track { position: sticky; top: 0; height: 5px; background: #1e2d44; }
.progress {
height: 100%; width: 0; background: #c9952a;
animation: grow linear both;
animation-timeline: --sc; /* consomme l'axe nommé */
}
@keyframes grow { to { width: 100%; } }
.content p { margin: .75rem; color: #c9d4e8; }
View Transitions API
Encapsule un changement du DOM dans
document.startViewTransition() et le navigateur
produit un fondu animé entre l'ancien et le nouvel état —
automatiquement. Idéal pour les changements d'état et de page.
<div class="card">
<div class="emoji" id="emoji">🌞</div>
<div id="label">Jour</div>
</div>
<button id="toggle">Changer d'état</button>
.card {
padding: 1.5rem; text-align: center; border-radius: 8px;
background: #131a24; border: 1px solid #1e2d44; color: #c9d4e8;
}
.emoji { font-size: 2.5rem; }
const states = [{ emoji: '🌞', label: 'Jour' }, { emoji: '🌙', label: 'Nuit' }];
let i = 0;
function update() { // modifie le DOM
i = (i + 1) % states.length;
document.getElementById('emoji').textContent = states[i].emoji;
document.getElementById('label').textContent = states[i].label;
}
document.getElementById('toggle').addEventListener('click', () => {
// le navigateur produit un fondu auto entre l'ancien et le nouvel état
if (document.startViewTransition) document.startViewTransition(update);
else update(); // fallback navigateurs non compatibles
});
@property — dégradé conique animé
@property déclare un type pour une variable CSS,
ce qui la rend animable. En animant un
<angle> dans un conic-gradient,
on obtient la bordure néon tournante des cartes « nouveau ».
<div class="neon">néon</div>
/* @property déclare un type → la variable devient animable */
@property --angle {
syntax: '<angle>'; initial-value: 0deg; inherits: false;
}
.neon {
display: grid; place-items: center; width: 120px; height: 60px;
color: #c9d4e8; border: 2px solid transparent; border-radius: 10px;
background:
linear-gradient(#131a24, #131a24) padding-box,
conic-gradient(from var(--angle), transparent, #c9952a, transparent 40%) border-box;
animation: spin 3s linear infinite; /* bordure néon tournante */
}
@keyframes spin { to { --angle: 360deg; } }
@starting-style + allow-discrete
Animer l'apparition d'un élément (y compris depuis
display: none) était impossible en CSS pur.
@starting-style définit l'état de départ, et
transition-behavior: allow-discrete anime
display. Entrée et sortie fluides.
J'apparais et je disparais en douceur, malgré
display: none.
<button id="toggle">Afficher le panneau</button>
<div class="pop" id="pop">
<strong>Coucou 👋</strong>
<p>J'apparais et disparais en douceur, malgré display: none.</p>
</div>
.pop {
display: none; opacity: 0; transform: translateY(8px);
margin-top: .75rem; padding: 1rem; color: #c9d4e8;
background: #131a24; border: 1px solid #1e2d44; border-radius: 8px;
/* allow-discrete permet d'animer la propriété display */
transition: opacity .3s, transform .3s, display .3s allow-discrete;
}
.pop.is-open { display: block; opacity: 1; transform: none; }
/* état de départ de l'apparition (même depuis display:none) */
@starting-style {
.pop.is-open { opacity: 0; transform: translateY(8px); }
}
const pop = document.getElementById('pop');
document.getElementById('toggle').addEventListener('click', (e) => {
const open = pop.classList.toggle('is-open');
e.target.textContent = open ? 'Masquer le panneau' : 'Afficher le panneau';
});
Easing — playground cubic-bezier
La courbe de Bézier définit le « caractère » d'une animation. Règle les quatre points de contrôle et lance la balle pour ressentir la différence — un bon easing vaut mille durées.
cubic-bezier(0.34, 1.56, 0.64, 1)
<div class="track"><div class="ball" id="ball"></div></div>
<code id="value">cubic-bezier(0.34, 1.56, 0.64, 1)</code>
<label>x1 <input type="range" id="bz0" min="0" max="1" step="0.01" value="0.34"></label>
<label>y1 <input type="range" id="bz1" min="-1" max="2" step="0.01" value="1.56"></label>
<label>x2 <input type="range" id="bz2" min="0" max="1" step="0.01" value="0.64"></label>
<label>y2 <input type="range" id="bz3" min="-1" max="2" step="0.01" value="1"></label>
<button id="play">Lancer la balle</button>
.track { height: 40px; margin-bottom: .5rem; }
.ball { width: 32px; height: 32px; border-radius: 50%; background: #c9952a; }
.ball.play { animation: slide 1s var(--ease, ease) forwards; }
@keyframes slide { to { transform: translateX(220px); } }
label { display: block; color: #8492ac; font-size: .8rem; }
const ball = document.getElementById('ball');
const value = document.getElementById('value');
const inputs = ['bz0', 'bz1', 'bz2', 'bz3'].map((id) => document.getElementById(id));
function apply() {
const ease = 'cubic-bezier(' + inputs.map((i) => (+i.value).toFixed(2)).join(', ') + ')';
value.textContent = ease;
ball.style.setProperty('--ease', ease); // la courbe pilote le « caractère »
}
inputs.forEach((i) => i.addEventListener('input', apply));
document.getElementById('play').addEventListener('click', () => {
ball.classList.remove('play');
void ball.getBoundingClientRect(); // relance l'animation
ball.classList.add('play');
});
apply();
Reveal en cascade
IntersectionObserver détecte l'entrée dans le
viewport ; un transition-delay indexé par élément
crée l'effet d'apparition échelonnée. Discret, performant,
sans dépendance.
<div class="group" id="group">
<div class="item" style="--i:0">1</div>
<div class="item" style="--i:1">2</div>
<div class="item" style="--i:2">3</div>
<div class="item" style="--i:3">4</div>
<div class="item" style="--i:4">5</div>
</div>
<button id="replay">Rejouer</button>
.group { display: flex; gap: .75rem; margin-bottom: 1rem; }
.item {
width: 48px; height: 48px; display: grid; place-items: center;
color: #0d1117; background: #c9952a; border-radius: 8px; font-weight: 700;
opacity: 0; transform: translateY(20px);
transition: opacity .5s, transform .5s;
transition-delay: calc(var(--i) * 80ms); /* décalage indexé → cascade */
}
.item.in { opacity: 1; transform: none; }
const group = document.getElementById('group');
const items = group.querySelectorAll('.item');
function play() {
items.forEach((el) => { el.style.transition = 'none'; el.classList.remove('in'); });
void group.offsetWidth; // applique l'état caché sans transition
requestAnimationFrame(() => {
items.forEach((el) => (el.style.transition = ''));
requestAnimationFrame(() => items.forEach((el) => el.classList.add('in')));
});
}
// déclenche la cascade à l'entrée dans le viewport
new IntersectionObserver((entries, obs) => {
entries.forEach((e) => { if (e.isIntersecting) { play(); obs.disconnect(); } });
}, { threshold: 0.35 }).observe(group);
document.getElementById('replay').addEventListener('click', play);