Skip to content

CSS transform-origin and Animation Timing: Why the Pivot Changes the Speed

A 12 degree rotation travels 48.8px about a corner and 24.4px about the centre. Measured travel, peak speed, and which pivot suits which pattern.

· · 10 min read
Two rectangles rotated twelve degrees, one about its centre and one about its corner, with corner travel marked

Two cards, one keyframe, one curve. The first rotates 12 degrees about its centre. The second rotates the same 12 degrees about its top-left corner. Which one moves more? Most people say they look about the same, and that's the trap, because the second one's far corner travels twice as far in exactly the same 300 milliseconds.

transform-origin gets treated as a positioning detail, something you set until the thing stops looking wrong. It's also half of the speed of your animation. I measured it, in a Chromium tab, with probe elements pinned to a box's four corners, and the numbers below come from those probes rather than from a formula I trusted.

TL;DR: transform-origin sets the pivot, and travel at any point is its distance from the pivot times the angle or scale change. A 12 degree rotation of a 200 by 120 box moved its farthest corner 24.4px about the centre and 48.8px about a corner, so the same duration and curve ran twice as fast. Move the pivot to match the pattern, then fix the travel with the angle, not the duration (MDN, 2026).

Where Does the Pivot Sit, and What Does It Control?

At 50% 50% unless you say otherwise: the middle of the element's box. Every rotate, scale and skew is applied relative to that point, so the point itself never moves and everything else swings or grows away from it. Run the preview above and watch the three dashed outlines. The card on the left pivots in the middle, the second on its top edge, the third in its top-left corner.

.card {
  --origin: 50% 50%;
  transform-origin: var(--origin);
  animation: swing 600ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.card--edge { --origin: 50% 0; }
.card--corner { --origin: 0 0; }

@keyframes swing { from { rotate: -12deg; opacity: 0; } }

Two details trip people up. First, the origin accepts keywords, percentages and lengths, and the lengths can sit outside the box: 50% -60px puts the pivot sixty pixels above the element, which is how you make something swing like a pendulum. Second, the individual rotate, scale and translate properties use the same origin as the transform shorthand. I set a 12 degree rotation both ways with the origin at 0 0 and compared all four corners: identical to a hundredth of a pixel. You don't need to switch to transform to get a custom pivot.

How Far Does a Corner Really Travel?

Further than you'd guess, and the arithmetic is short. A point's travel under a rotation is its distance from the pivot times the angle in radians. Under a scale it's its distance from the pivot times the scale change. Move the pivot, and the distances change.

I measured this with four dot elements pinned to the corners of a 200 by 120 box and read their positions before and after a 12 degree rotation. The scale test used a 240 by 160 box shrunk to 0.9.

pivot (transform-origin)rotate 12deg, farthest cornerscale 0.9, farthest corner
50% 50% (centre)24.4px14.4px
50% 0 (top edge)32.7px20.0px
0 0 (top-left corner)48.8px28.8px
50% -60px (above the box)43.1pxnot tested
Farthest corner travel for a 12 degree rotation: centre 24.4px, top edge 32.7px, above 43.1px, corner 48.8px
Rotation of 12 degrees on a 200 by 120 box, measured at the farthest corner.

The centre row is the floor. No pivot inside the box gives a smaller maximum, because the centre is the point whose farthest neighbour is nearest. Every other pivot adds distance, and distance is speed.

That's why a corner pivot looks dramatic and a centre pivot looks polite. They aren't different effects, they're the same effect at two scales. The table also explains a failure you've probably seen: a card that's fine at 12 degrees from the middle and flings itself across the screen the day somebody moves the pivot to its hinge.

The pivot is half of an animation's speed: move it to a corner and the same duration and curve travel twice as far.

Why Does the Same Curve Feel Faster From a Corner?

Because speed at a point is travel times the curve's slope, divided by the duration. The duration didn't change. The curve didn't change. The travel doubled, so the speed did too.

The slope matters more than most people expect. A CSS timing curve has an average slope of 1 by definition, and a real curve spends some of its time well above that. I computed the peak slope for four common curves numerically, then multiplied by the travel from the table, for a 12 degree rotation over 300 ms.

timing curvepeak slopecentre pivot, peak speedcorner pivot, peak speed
cubic-bezier(0, 0, 0.58, 1) (ease-out)1.72140 px/s280 px/s
cubic-bezier(0.42, 0, 0.58, 1) (ease-in-out)1.72140 px/s280 px/s
cubic-bezier(0.25, 0.1, 0.25, 1) (ease)2.29186 px/s372 px/s
cubic-bezier(0.16, 1, 0.3, 1) (expo-out)6.25508 px/s1,016 px/s
Peak corner speed over 300 ms: ease-out 140 and 280 px/s, ease 186 and 372, expo-out 508 and 1016
Peak speed of the farthest corner, centre pivot against corner pivot.

The last row is the one to remember. An expo-out curve starts at 6.25 times its average speed, because its first control point is (0.16, 1) and the opening slope is y / x. Put that curve on a corner pivot and the far corner leaves at about a thousand pixels a second. That's the snap people describe as "jumpy" without being able to say why.

So what do you fix? Not the duration. Lengthen it and the entrance gets slow before it stops being sharp. The travel is the thing that grew, and travel is angle times distance, so halve the angle:

.card--corner {
  --origin: 0 0;
  /* same 24px of far-corner travel as the centre pivot */
  --angle: -6deg;
}

@keyframes swing { from { rotate: var(--angle, -12deg); opacity: 0; } }

Six degrees about the corner matches the far-corner travel of twelve degrees about the centre. The curve you tuned stays as it was.

Does the Same Rule Hold for Scale-In Popovers?

It does, and this is the case you'll meet most often. A popover entering from scale(0.9) looks small, but the pivot decides where the missing ten percent comes from. On a 240 by 160 panel the farthest corner moved 14.4px about the centre and 28.8px about the top-left corner, measured the same way as above. The top edge sat between them at 20.0px.

.popover {
  transform-origin: var(--origin, 0 0);
  animation: pop 160ms cubic-bezier(0, 0, 0.58, 1) both;
}

@keyframes pop { from { scale: 0.9; opacity: 0; } }

If the trigger is a button in the panel's top-left corner, 0 0 is correct even though it doubles the travel, because the panel is visibly growing out of the button. What you can still control is how much it grows. A start of scale(0.95) from that corner travels about 14.4px, the same as 0.9 from the centre, and it reads as a shorter, calmer entrance without losing the sense of origin. Keep the pivot honest and tune the number instead.

Which Pivot Fits Which Pattern?

The rule I'd use: the pivot is where the thing is attached, or where it came from.

  • Dropdowns, popovers, menus. The edge or corner nearest the trigger. A menu scaling from its middle looks like a balloon inflating in mid-air; scaled from the trigger's edge it looks released from it. The micro-interactions piece makes the same complaint about tooltips. Popover libraries tend to publish the correct origin as a variable, and Radix Primitives, for one, does it per component.
  • Modals and dialogs. The centre, or the point that opened them if there is one. A centre scale from 0.96 is the default for a reason: it has the smallest travel.
  • Hinged cards, flaps, drawers. The hinge edge. A card that swings open from 0 50% reads as a door; from the centre it reads as a wobble.
  • Progress bars and meters. The start edge, so the bar grows in the reading direction. The dashboard article on animated stats cards uses transform-origin: left with scaleX() for exactly this.
  • Pendulums, tags, swinging signs. A length above the box, like 50% -60px. Measured above: 43.1px of travel for 12 degrees, close to a corner pivot, so keep the angle small.

Notice what's missing from that list: nothing animates the origin itself. Setting it once and animating the transform is the whole job. If the pivot slides during the motion, the element drifts, and no timing curve will hide that.

Why Does My SVG Shape Rotate Around the Wrong Point?

Because SVG elements default to a different box. For HTML elements, 50% 50% means the element's own box. For SVG shapes, the default transform-box is view-box, so the percentage resolves against the whole SVG viewport.

I tested it with a 100 by 60 rectangle at x=100, y=60 inside a 300 by 200 SVG. Rotated 45 degrees at 50% 50% with the default box, the rectangle ended up 7.1px to the right and 3.0px lower than the same rotation with transform-box: fill-box. It rotated around the middle of the picture, not the middle of the shape, so it drifted off its own position as it turned.

.shape {
  transform-box: fill-box;
  transform-origin: center;
}

If a rotating icon seems to orbit something instead of spinning, this is nearly always the reason, and it's the first thing I'd check before touching the keyframes.

What About Performance?

Less than you'd think, and not in the way articles usually say. transform-origin isn't a layout property, so changing it doesn't reflow anything, and a rotation or scale on transform, rotate or scale stays the same kind of operation wherever the pivot sits. I didn't benchmark frame times for this piece, and I don't think you need to: what moved in every measurement above was distance, and distance is something your eye pays for, not the GPU.

That distinction is the part worth taking away. When an animation looks wrong after you move the pivot, the instinct is to suspect the browser. The browser is doing the same work. What changed is how far your eye has to follow.

Summary

transform-origin sets the pivot, and the pivot sets the travel: distance from the pivot times the angle or scale change. Measured on a 200 by 120 box, a 12 degree rotation moved the farthest corner 24.4px about the centre and 48.8px about a corner, so the same 300 ms and the same curve ran at twice the speed. With an expo-out curve that's about 1,016 px/s at the start. Pick the pivot where the element is attached or where it came from, correct the travel with the angle rather than the duration, and give SVG shapes transform-box: fill-box before you debug anything else.

One keyframe, three pivots, one timing curve
Edit the CSS

Change the corner card's --origin to 50% 50%. Its sweep shrinks to half, because every point now sits closer to the pivot. Then change the curve to ease-in-out and compare how the corner card starts.

Frequently Asked Questions

Do the individual rotate and scale properties use transform-origin?
Yes. In a Chromium check, rotate: 12deg with transform-origin set to 0 0 landed all four corners of a box in exactly the same places as transform: rotate(12deg) with the same origin. The individual properties and the transform shorthand share one origin, so you set it once.
Why does my SVG shape rotate around the wrong point?
Because the default transform-box for SVG elements is view-box, so a percentage origin resolves against the whole SVG viewport rather than the shape. A 100 by 60 rectangle rotated 45 degrees with transform-origin 50% 50% landed 7.1px right and 3.0px lower than the same rotation with transform-box: fill-box. Set fill-box and the percentages mean the shape's own box.
Is transform-origin expensive to animate?
You rarely animate it. It's an input to the transform, not a layout property, so setting it costs nothing on its own. The common mistake is the reverse: animating the origin mid-motion makes the pivot slide, and the element drifts in a way no timing curve can hide. Set it once per element and animate the transform.
Which transform-origin should a dropdown or popover use?
The edge or corner nearest its trigger. A menu that scales from its centre appears to inflate in mid-air, while one that scales from the trigger's edge appears to be released from it. Libraries that position popovers usually expose the right origin as a variable, for example Radix Primitives publishes one per component.
Should I shorten the duration when the pivot moves to a corner?
Usually shrink the angle instead. Travel at the farthest point is angle times distance from the pivot, so doubling the distance doubles the travel at the same duration and the same curve. Halving the angle restores the original travel without touching the timing you tuned. Lengthening the duration to compensate makes the interface feel slow.

Tagged