CSS geeft je twee gereedschappen voor beweging: transities en @keyframes-animaties. De meeste ontwikkelaars weten dat beide bestaan, maar grijpen naar degene die ze het eerst leerden — meestal transities — en vragen zich dan af waarom de laadspinner niet loopt of waarom de melding terugschiet naar zijn oorspronkelijke positie. De keuze is niet ingewikkeld zodra je begrijpt waarvoor elke optie bedoeld is. Transities zijn een reactie: iets verandert van staat, de browser verzacht de overgang. @keyframes-animaties zijn zelfstandige scripts: ze draaien op zichzelf, lopen in een lus als je wilt, en hebben geen staatstrigger nodig. Deze gids behandelt beide, de eigenschappen die het meest tellen, en de prestatiedetails die vloeiende 60fps-animaties scheiden van schokkerige. Als je veel CSS schrijft, is de CSS Formatter het bookmarken waard — die ruimt snel rommeltige shorthand op.

CSS Transities — Het Eenvoudige Geval

Een transitie vertelt de browser: "als deze eigenschap verandert, animeer de verandering in de loop van de tijd in plaats van te springen." Dat is alles. Het meest gebruikelijke gebruik is voor hover-effecten — kleurveranderingen, opacity-fades, subtiele schaal-transforms. De MDN transitiegids behandelt de volledige spec, maar de vier eigenschappen die ertoe doen zijn transition-property, transition-duration, transition-timing-function en transition-delay.

css
/* The shorthand: property duration easing delay */
.btn-primary {
  background-color: #4f46e5;
  color: #fff;
  padding: 0.625rem 1.25rem;
  border-radius: 6px;
  border: none;
  cursor: pointer;
  transition: background-color 200ms ease, transform 150ms ease;
}

.btn-primary:hover {
  background-color: #4338ca;
  transform: translateY(-1px);
}

.btn-primary:active {
  transform: translateY(0);
}

/* Opacity fade — a card coming into view */
.notification-card {
  opacity: 0;
  transition: opacity 300ms ease-in-out;
}

.notification-card.is-visible {
  opacity: 1;
}

Je kunt meerdere transities weergeven, gescheiden door komma's, elk met zijn eigen duur en easing — dat doet het bovenstaande knopvoorbeeld. Elke eigenschap animeert onafhankelijk.

Gebruik geen transition: all. Het is verleidelijk als allesomvattende optie, maar het betekent dat elke eigenschapswijziging op dat element wordt geanimeerd — inclusief degene die je niet bedoeld had. Het laat de browser ook meer werk doen op elk frame. Lijst de specifieke eigenschappen op die je wilt transiteren. De MDN transition-property docs leggen uit welke eigenschappen animeerbaar zijn.

transition-timing-function — Easing Uitgelegd

De timingfunctie bepaalt de versnellingscurve van de animatie — of die snel begint en vertraagt, op een constant tempo beweegt, of door een aangepaste curve stuitert. De ingebouwde sleutelwoorden dekken de meeste situaties.

  • ease — begint langzaam, versnelt, vertraagt dan richting het einde. De standaard. Goed voor de meeste UI-bewegingen.
  • linear — constante snelheid gedurende de hele duur. Goed voor spinners en voortgangsbalken, slecht voor de meeste andere dingen — ziet er mechanisch uit.
  • ease-in — begint langzaam, eindigt snel. Voelt aan alsof iets snelheid pakt. Goed voor elementen die het scherm verlaten.
  • ease-out — begint snel, eindigt langzaam. Voelt natuurlijk voor elementen die het scherm binnenkomen (zoals een melding die inschuift).
  • ease-in-out — langzaam aan beide kanten. Ziet er verzorgd uit voor elementen die in het zicht beginnen en stoppen.
  • cubic-bezier(x1, y1, x2, y2) — definieer je eigen curve. Tools zoals cubic-bezier.com laten je aangepaste curven visueel bouwen en bekijken.
css
/* Custom spring-like easing with cubic-bezier */
.drawer {
  transform: translateX(-100%);
  transition: transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

.drawer.is-open {
  transform: translateX(0);
}

/* The cubic-bezier values above overshoot slightly (y > 1)
   which gives a satisfying spring effect on open */

@keyframes — Wanneer Transities Niet Genoeg Zijn

Transities hebben een harde grens: ze hebben twee staten en een trigger nodig. Als je iets oneindig wilt laten lussen, bij het laden van de pagina wilt laten starten zonder enige interactie, of door meer dan twee stappen wilt animeren, heb je @keyframes nodig. De MDN @keyframes referentie behandelt de volledige syntaxis. Je definieert de keyframes afzonderlijk en koppelt ze dan aan een element met de animation-eigenschap.

css
/* Define the animation */
@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

/* Attach it to an element */
.spinner {
  width: 24px;
  height: 24px;
  border: 3px solid #e5e7eb;
  border-top-color: #4f46e5;
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}

/* Multi-step — more than two stops */
@keyframes pulse-ring {
  0%   { transform: scale(1);    opacity: 1; }
  50%  { transform: scale(1.15); opacity: 0.7; }
  100% { transform: scale(1);    opacity: 1; }
}

.badge-live {
  animation: pulse-ring 1.5s ease-in-out infinite;
}

De animation Shorthand — Alle Acht Eigenschappen

De animation shorthand stopt acht eigenschappen in één declaratie. Je hebt ze niet allemaal elke keer nodig, maar weten wat elk controleert bespaart je het doorzoeken van docs als iets zich niet gedraagt.

css
/*
  animation: name | duration | timing-function | delay | iteration-count | direction | fill-mode | play-state
*/
.toast {
  animation: slide-in-right 350ms ease-out 0s 1 normal forwards running;
}

/* More commonly written as just the values you need: */
.toast {
  animation: slide-in-right 350ms ease-out forwards;
}

/* Breaking it out to individual properties for clarity: */
.confetti-piece {
  animation-name: float-down;
  animation-duration: 2s;
  animation-timing-function: ease-in;
  animation-delay: calc(var(--i) * 150ms); /* staggered via CSS custom property */
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: forwards;
  animation-play-state: running;
}

/* Pausing an animation via JavaScript: toggle a class */
.spinner.is-paused {
  animation-play-state: paused;
}

animation-fill-mode — Het Snap-Back Probleem Oplossen

Dit is de eigenschap die iedereen op een gegeven moment in de problemen brengt. Standaard, wanneer een animatie eindigt, springt het element terug naar zijn stijlen van vóór de animatie — alsof de animatie nooit heeft plaatsgevonden. Als je een toast-melding animeert die van rechts inschuift, springt het terug buiten het scherm zodra de animatie eindigt. animation-fill-mode: forwards lost dit op door het element in zijn eindkeyframe-staat te houden zodra de animatie eindigt.

css
@keyframes slide-in-right {
  from {
    transform: translateX(110%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

/* Without forwards: toast slides in, then SNAPS back off-screen */
.toast-bad {
  animation: slide-in-right 350ms ease-out;
}

/* With forwards: toast slides in and STAYS in position */
.toast {
  animation: slide-in-right 350ms ease-out forwards;
}

/*
  fill-mode values:
  - none      (default) — no styles applied before or after
  - forwards  — hold the final keyframe after the animation ends
  - backwards — apply the first keyframe during the delay period
  - both      — forwards + backwards combined
*/

Praktische Voorbeelden — Skeleton Loader, Spinner, Toast, Pulserend Badge

Hier zijn vier patronen die je constant zult gebruiken. Elk demonstreert een specifieke combinatie van @keyframes en animatie-eigenschappen.

css
/* --- 1. Skeleton loading shimmer --- */
@keyframes shimmer {
  0%   { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite linear;
  border-radius: 4px;
}

.skeleton-title  { height: 20px; width: 60%; margin-bottom: 12px; }
.skeleton-text   { height: 14px; width: 100%; margin-bottom: 8px; }
.skeleton-text:last-child { width: 80%; }
css
/* --- 2. Spinning loader --- */
@keyframes spin {
  to { transform: rotate(360deg); }
}

.loader-spinner {
  display: inline-block;
  width: 32px;
  height: 32px;
  border: 3px solid rgba(79, 70, 229, 0.2);
  border-top-color: #4f46e5;
  border-radius: 50%;
  animation: spin 600ms linear infinite;
}
css
/* --- 3. Slide-in notification toast --- */
@keyframes toast-enter {
  from {
    transform: translateX(calc(100% + 1.5rem));
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

@keyframes toast-exit {
  from {
    transform: translateX(0);
    opacity: 1;
  }
  to {
    transform: translateX(calc(100% + 1.5rem));
    opacity: 0;
  }
}

.toast {
  position: fixed;
  bottom: 1.5rem;
  right: 1.5rem;
  padding: 0.875rem 1.25rem;
  background: #1f2937;
  color: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
  animation: toast-enter 350ms ease-out forwards;
}

.toast.is-dismissing {
  animation: toast-exit 300ms ease-in forwards;
}
css
/* --- 4. Pulsing notification badge --- */
@keyframes badge-pulse {
  0%, 100% {
    box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
  }
  50% {
    box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
  }
}

.notification-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 20px;
  padding: 0 5px;
  background: #ef4444;
  color: #fff;
  font-size: 11px;
  font-weight: 700;
  border-radius: 999px;
  animation: badge-pulse 2s ease-in-out infinite;
}

Prestaties — GPU-Gecomposeerde vs Layout-Triggerende Eigenschappen

Niet alle CSS-eigenschappen zijn gelijk als het gaat om animatieprestaties. De browser-renderingpipeline heeft drie stadia die hier belangrijk zijn: layout (het berekenen van elementgroottes en -posities), paint (het invullen van pixels) en composite (het combineren van lagen op de GPU). Het animeren van een eigenschap die layout triggert, betekent dat de browser de volledige documentgeometrie op elk frame herberekent — dat is duur, en het is wat schokkerigheid veroorzaakt. De web.dev-animatieprestatiegids behandelt dit uitgebreid.

  • Veilig om te animeren (GPU-gecomposeerd): transform (translate, scale, rotate) en opacity. Deze draaien volledig op de GPU-compositorthreads — ze triggeren nooit layout of paint.
  • Veroorzaakt paint (vermijd indien mogelijk): color, background-color, border-color, box-shadow. Deze slaan layout over maar triggeren een repaint op elk frame. Korte transities (onder 300ms) zijn meestal prima.
  • Veroorzaakt layout (nooit animeren): width, height, top, left, margin, padding. Elk frame triggert een volledige layout-herberekening — gegarandeerde schokkerigheid op complexe pagina's.
De praktische regel: animeer alleen met transform en opacity. Als je iets wilt verplaatsen, gebruik translate niet left/top. Als je iets wilt vergroten of verkleinen, gebruik scale niet width/height. CSS Triggers is een referentie die precies aangeeft welke renderstadia elke eigenschap raakt.
css
/* Bad — animating width triggers layout on every frame */
.progress-bar-bad {
  transition: width 300ms ease;
}

/* Good — use scaleX transform instead */
.progress-bar {
  transform-origin: left center;
  transition: transform 300ms ease;
}

/* In JS, set: progressBar.style.transform = 'scaleX(0.75)' for 75% */

/* Bad — animating top/left (positions element with layout) */
.tooltip-bad {
  position: absolute;
  top: 0;
  transition: top 200ms ease;
}

/* Good — use translateY instead */
.tooltip {
  position: absolute;
  top: 0;
  transform: translateY(0);
  transition: transform 200ms ease;
}

will-change — Gebruik Het Spaarzaam

will-change is een hint aan de browser dat een specifieke eigenschap op het punt staat geanimeerd te worden, zodat het element van tevoren naar zijn eigen compositlaag gepromoveerd moet worden. Dit kan de korte flits van schokkerigheid elimineren die je soms ziet aan het begin van een animatie op lagere hardware. Maar het heeft echte kosten: elke gepromoveerde laag verbruikt GPU-geheugen. Als je will-change: transform op elk geanimeerd element in je app zet, degradeer je de prestaties op de meeste apparaten — het tegenovergestelde van wat je wilde.

css
/* Right way — add it just before animation starts, remove after */
.modal-overlay {
  /* Don't set will-change here by default */
}

/* Add via JavaScript only when user triggers modal open */
/* overlay.style.willChange = 'opacity'; */
/* overlay.addEventListener('transitionend', () => { */
/*   overlay.style.willChange = 'auto'; */
/* }); */

/* Acceptable in CSS for elements that animate frequently
   (e.g., a persistent floating action button on scroll) */
.fab {
  will-change: transform; /* OK — this element genuinely animates on scroll */
  transition: transform 200ms ease;
}

/* Don't do this — wastes GPU memory with zero benefit */
.card {
  will-change: transform; /* BAD — card only animates on hover, not constantly */
}

prefers-reduced-motion — Toegankelijkheid Die Je Niet Kunt Overslaan

Een aanzienlijk deel van de gebruikers heeft vestibulaire stoornissen of andere aandoeningen waarbij beweging ongemak of misselijkheid veroorzaakt. De prefers-reduced-motion media query laat je de systeembrede instelling "beweging verminderen" respecteren. De WCAG 2.1-richtlijn 2.3.3 behandelt deze vereiste, en de MDN prefers-reduced-motion referentie toont browserondersteuning (nu universeel). Het patroon is eenvoudig: wikkel je animaties in de standaard media query, en bied een geen-beweging fallback in de verminderde-beweging-query.

css
/* Define animations normally for users who are fine with motion */
@keyframes slide-in-right {
  from { transform: translateX(110%); opacity: 0; }
  to   { transform: translateX(0);    opacity: 1; }
}

.toast {
  animation: slide-in-right 350ms ease-out forwards;
}

.spinner {
  animation: spin 600ms linear infinite;
}

/* Override for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
  .toast {
    /* No sliding — just appear */
    animation: none;
    opacity: 1;
    transform: none;
  }

  .spinner {
    /* Slow it way down or stop it entirely */
    animation-duration: 4s;
  }

  /* Nuclear option — disable ALL animations site-wide */
  /* Use this only if you haven't audited each animation individually */
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

De "nucleaire optie" aan het einde van dat fragment is een veelgebruikt patroon voor bestaande codebases die niet elke animatie hebben gecontroleerd. Het is beter dan niets, maar het individueel controleren van elke animatie geeft je meer controle — sommige animaties brengen staat over (een voortgangsbalk, een laadspinner) en zouden bewaard moeten worden, alleen vertraagd.

Transities vs @keyframes — De Beslissingsgids

Als je ooit niet zeker weet welke je moet kiezen, stel jezelf twee vragen over de animatie die je bouwt:

  • Wordt het getriggerd door een staatswijziging? (hover, focus, class-toggle, checkbox) → Gebruik een transitie. Dat is precies waarvoor transities zijn ontworpen.
  • Loopt het oneindig? → Gebruik @keyframes met animation-iteration-count: infinite.
  • Heeft het meer dan twee stappen? (niet alleen start → einde, maar start → midden → einde of meer) → Gebruik @keyframes.
  • Moet het starten bij het laden van de pagina, zonder enige gebruikersinteractie? → Gebruik @keyframes.
  • Moet het element in zijn eindstaat blijven na het eindigen? → Gebruik @keyframes met animation-fill-mode: forwards.
De combinatie die mensen verrast: je wilt een hover-effect dat een achtergrondkleur vloeiend faded (transitie), maar je wilt ook dat een "nieuw"-badge op hetzelfde element continu pulseert (@keyframes). Je kunt beide op hetzelfde element gebruiken — ze regelen verschillende eigenschappen en interfereren niet met elkaar.

Samenvatting

Transities verwerken staatgestuurde beweging. @keyframes verwerkt al het andere. Animeer altijd transform en opacity voor prestaties — laat width, height, top en left erbuiten. Gebruik animation-fill-mode: forwards wanneer je het element in zijn eindstaat wilt houden. Voeg de prefers-reduced-motion-override toe voordat je iets uitbrengt — het is een toevoeging van tien regels die veel uitmaakt voor echte gebruikers. Voor meer informatie over het compositingmodel en wat layout triggert, zijn de web.dev rendering performance docs de meest praktische bron. Zodra je de CSS hebt geschreven, laat die door de CSS Formatter lopen om de shorthand leesbaar te houden, of door de CSS Minifier om witruimte te verwijderen voor productie.