Animate on the compositor with transform and opacity, structure your @keyframes, and respect users' motion preferences.
Open this lesson in KodokonBefore choosing what to animate, recall the rendering pipeline: layout (geometry), paint (pixels), composite (assembling layers on the GPU). Animating width or top triggers a layout on every frame, often followed by a paint: that is the recipe for jank below 60 fps. transform and opacity are handled directly by the compositor, without going back through the first two stages. The professional rule: animate only those two properties whenever possible, and simulate the rest (translate instead of top, scale instead of width).
.button {
transform: translateY(0);
transition:
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.15s ease-out;
}
.button:hover {
transform: translateY(-2px);
}
.button:active {
transform: translateY(0) scale(0.97);
}When a transition is no longer enough (intermediate steps, looping, automatic start), move to @keyframes. Two settings make the difference between a clean animation and a visual bug: animation-fill-mode controls the state before the start (backwards applies the first keyframe during the delay) and after the end (forwards freezes the last one); both combines the two. Also note that the timing function applies between each pair of keyframes, not over the total duration: you can redefine it inside a keyframe.
@keyframes slide-in {
from {
transform: translateX(-100%);
opacity: 0;
}
70% {
transform: translateX(8px);
opacity: 1;
}
to {
transform: translateX(0);
}
}
.toast {
animation: slide-in 0.35s ease-out both;
}Two complementary tools worth knowing. will-change: transform promotes the element onto its own layer before the animation, avoiding a late raster; but each layer costs GPU memory, so apply it just before the animation and remove it afterwards, never as a permanent style on dozens of elements. steps(n) replaces continuous interpolation with discrete jumps: essential for sprites, typewriter-style cursors, or clocks.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}transform: translateX(100px) over left: 100px for an animated movement?transform is better supported by older browserstransform is handled by the compositor without recalculating layout on every frameleft only works on absolutely positioned elements, transform works everywheretransform accepts negative values, unlike leftanimation-fill-mode: both do?will-change?* to speed up the whole pagetransform: translateZ(0)transition for micro-interactions