Animations · Démo interactive

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.

01

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; }
02

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.

🌞
Jour
<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
});
03

@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 ».

néon
<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; } }
04

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

Coucou 👋

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';
});
05

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();
06

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.

1
2
3
4
5
<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);