CSS giver dig to værktøjer til bevægelse: transitions og @keyframes-animationer. De fleste udviklere ved, at begge eksisterer, men bruger det, de lærte først — normalt transitions — og undrer sig derefter over, hvorfor ladningsspinneren ikke looper, eller hvorfor notifikationen springer tilbage til sin oprindelige position. Beslutningen er ikke kompliceret, når du forstår, hvad hver enkelt egentlig er til. Transitions er en reaktion: noget skifter tilstand, browseren jævner ændringen. @keyframes-animationer er selvstændige skripts: de kører for sig selv, looper hvis du vil, og behøver ikke en tilstandsudløser. Denne guide dækker begge, de egenskaber der betyder mest, og præstationsdetaljerne der adskiller jævne 60fps-animationer fra hakke-fyldte. Hvis du skriver en masse CSS, er CSS Formatter værd at bogmærke — det rydder hurtigt op i rodet med shorthand.
CSS Transitions — det enkle tilfælde
En transition fortæller browseren: "når denne egenskab ændres, animer ændringen over tid i stedet for at hoppe." Det er det. Den mest almindelige brug er hover-effekter — farveændringer, opacitetsfader, subtile skala-transformationer. MDN's transitions-guide dækker hele specifikationen, men de fire egenskaber du bekymrer dig om er transition-property, transition-duration, transition-timing-function og transition-delay.
/* 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;
}Du kan liste flere transitions adskilt med kommaer, hver med sin egen varighed og easing — det er hvad knap-eksemplet ovenfor gør. Hver egenskab animerer uafhængigt.
transition: all. Det er fristende som en catch-all, men det betyder at hver egenskabsændring på det element animeres — inklusive dem du ikke havde til hensigt. Det giver også browseren mere arbejde på hver frame. List de specifikke egenskaber du vil transitionere. MDN's transition-property-dokumentation forklarer, hvilke egenskaber der kan animeres.transition-timing-function — easing forklaret
Timing-funktionen styrer animationens accelerationskurve — om den starter hurtigt og bremser ned, bevæger sig med konstant hastighed, eller hopper gennem en brugerdefineret kurve. De indbyggede nøgleord dækker de fleste situationer.
ease— starter langsomt, accelererer, derefter bremser op mod slutningen. Standard. Godt til de fleste UI-bevægelser.linear— konstant hastighed hele vejen. Godt til spinnere og statusbarer, dårligt til de fleste andre ting — ser mekanisk ud.ease-in— starter langsomt, slutter hurtigt. Føles som noget der tager fart. Godt til elementer der forlader skærmen.ease-out— starter hurtigt, slutter langsomt. Føles naturligt for elementer der kommer ind på skærmen (som en notifikation der glider ind).ease-in-out— langsomt i begge ender. Ser poleret ud for elementer der starter og stopper i visning.cubic-bezier(x1, y1, x2, y2)— definer din egen kurve. Værktøjer som cubic-bezier.com lader dig bygge og forhåndsvise brugerdefinerede kurver visuelt.
/* 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 — når transitions ikke er nok
Transitions har en hård grænse: de har brug for to tilstande og en udløser. Hvis du vil have noget til at loope uendeligt, køre ved sidelasting uden nogen interaktion, eller animere gennem mere end to trin, har du brug for @keyframes. MDN's @keyframes-reference dækker hele syntaksen. Du definerer keyframes separat og knytter dem til et element med egenskaben animation.
/* 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;
}animation-shorthand — alle otte egenskaber
Shorthand-egenskaben animation pakker otte egenskaber ind i én deklaration. Du behøver dem ikke alle hver gang, men at kende hvad hver enkelt styrer, sparer dig for at grave i dokumentationen, når noget ikke opfører sig.
/*
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 — løsning på snapback-problemet
Dette er den egenskab, der snubler alle på et tidspunkt. Som standard, når en animation slutter, snappes elementet tilbage til sine præ-animations stilarter — som om animationen aldrig skete. Hvis du animerer en toast-notifikation der glider ind fra højre, snappes den tilbage ud af skærmen, i det øjeblik animationen slutter. animation-fill-mode: forwards løser dette ved at holde elementet ved sin endelige keyframe-tilstand, når animationen slutter.
@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
*/Virkelige eksempler — skeleton-loader, spinner, toast, pulserende badge
Her er fire mønstre, du vil bruge hele tiden. Hvert enkelt demonstrerer en specifik kombination af @keyframes og animationsegenskaber.
/* --- 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%; }/* --- 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;
}/* --- 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;
}/* --- 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;
}Ydeevne — GPU-kompositorerede vs. layout-udløsende egenskaber
Ikke alle CSS-egenskaber er ens, når det gælder animationsydeevne. Browserens rendering-pipeline har tre stadier, der betyder noget her: layout (beregning af elementstørrelser og positioner), paint (udfyldning af pixels) og composite (kombination af lag på GPU). At animere en egenskab, der udløser layout, betyder at browseren genberegner hele dokumentgeometrien på hvert frame — det er dyrt, og det er det, der forårsager hakken. web.dev's animationsydeevne-guide dækker dette i dybden.
- Sikre at animere (GPU-kompositorerede):
transform(translate, scale, rotate) ogopacity. Disse kører udelukkende på GPU-kompositortråden — de udløser aldrig layout eller paint. - Forårsager paint (undgå hvis muligt):
color,background-color,border-color,box-shadow. Disse springer layout over, men udløser en repaint på hvert frame. Korte transitions (under 300ms) er normalt OK. - Forårsager layout (animer aldrig):
width,height,top,left,margin,padding. Hvert frame udløser en fuld layoutgenberegning — garanteret hak på komplekse sider.
transform og opacity. Hvis du vil flytte noget, brug translate ikke left/top. Hvis du vil ændre størrelse på noget, brug scale ikke width/height. CSS Triggers er en reference, der viser præcist hvilke rendering-stadier hver egenskab rammer./* 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 — brug sparsomt
will-change er et hint til browseren om, at en specifik egenskab snart vil blive animeret, så den bør fremrykke elementet til sit eget kompositor-lag på forhånd. Dette kan eliminere det korte hak, du nogle gange ser i starten af en animation på lav-end hardware. Men det har en reel omkostning: hvert fremrykket lag forbruger GPU-hukommelse. Hvis du sætter will-change: transform på hvert animeret element i din app, forringer du ydeevnen på de fleste enheder — det modsatte af hvad du ønskede.
/* 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 — tilgængelighed du ikke kan springe over
En betydelig del af brugerne har vestibulære forstyrrelser eller andre tilstande, hvor bevægelse udløser ubehag eller kvalme. Mediafrågan prefers-reduced-motion lader dig respektere systemindstillingen "reducer bevægelse". WCAG 2.1-retningslinje 2.3.3 dækker dette krav, og MDN's prefers-reduced-motion-reference viser browserunderstøttelse (det er universelt nu). Mønstret er enkelt: indpak dine animationer i standard-mediafrågan og tilby et bevægelsesfrit alternativ inde i den reducerede bevægelse-variant.
/* 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;
}
}Den "nukleare mulighed" i slutningen af det kodstykke er et almindeligt mønster for eksisterende kodebaser, der ikke har gennemgået hver animation. Det er bedre end ingenting, men at gennemgå hver animation individuelt giver dig mere kontrol — nogle animationer formidler tilstand (en fremdriftslinje, en ladningsspinner) og bør bevares, blot sættes ned i tempo.
Transitions vs @keyframes — beslutningsguiden
Hvis du nogensinde er usikker på, hvilken du skal bruge, stil dig to spørgsmål om den animation, du bygger:
- Udløses den af en tilstandsændring? (hover, fokus, klasseskift, afkrydsningsfelt) → Brug en transition. Det er præcis hvad transitions er designet til.
- Looper den uendeligt? → Brug @keyframes med
animation-iteration-count: infinite. - Har den mere end to trin? (ikke kun start → slut, men start → midt → slut eller mere) → Brug @keyframes.
- Skal den køre ved sidelasting, uden nogen brugerinteraktion? → Brug @keyframes.
- Skal elementet forblive ved sin slutningstilstand efter afslutning? → Brug @keyframes med
animation-fill-mode: forwards.
Opsummering
Transitions håndterer tilstandsdrevet bevægelse. @keyframes håndterer alt andet. Animer altid transform og opacity for ydeevne — lad width, height, top og left være ude af det. Brug animation-fill-mode: forwards når du har brug for, at elementet holder sin slutningstilstand. Tilføj prefers-reduced-motion-overriden inden du sender noget afsted — det er et ti-linjes tilføjelse, der betyder meget for rigtige brugere. For mere om kompositeringsmodellen og hvad der udløser layout, er web.dev's rendering-ydeevne-dokumentation den mest praktiske tilgængelige ressource. Når du har skrevet CSS, kør det igennem CSS Formatter for at holde shorthand-reglerne læsbare, eller igennem CSS Minifier for at fjerne mellemrum inden levering til produktion.