transform-origin and transform-box in SVG

Part of Animating SVG with CSS in Core CSS Animation Fundamentals.

The problem

A settings icon should spin on hover. The CSS is the same rotate: 1turn that works on any HTML element, and the result is a gear that swings around in a large circle, leaving the icon’s bounds and clipping at the edges. A bell that should wiggle around its top pivots on a point somewhere to its left. A heart that should pulse scales towards the top-left corner of the canvas.

Adding transform-origin: center sometimes fixes one icon and not the next, and the fix that works in one browser can look different in another if the SVG has older authoring quirks.

Root cause analysis: SVG shapes have no border box

For an HTML element, transform-origin percentages and keywords refer to the element’s border box, and the default origin is 50% 50% — the centre. The reference box is set by the transform-box property, and for HTML elements its effective value is border-box.

SVG shapes have no CSS box. Their initial transform-box is view-box, which uses the nearest SVG viewport as the reference: 0 0 is the viewport’s origin, and percentages are fractions of the viewBox’s width and height. On top of that, the initial transform-origin for SVG elements that are not the outer svg is 0 0. So a rotation with no origin set pivots around the canvas’s top-left corner, and transform-origin: center pivots around the canvas’s centre. Neither is the shape’s centre unless the shape happens to be centred in the canvas — which is why the fix appears to work on some icons.

transform-box: fill-box changes the reference to the shape’s own geometry bounding box. With it, center means the middle of the shape, 50% 0% means the middle of its top edge, and the result does not depend on where the shape sits in the canvas.

What each reference box means for a shape inside an svgfill-box is the right default for icon animation; stroke-box when thick strokes should count.What each reference box means for a shape inside an svgOrigin 'center' isDepends on position?Use forview-box (SVGdefault)Centre of the viewBoxYesShapes that orbit the canvasfill-boxCentre of the shape's geometryNoSpins, pulses, wigglesstroke-boxCentre including the strokeNoThick asymmetric strokesborder-box /content-boxTreated as fill-box /stroke-box for SVGNoShared rules with HTML
fill-box is the right default for icon animation; stroke-box when thick strokes should count.

Step-by-step resolution

Fixing a shape that pivots on the wrong pointThe two declarations together are a safe default for any animated SVG shape.Fixing a shape that pivots on the wrong point1Select the shape in the Elements panel and read transform-box in the Computed tab.view-box confirms the cause2Add transform-box: fill-box to the animated element.The reference is now the shape itself3Set transform-origin: center, or a percentage pair for a custom pivot.The pivot sits on the shape4If the element also has a transform attribute, move that transform to a parent g.The CSS transform no longer replaces it5Use stroke-box when a thick stroke makes the fill centre look off.The visual centre includes the stroke6Resize the svg and repeat the animation at small and large sizes.
The two declarations together are a safe default for any animated SVG shape.

The transform attribute step catches a lot of exported SVGs. Design tools often position shapes with transform="translate(12 8)". A CSS transform on the same element replaces that attribute’s value entirely, so the moment an animation starts the shape jumps to its untranslated position. Individual properties like rotate and scale compose with the transform property rather than replacing it, but the attribute is mapped to the transform property, so the safest fix is structural: wrap the shape in a <g> that keeps the positioning attribute, and animate the shape inside it.

Production code pattern

<svg class="icons" viewBox="0 0 96 32" aria-hidden="true">
  <g transform="translate(4 4)">          <!-- positioning stays on the wrapper -->
    <path class="gear" d="M12 8 ... Z" />
  </g>
  <g transform="translate(36 4)">
    <path class="bell" d="M12 2 ... Z" />
  </g>
  <g transform="translate(68 4)">
    <path class="heart" d="M12 21 ... Z" />
  </g>
</svg>
/* Safe default for every animated shape inside an SVG. */
.icons :is(.gear, .bell, .heart) {
  transform-box: fill-box;       /* reference box = the shape's own bounds */
  transform-origin: center;      /* now means the shape's centre */
}

.gear:hover {
  animation: spin 1.2s linear infinite;
}

.bell {
  transform-origin: 50% 0%;      /* pivot at the middle of the bell's top edge */
}
.bell:hover {
  animation: ring 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97);
}

.heart:hover {
  animation: pulse 900ms ease-in-out infinite;
}

@keyframes spin  { to { rotate: 1turn; } }
@keyframes ring  { 20%, 60% { rotate: 12deg; } 40%, 80% { rotate: -12deg; } }
@keyframes pulse { 50% { scale: 1.12; } }

@media (prefers-reduced-motion: reduce) {
  .gear:hover, .bell:hover, .heart:hover { animation: none; }
}

Rendering Impact: paint, icon-sized. Transforms on shapes inside an SVG repaint the SVG’s region in most cases; for a 96 by 32 sprite that is negligible. The origin fix changes where the pivot is, not the rendering tier.

Using the individual rotate and scale properties rather than the transform shorthand keeps the effects independent if you later combine them — a gear that both spins and pulses — as described in individual transform properties versus the shorthand.

The bell's wiggle, before and afterThe keyframes never changed; the reference box and origin did.The bell's wiggle, before and afterDefaultstransform-box: view-boxtransform-origin: 0 0 of the canvasBell swings around the sprite cornerVisibly brokenfill-box, origin 50% 0%transform-box: fill-boxPivot at the bell's top centreBell rocks like it is hangingReads as a ring
The keyframes never changed; the reference box and origin did.

How viewBox scaling interacts with the origin

Once fill-box is set, the origin is computed in the shape’s local coordinates, and the whole SVG — shape, origin and transform — is then scaled to the rendered size by the viewBox. That is why the pivot stays correct when the icon is rendered at 16px or 160px: the origin is part of the graphic, not a pixel position on the page.

Pixel values behave differently. transform-origin: 12px 12px with fill-box is 12 user units from the shape’s bounding box corner, not 12 CSS pixels on screen. For icons drawn on a 24-unit grid that can be convenient, but percentages and keywords are the safer choice when the same CSS styles icons drawn at different scales.

The outer svg element is the exception to all of this. It is laid out like an HTML replaced element, it has a border box, and its default origin is its centre. Animations on the outer element — the reliable compositor-driven path — do not need transform-box at all.

Verification checklist

Constraints and trade-offs

  • fill-box ignores strokes, so a shape with a thick one-sided stroke may look slightly off-centre; stroke-box includes it, at the cost of the pivot moving if stroke width animates.
  • A group’s fill box is the union of its children’s bounds, which changes if a child moves, so a group’s pivot can drift during compound animations.
  • Very old exported SVGs sometimes rely on the view-box default for deliberate orbiting motion; changing a shared selector globally can break those.
  • Applying transform-box to the outer svg has no useful effect; it already uses its CSS box.

Frequently asked questions

Why is the default transform-origin different for SVG elements?

SVG predates CSS transforms and positions everything in user-space coordinates from the viewport origin. To keep existing SVG transform attributes working, SVG elements default to the viewport as reference box and an origin of 0 0.

Should I set transform-box: fill-box globally?

Setting it on animated shapes and groups is a safe default. A global rule on every SVG descendant can change the result of existing transform attributes that relied on the canvas origin, so scope it to the graphics you animate.

Does transform-box affect the transform attribute?

Yes. The transform attribute maps to the transform property, and transform-origin and transform-box apply to it as well. That is another reason to keep positioning transforms on a wrapper group and animate the element inside it.

What is the difference between fill-box and stroke-box?

fill-box is the bounding box of the shape’s geometry. stroke-box extends it by the stroke, so centre includes half the stroke width on every side. They differ only when strokes are thick or uneven.