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.
Step-by-step resolution
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.
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-boxignores strokes, so a shape with a thick one-sided stroke may look slightly off-centre;stroke-boxincludes 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-boxdefault for deliberate orbiting motion; changing a shared selector globally can break those. - Applying
transform-boxto the outersvghas 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.
Related
- Animating SVG with CSS — the parent topic
- Individual Transform Properties vs Shorthand — composing rotate and scale without conflicts
- Drawing SVG Lines with stroke-dashoffset — the other everyday SVG effect