Show all 23 aliases
transform order, transform function order, rotate before translate, my rotation flew across the screen, element drifting sideways, orbit animation css, standalone transform properties, translate rotate scale properties, cos() sin() in calc, polar coordinates css, sibling-index, animation shorthand order, animation-delay wrong, animation-direction reverse, partial keyframes, implicit keyframes, stacking keyframe animations, transition vs keyframe animation, reversing shortening factor, animation delay only first iteration, custom properties in keyframes, @property, registered custom property
Core Philosophy
- Most CSS animation bugs are not taste problems, they are mechanics problems. The curve is fine and the duration is fine, but the transform composed in the wrong order, or the second time value landed in
animation-delay, or two keyframes fought over one property
- Transforms are not commutative and never will be. Order is a design decision you make on every declaration, whether or not you notice you are making it
- Prefer the mechanism that matches the interaction: transitions for two-state controls the user can hammer, keyframe animations for multi-stage and looping motion
- Keyframes read
var(), calc() and trigonometry, so one rule can drive a hundred elements with different values. Generating a rule per element is almost always a sign you missed this
- Adjacent material lives elsewhere:
spring-easing-in-css.md owns springs and linear(), animation-and-motion.md owns easing and duration judgement, particles.md owns emitters and per-particle randomisation, reveal-techniques.md owns fill modes and reveal patterns
- A
transform list is a matrix product. The rightmost function is applied to the element first, and each function to its left transforms the space the result already sits in. Read the list right to left to predict what you will see
- The transform origin is a fixed point on the element's own box.
transform-origin wraps the whole list in a translate to that point and back, so nothing inside the list can move the pivot. That single fact explains both cases below
.spins-in-place {
transform: translateX(80px) rotate(45deg);
}
.orbits {
transform: rotate(45deg) translateX(80px);
}
- In the first,
rotate runs first, about the element's own centre, then the whole thing slides 80px. The element spins in place while it travels
- In the second,
translateX runs first, but the pivot is still at the untranslated origin, so the 45 degree rotation swings the element around a circle of radius 80px. This is the usual cause of "my rotation flew across the screen" and "why is it drifting sideways when all I did was rotate it"
- Turn that bug into a feature. An orbit is a rotation applied before a fixed translation, where only the angle animates
@keyframes orbit {
from { transform: rotate(0deg) translateX(80px); }
to { transform: rotate(360deg) translateX(80px); }
}
- If the element should stay upright while it orbits, counter-rotate it with a second element: put the orbit on a wrapper and
rotate(-360deg) on the child
- Animating the distance as well as the angle turns a straight flight path into a spiral. For a particle that should curve outward rather than fly straight, put
rotate() before translate() and animate both
Standalone translate, rotate and scale
translate, rotate and scale also exist as top-level properties, not just as functions inside transform. Each is a separate CSS property, and that is the whole point
- Separate properties means separate transitions. One channel can move on a slow spring while another snaps
.card {
transition:
translate 400ms cubic-bezier(0.2, 0, 0, 1),
rotate 150ms ease-out;
}
- Separate properties also means two keyframe animations can drive movement and size at the same time. Two animations both writing
transform cannot: the one later in animation-name wins outright and the other is silently ignored
- The trade-off is composition order. The individual properties apply in a fixed sequence,
translate, then rotate, then scale, then whatever the transform property itself says. That is equivalent to writing transform: translate() rotate() scale() <your transform>, so the element scales and rotates about its own origin and then moves. Reordering the declarations changes nothing
- Which means you cannot build an orbit out of the standalone properties. The translation always lands outside the rotation. Orbits, skews, 3D transforms and single-axis functions like
scaleY() stay on transform
- A standalone property that no
transition lists will snap rather than animate. Sometimes that is exactly what you want: transition rotate only on the exit state so an element straightens on the way out and is already straight when it comes back
Polar placement with trigonometry in calc()
cos() and sin() work inside calc(), so placing things around a circle needs no JavaScript. Both accept an <angle>, and both interpret a bare <number> as radians, so always pass a unit
- The idiom: one angle, one distance, one
translate
.dot {
--angle: calc(var(--index) * 45deg);
--distance: 12px;
transform: translate(
calc(cos(var(--angle)) * var(--distance)),
calc(sin(var(--angle)) * var(--distance))
);
}
- Set
--index once per element, as an inline style when you render the markup, and the same value drives both the position and the stagger. animation-delay: calc(var(--index) * 40ms) reuses it with no extra bookkeeping. The CSS-only stagger pattern is written out in animation-and-motion.md
sibling-index() would remove the hand-written index and count the element's position for you, resolving to 1 for the first sibling rather than 0. It is not Baseline and does not work in some of the most widely used browsers, so the manual index stays the portable form. Treat sibling-index() as an enhancement on top of a rule that already works without it, never as the only source of the index
- Angles start at three o'clock and increase clockwise, because the y axis points down. Subtract 90 degrees if you want the first item at the top
- For randomised, per-particle polar values computed in JavaScript, see
particles.md. This section is the case where the positions are deterministic
Reading the animation shorthand
@keyframes rules are looked up by name when the animation runs, so the rule can sit anywhere in the stylesheet, above or below the declaration that uses it. If two @keyframes share a name they do not merge: the last one in the winning cascade origin and layer is used and the other is ignored entirely
- Sub-property order inside
animation is free, because every keyword lives in a unique value space. infinite can only be animation-iteration-count, alternate can only be animation-direction, so the browser sorts them out
.badge {
animation: pulse 800ms ease-in-out infinite alternate;
animation: ease-in-out infinite pulse alternate 800ms;
}
- The one exception is
animation-delay, which shares the <time> type with animation-duration. The first value that parses as a time is the duration, the second is the delay, always. Swapping them is valid CSS that silently animates something else: animation: pulse 200ms 800ms is a fast pulse after a long wait, animation: pulse 800ms 200ms is a slow pulse after a short one
- Write the name and duration first by convention, and put the delay immediately after the duration so the pair reads as a pair
animation-direction: reverse plays every iteration from 100% to 0%, so a looping animation resets to the end state and runs backwards again each time. alternate ping-pongs instead, forwards on odd iterations and backwards on even ones. They are not interchangeable, and only alternate gives a there-and-back
- Both backwards modes reverse the easing too. An
ease-in played backwards behaves as ease-out
from is exactly 0% and to is exactly 100%. They mix freely with percentage selectors in the same rule. Use from/to when the rule has only two stops and percentages when it has more, so the shape of the rule tells you how many stages there are
Transitions retarget, keyframe animations restart
- This is the rule that decides which tool you reach for. A transition interrupted mid-flight starts from the current presentation value, the position the element is visibly at right now. A keyframe animation retriggered mid-flight restarts at its own 0% keyframe
- So a two-state control the user can hammer, a switch, a disclosure arrow, a like button, must be built from transitions on a state selector. Build it from a pair of enter and exit keyframes and every rapid toggle jumps back to the start of the exit before playing it
.chevron {
rotate: 0deg;
transition: rotate 200ms cubic-bezier(0.2, 0, 0, 1);
}
.disclosure[aria-expanded="true"] .chevron {
rotate: 90deg;
}
- When a transition is reversed before it finished, the browser does not simply play the same duration backwards. It computes a reversing shortening factor: the portion of the space between the reversing-adjusted start value and the end value that the old transition already traversed, measured in amounts of the value rather than in elapsed time, clamped to 0 to 1. The return runs for that fraction of the duration
- Measuring distance rather than time is what makes an interrupted ease feel right. A transition stopped 10% of the way through an
ease-out is much further along in value than in time, and the return is shortened to match what the eye can see
- The cost of that shortening on a baked spring curve, where the oscillations get compressed into the shorter window, is covered in
spring-easing-in-css.md
- Reach for keyframes when the motion has stages the element passes through, when it loops, or when several independent animations need their own durations. Reach for a transition whenever there are two states and the user controls which one
Stacking several animations on one property
- Several keyframe animations can drive the same property at once. They do not simply fight, with one winning: a partial keyframe leaves a hole, and the value that fills the hole is whatever the element would otherwise be showing, including the value another animation is currently producing
- The result composes. A twinkle oscillating opacity between 0.25 and 0.75, combined with a fade whose
to is unspecified, produces the product of the two
@keyframes twinkle {
from { opacity: 0.25; }
to { opacity: 0.75; }
}
@keyframes fadeFromTransparent {
from { opacity: 0; }
}
.spark {
animation:
twinkle 250ms ease-in-out infinite alternate,
fadeFromTransparent 2000ms;
}
fadeFromTransparent interpolates from 0 to the twinkle value, so at 40% through the fade the element shows 0.4 times whatever twinkle is at. The pair genuinely reaches 0 even though twinkle's floor is 0.25. That is how you fade into a running loop instead of having the loop pop on at full strength
- The partial keyframe must come after the fully specified one in the
animation list. The animation that occurs last for a given property overrides the earlier ones at that point, and only a partial keyframe leaves a hole for the earlier value to show through. Put twinkle last and it overrides fadeFromTransparent completely, and nothing fades
- Older versions of mobile Safari ran the two sequentially instead of composing them, so the element faded in and only then began twinkling. If you have to support them, split the two animations across a wrapper and a child, which also removes the ordering trap at the cost of doubling the node count
Partial keyframes as a utility set
- Because an unspecified end resolves to the value the element would otherwise have, one partial keyframe serves elements at any resting opacity. A traditional
from { opacity: 1 } to { opacity: 0 } fade snaps a half-transparent element to fully opaque before it starts
- Four rules cover almost every fade in a codebase. Declare them once in global styles
@keyframes fadeFromOpaque { from { opacity: 1; } }
@keyframes fadeToOpaque { to { opacity: 1; } }
@keyframes fadeFromTransparent { from { opacity: 0; } }
@keyframes fadeToTransparent { to { opacity: 0; } }
- An icon button resting at
opacity: 0.7 that rises to 1 on hover can use fadeFromTransparent for its entrance and land on the correct value either way. If the pointer is over it while it enters, it fades to 1; if not, it fades to 0.7
- The same trick works for any property.
@keyframes fromShrunken { from { scale: 0.9; } } grows an element to whatever size it is meant to be, without the keyframe needing to know that size
- Stop writing per-case keyframes named after the component. If a rule mentions one component in its name, it is usually a partial keyframe you have not spotted yet
Split one change into several animations
- One visual change does not have to be one animation. Give each property its own animation so each gets its own duration and curve, which is what makes an entrance feel layered rather than uniform
.toast {
animation:
fromShrunken 150ms cubic-bezier(0.2, 0, 0, 1),
colorShift 150ms linear,
fadeFromTransparent 300ms 150ms backwards;
}
- Each animation in the list must own a different property, or the last one for that property wins and the rest are ignored. Here the scale, the colour and the opacity are three separate properties, so all three run
backwards is what holds the first frame during the delay. Without it the element sits at its final opacity for the first 150ms and then snaps to the animation's first frame when it starts, which reads as a flicker. Fill modes in full are in reveal-techniques.md
- This also lets a one-shot entrance compose with an infinite ambient loop, as long as they own different properties
A bounce needs alternate, not a three-stop arc
- A natural bounce is one destination keyframe plus
alternate. It is not a 0/50/100 arc
- The timing function applies to each keyframe interval, not once across the whole animation, and the element-level curve is used for every interval that does not override it. So a 0/50/100 arc runs the same curve on the way up and on the way down. Whatever you pick, both legs get it, and a bounce needs them to differ: decelerating into the apex and accelerating into the floor
alternate replays the same iteration backwards, and reverses the easing when it does, so one ease-out becomes an ease-in on the return leg for free. That is exactly gravity: the rise decelerates into the apex, the fall accelerates into the floor
@keyframes hop {
to { translate: 0 -24px; }
}
.ball {
animation: hop 400ms ease-out infinite alternate;
}
- Remember that
alternate doubles the effective cycle length. If a squash animation on the same element has to stay in step with the bounce, the bounce duration is half the squash duration, because two bounce iterations fit inside one squash
Per-element values inside keyframes
- Keyframes can read custom properties, so one rule can serve elements with different amplitudes. This is what makes keyframe animations as flexible as transitions
- For symmetric oscillation, flip the sign with
calc() rather than declaring a second variable
@keyframes sway {
from { translate: calc(var(--amount) * -1) 0; }
to { translate: var(--amount) 0; }
}
.pendulum {
animation: sway 700ms ease-in-out infinite alternate;
}
<div class="pendulum" style="--amount: 8px"></div>
<div class="pendulum" style="--amount: 32px"></div>
- The unit has to be in the variable.
--amount: 16 gives calc(16 * -1), a bare number, and translate needs a length, so the declaration is invalid and the element does not move. If a value arrives from an API or a data attribute without a unit, multiply it: calc(var(--amount) * -1px)
- Give every such variable a fallback in the consuming rule,
var(--amount, 8px), so an element that forgets to set it still animates instead of silently doing nothing
- These variables are ordinary unregistered custom properties, which is fine here because you are animating
translate, not the variable itself. Animating the variable is the case that needs @property, below
Spacing out an infinite loop
animation-delay offsets the start of the animation, once. It does not insert a pause between iterations, so you cannot use it to space out an infinite loop. A blink with a 2 second delay waits 2 seconds and then blinks continuously
- Bake the pause into the keyframe percentages instead. Make the animation as long as the whole cycle and let the motion occupy a small slice at the front
@keyframes blink {
0% { scale: 1 1; }
4% { scale: 1 0.05; }
10% { scale: 1 1; }
100% { scale: 1 1; }
}
.eye {
animation: blink 2000ms infinite;
}
- The 10% to 100% stretch is dead air. Holding the same value across it is what creates the rest, and the explicit 100% keyframe pins the resting value rather than leaving it to the implicit keyframe the browser would build from the element's computed style
- Stagger a group of them with a negative delay, which starts each element partway through its own cycle rather than making them all blink in unison:
animation-delay: calc(var(--index) * -370ms)
- If you are using an animation library, its own repeat-delay option is the direct equivalent. See
looping-motion.md
Keeping an element inside its container at any position
- Percentages resolve against different things depending on the property, and that difference is a tool.
top and left percentages resolve against the containing block, while translate percentages resolve against the element's own reference box
- Pair them, inverted, and the element stays flush inside the container at both extremes without any code knowing how big the element is
.dot {
position: absolute;
top: var(--top);
left: var(--left);
transform: translate(calc(var(--left) * -1), calc(var(--top) * -1));
}
- At
--left: 0% the translate is zero and the element sits against the left edge. At --left: 100% the element is pushed to the far edge and then pulled back by exactly its own width, so it sits flush against the right edge. Everywhere in between it is compensated proportionally
- The simpler
transform: translate(-50%, -50%) centres the anchor point on the coordinate instead, which is what you want when the element is allowed to overhang the edges, for example a starfield that should look like it continues past the frame
- Use the inverse pair whenever the element must never overflow, and when the elements vary in size or contain text of unknown length. A fixed inset on an inner wrapper only works if you know the size in advance
Register a custom property before animating it
- An unregistered custom property animates as a discrete type. It jumps from the old value to the new one halfway through, it does not tween, no matter what duration you give it. This is the answer to "my custom property animation is not animating"
- Registering it with
@property gives it a type, and a typed custom property interpolates by computed value
@property --glow-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.halo {
background: conic-gradient(
from var(--glow-angle),
oklch(0.72 0.15 250),
oklch(0.85 0.12 330),
oklch(0.72 0.15 250)
);
animation: sweep 6000ms linear infinite;
}
@keyframes sweep {
to { --glow-angle: 360deg; }
}
initial-value is required unless syntax is the universal *. A rule that needs it and omits it, or gives an invalid one, is thrown away whole, so the property stays unregistered and you are back to discrete jumps with no error to read
inherits: false is the right default for a value only one element uses. Set it to true only when descendants genuinely need to read the same animated value
- Registering also validates assignment. A
<length> property assigned 45deg falls back to the initial value rather than poisoning every rule that reads it, which is a real benefit on its own
- The same registration is available at runtime through
CSS.registerProperty(), which is what you want when the syntax depends on data rather than being known at author time
Reduced motion
- Every technique above is movement, so every one needs a reduced path. Cut the movement, keep the outcome
- Gate the motion in rather than switching it out. Declare the
animation inside @media (prefers-reduced-motion: no-preference) and leave the resting state in the ordinary rule. The feature has exactly two values, so a browser that cannot evaluate the query matches neither, and this direction gives that browser a still page instead of a full-speed one. accessibility.md has the argument in full
- The sway and the blink are ambient and start from the element's resting state, so the ungated rule needs nothing at all. Move the animation into the gate and the element sits where an untransformed element already sits
@media (prefers-reduced-motion: no-preference) {
.pendulum {
animation: sway 700ms ease-in-out infinite alternate;
}
.eye {
animation: blink 2000ms infinite;
}
}
- The orbit is not that case, and it is the trap. Its resting position is the centre of the ring, so an element with no animation piles up at the origin. State the placement in the ungated rule, using the same per-element angle that spaced the dots out in the first place, and put only the animation in the gate
.orbiting {
transform: rotate(var(--angle, 0deg)) translateX(80px);
}
@media (prefers-reduced-motion: no-preference) {
.orbiting {
animation: orbit 6000ms linear infinite;
}
}
- That is the general rule wherever the keyframes carry position as well as movement. The ungated rule is the only thing a motion-free browser applies, so anything the keyframes were responsible for placing has to be stated there
- The entrance cases are dangerous for the mirror-image reason. A partial keyframe like
fadeFromTransparent starts at opacity: 0, so an entrance declared outside the gate leaves the element invisible forever on any browser that never runs it. Put the settled values in the ungated rule and the animation list inside the gate
.toast {
opacity: 1;
scale: 1;
}
@media (prefers-reduced-motion: no-preference) {
.toast {
animation:
fromShrunken 150ms cubic-bezier(0.2, 0, 0, 1),
colorShift 150ms linear,
fadeFromTransparent 300ms 150ms backwards;
}
}
- Transitions on state selectors are the easy case: declare the
transition inside the gate and the state change still happens, it just happens instantly. The changed state is what has to be visible, not the travel between states
- Where the motion carried meaning, a spinner that says work is happening or a blink that says an element is live, replace it with something static that says the same thing: a text status, a persistent dot, a changed label. Never let the preference remove the information
Common mistakes
- Writing
rotate() before translate() when you wanted an in-place spin, so the element orbits and appears to fly across the screen
- Expecting
transform-origin to follow a translation in the same list, then adding more translations to chase the pivot back
- Reaching for the standalone
translate and rotate properties to build an orbit, which their fixed composition order makes impossible
- Passing a bare number to
cos() or sin() and getting radians when you meant degrees
- Putting the delay before the duration in the
animation shorthand, which is valid CSS that silently gives you a different animation
- Using
reverse when you wanted alternate, so a loop resets and replays backwards instead of ping-ponging
- Building a hammerable toggle out of enter and exit keyframes, so every rapid tap restarts from 0% and visibly jumps instead of retargeting from where the element is
- Listing the partial keyframe before the fully specified one, so the full one overrides it and the fade never happens
- Putting two animations that both write
transform on one element and wondering why only the later one runs
- Adding
animation-delay to an infinite animation to space out the loop, when it only offsets the first iteration
- Setting a custom property without a unit and using it inside
calc() where a length is required, which invalidates the declaration with no visible error
- Animating a custom property that was never registered with
@property, so it jumps instead of tweening
- Omitting
initial-value on a typed @property rule, which discards the whole registration silently
- Leaving an entrance animation's end state inside the keyframes only, so an element that starts from
opacity: 0 is permanently invisible wherever the animation does not run