← Back to Index

Lesson 2

Transforms & Grouping

Lesson 1 established how the coordinate system works - the infinite stage, the viewBox camera, the viewport screen. This lesson covers how to manipulate that coordinate system: moving, rotating, scaling, and skewing elements within it. This is the transform attribute.

The Core Insight: Transforms Move Space, Not Shapes

Transforms don't move elements - they move the coordinate system

When you apply transform="translate(50, 30)" to an element, you're not shifting the element 50 units right and 30 units down. You're creating a new local coordinate system that is shifted 50 right and 30 down - and then drawing the element at its original coordinates within that new system.

The result looks the same, but the mental model matters: transforms modify space, not shapes. This becomes critical when you chain multiple transforms.

Visualising "move the space, not the shape"

Here's the same translate(100, 60) shown two ways. On the left, we show the wrong mental model (shape slides along a fixed grid). On the right, the correct model (grid moves, shape stays at its local coordinates):

Wrong: "move the shape" x y 0,0 was here now here Grid stays fixed, shape moves Correct: "move the grid" original 0,0 x' y' new 0,0 drawn at (20,20) on the new grid grid shifts by (100,60) Grid moves, shape stays at local coords

Left: the intuitive-but-wrong model - the shape moves along a fixed grid. Right: what actually happens - a new grid (blue axes) is created at an offset, and the shape is drawn at the same local coordinates on the new grid. The visual result is identical, but the "grid moves" model correctly predicts what happens when you add more transforms.

Why does this matter in practice?

If you then add rotate(30) after the translate, the rotation happens around (0, 0) of the new (blue) grid - which is point (100, 60) in the original space. If you thought "the shape moved," you'd expect rotation around the shape's centre. But if you think "the grid moved," you correctly predict: the rotation pivots around the new grid's origin.

Chaining example: where the models diverge

Consider transform="translate(120, 80) rotate(45)" applied to a rect at (20, 15). What do the two mental models predict?

Wrong model prediction "Shape moved, then rotated around its own centre" 0,0 original assumed pivot (rect's centre) Predicts rotation around shape centre - WRONG Correct model prediction "Grid moved, then grid rotated around its new origin" original 0,0 new 0,0 (the pivot!) x' y' Predicts rotation around new grid origin - CORRECT

transform="translate(120, 80) rotate(45)" - Left: wrong model expects rotation around the rect's centre. Right: correct model shows the grid was translated (moving the origin to a new point), then the grid was rotated 45° around that new origin. The rect at local (20,15) swings with the grid. The two predictions produce visibly different results.

1. The Transform Functions

SVG's transform attribute accepts these functions:

Function Syntax What it does
translate translate(x [, y]) Shifts the coordinate system. If y omitted, defaults to 0.
rotate rotate(angle [, cx, cy]) Rotates the coordinate system by angle degrees. Optional centre point (cx, cy) - defaults to (0, 0).
scale scale(x [, y]) Scales the coordinate system. If y omitted, uniform scale (same as x).
skewX skewX(angle) Skews along the x-axis by angle degrees.
skewY skewY(angle) Skews along the y-axis by angle degrees.
matrix matrix(a,b,c,d,e,f) Applies a full 2D affine transformation matrix. All others are shortcuts for specific matrices.
What does "affine" mean?

An affine transformation is any transformation that preserves straight lines and parallelism. Lines stay straight, and parallel lines stay parallel. That's the guarantee.

What affine transformations can do (but don't have to):

  • Change angles - skew turns a square into a rhombus (sides still parallel, corners no longer 90°)
  • Change lengths - scale makes things bigger or smaller
  • Move the origin - translate shifts everything
  • Rotate - rotation preserves both angles and lengths, but it's still affine

What is not affine (and SVG's transform cannot do):

  • Perspective - parallel lines converging to a vanishing point (available in CSS 3D, not in SVG transforms)
  • Warping/bending - curving a straight edge (not available in either CSS or SVG transforms)

In practice: matrix(a,b,c,d,e,f) can express any combination of translate + rotate + scale + skew in a single operation. You'll rarely write it by hand - the named functions are far more readable - but they all collapse to this same 6-number matrix under the hood.

2. Translate

The simplest transform - shifts everything in the element's local coordinate system:

<svg width="300" height="150" viewBox="0 0 300 150">
  <!-- Original position -->
  <rect x="10" y="10" width="50" height="30" fill="#61afef"/>

  <!-- Translated 100 right, 50 down -->
  <rect x="10" y="10" width="50" height="30" fill="#e06c75"
        transform="translate(100, 50)"/>
</svg>
original translated(100, 50)

Same rectangle, same coordinates - only the coordinate system is shifted.

Notice both rects have x="10" y="10". The red one appears elsewhere because its local origin has moved.

3. Rotate

Rotates the coordinate system by the given angle in degrees (clockwise, because y-axis points down):

<rect x="60" y="20" width="80" height="40" fill="#98c379"
      transform="rotate(30)"/>
The rotation origin gotcha

By default, rotate(angle) rotates around (0, 0) - the top-left corner of the current coordinate system. This is almost never what you want. To rotate around the element's own centre, use the three-argument form:

rotate(angle, cx, cy) - where (cx, cy) is the point to rotate around.

0,0 original rotate(30) - around 0,0 rotate(30, 120, 80) - around centre

Red: rotated around (0,0) - swings far from its original position. Blue: rotated around its own centre (120,80) - stays in place.

4. Scale

Multiplies all coordinates by the scale factor:

<rect x="20" y="20" width="40" height="30" fill="#e5c07b"
      transform="scale(2)"/>
<!-- Effective position: x=40, y=40, width=80, height=60 -->
Scale also scales position

Because transforms modify the coordinate system, scaling by 2 also doubles the element's x and y position - not just its size. A rect at (20, 20) with scale(2) effectively appears at (40, 40) at double size.

To scale "in place" (from centre), you need to translate to origin, scale, then translate back - or use CSS transform-origin.

original (20,20) scale(2) - position also doubled

The rect's position moved from (20,20) to (40,40) because the entire coordinate system was scaled.

scale(-1, 1) flips horizontally. scale(1, -1) flips vertically. This is how axis reversal works in practice - transforms are where it lives, not in the viewport/viewBox.

Skew - tilting the coordinate axes

skewX(angle) tilts the y-axis by the given angle, making vertical lines lean. skewY(angle) tilts the x-axis, making horizontal lines lean. The result is a parallelogram distortion:

original skewX(20) y-axis tilts 20° skewY(20) x-axis tilts 20° skewX(15) skewY(10) combined

Skew distorts the coordinate axes independently. skewX shears horizontally (vertical lines lean); skewY shears vertically (horizontal lines lean). Combined skews produce a diamond/rhombus shape from a square.

Scaling "in place" - the concrete technique

Say you have a rect at x="100" y="60" width="80" height="50" and you want to scale it to 1.5× from its centre. Its centre is at (140, 85) - that's x + width/2, y + height/2.

Approach 1: SVG transform attribute - translate the grid's origin to the centre, scale, then translate back:

<rect x="100" y="60" width="80" height="50" fill="#98c379"
      transform="translate(140, 85) scale(1.5) translate(-140, -85)"/>

Reading right to left (the order things happen):

  1. translate(-140, -85) - shift the grid so the rect's centre is now at (0,0)
  2. scale(1.5) - scale around (0,0) - which is the rect's centre
  3. translate(140, 85) - shift everything back to the original position

Approach 2: CSS transform-origin - let the browser do the translate/untranslate for you:

<rect x="100" y="60" width="80" height="50" fill="#98c379"
      style="transform: scale(1.5); transform-origin: 140px 85px;"/>

Same result, less manual maths. But remember: SVG's transform-origin defaults to (0, 0), so you must set it explicitly.

Note on units: because transform-origin is a CSS property (even when written in a style attribute), you must include units - 140px 85px, not 140 85. Unitless values are invalid CSS and will be ignored. The px here maps to SVG user units, so 140px in CSS = 140 in the SVG transform attribute.

original centre (140,85) scale(1.5) only - drifts translate + scale + untranslate - stays centred

Red: naive scale(1.5) - the rect grows and drifts (position scaled too). Green: the translate-scale-untranslate pattern - the rect scales from its centre and stays in place.

5. Chaining Transforms

You can apply multiple transforms in a single attribute, separated by spaces:

<rect transform="translate(100, 50) rotate(45) scale(1.5)"/>
Order matters - two ways to read a transform chain

Given translate(100, 50) rotate(45) scale(1.5):

Reading direction You're following… How to think about it
Right to left …the shape "The shape is scaled, then rotated, then translated." More intuitive for simple cases.
Left to right …the coordinate system "The grid is shifted, then rotated, then scaled - now draw the shape on this modified grid." More faithful to what actually happens, and gives correct predictions when chaining gets complex.

Both arrive at the same result. The left-to-right reading is more consistent with the core insight that transforms modify space, not shapes - and it's the one that won't mislead you when chains get long.

Either way, the critical point: translate(100,50) rotate(45) and rotate(45) translate(100,50) produce different results because the order of coordinate system modifications matters.

6. The <g> Element - Grouping

The <g> (group) element lets you apply a single transform to multiple children at once:

<svg width="300" height="200" viewBox="0 0 300 200">
  <g transform="translate(50, 30) rotate(15, 100, 70)">
    <rect x="60" y="40" width="80" height="60" fill="#61afef"/>
    <circle cx="140" cy="70" r="20" fill="#e06c75"/>
    <text x="60" y="120" font-size="12">Grouped!</text>
  </g>
</svg>

Key points about <g>:

Ghost shapes show original positions. The group moves and rotates them together as a unit.

<g> vs nested <svg>

From Lesson 1, recall that nested <svg> creates its own viewport and coordinate system. Here's when to use which:

7. transform-origin in SVG

CSS lets you set transform-origin to control the pivot point. In HTML, it defaults to 50% 50% (centre of the element). In SVG, the story is different:

SVG's transform-origin defaults to (0, 0)

For SVG elements (except the root <svg>), transform-origin defaults to the origin of the user coordinate system - not the centre of the element. This means CSS transforms like transform: rotate(45deg) will swing the element around the top-left corner unless you explicitly set transform-origin.

The rule: if an element participates in HTML layout, it gets HTML defaults (50% 50%). If it lives inside SVG coordinate space, it gets SVG defaults (0 0).

Element transform-origin default Why
Root <svg> 50% 50% (centre) Lives in HTML document flow - behaves like a <div> or <img>
Nested <svg> inside <foreignObject> 50% 50% (centre) <foreignObject> bridges back into HTML layout
Everything else (rect, circle, g, nested svg, etc.) 0 0 (UCS origin) Lives inside SVG coordinate space

Two approaches to rotate around an element's centre:

<!-- Approach 1: SVG transform attribute with explicit centre -->
<rect x="50" y="30" width="100" height="60"
      transform="rotate(45, 100, 60)"/>
<!-- 100 = x + width/2, 60 = y + height/2 -->

<!-- Approach 2: CSS transform with transform-origin -->
<rect x="50" y="30" width="100" height="60"
      style="transform: rotate(45deg); transform-origin: 100px 60px;"/>

The SVG attribute approach (Approach 1) is simpler for rotation because the centre point is built into the rotate() function. For CSS animations, you'll need to set transform-origin explicitly.

8. How Transforms Relate to the Coordinate System

Let's tie this back to Lesson 1. Remember the three layers:

  1. User Coordinate System - the infinite stage
  2. viewBox - selects a rectangle of the stage
  3. Viewport - physical size on the page

Transforms add a fourth layer: local coordinate systems. Each element with a transform attribute gets its own local coordinate system, derived from its parent's. These nest:

<svg viewBox="0 0 200 200">              <!-- UCS established by viewBox -->
  <g transform="translate(50, 50)">       <!-- local system: shifted -->
    <g transform="rotate(30)">            <!-- nested local: shifted + rotated -->
      <rect x="0" y="0" width="40" height="40"/>  <!-- drawn in shifted+rotated space -->
    </g>
  </g>
</svg>

The rect is at (0,0) in its own local space - but that local space has been translated and rotated relative to the UCS. The coordinate system chain is: UCS → translate → rotate → element.

CSS transforms work the same way

This "transforms move the coordinate system" model applies to CSS transforms on HTML elements too - it's the same maths. The reason CSS rotations and scales on <div>s seem to "just work" centred is that CSS defaults transform-origin to 50% 50%. Under the hood, the browser is doing the translate-to-centre → apply transform → translate-back pattern automatically. SVG makes this machinery visible because it defaults to (0, 0).

Animating transforms

Transforms can be smoothly animated via three methods (covered in Lessons 9-11):

All three require you to set transform-origin explicitly for elements inside the SVG (because of the 0 0 default). We'll cover these properly in Lessons 9–11.

9. Interactive: Transform Explorer

Drag sliders to see how translate, rotate, and scale modify the coordinate system. The ghost shows the original position.

Ad

10. Reusability: <defs>, <symbol>, and <use>

Grouping with <g> organises elements visually, but SVG also has a system for defining reusable content that isn't rendered until you explicitly instantiate it.

<defs> - define without rendering

Content inside <defs> is invisible until referenced elsewhere. It's the standard place to put gradients, patterns, clip paths, filters - and any shapes you want to reuse:

<svg viewBox="0 0 300 100" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <!-- Defined but not rendered -->
    <circle id="dot" r="10"/>
  </defs>

  <!-- Instantiate it multiple times with <use> -->
  <use href="#dot" x="50" y="50" fill="coral"/>
  <use href="#dot" x="150" y="50" fill="#61afef"/>
  <use href="#dot" x="250" y="50" fill="#98c379"/>
</svg>

One circle defined in <defs>, instantiated three times with <use> at different positions and fills.

<use> - instantiate a clone

<use href="#id"> creates a visual clone of the referenced element. You can reposition it with x/y and override inheritable presentation attributes (like fill). The clone lives in a shadow-DOM-like scope - you can't directly style its internals with external CSS selectors.

AttributePurpose
hrefReference to the element to clone: #id or file.svg#id
x, yPosition offset for the clone
width, heightOverride dimensions (only works on <symbol> or <svg> targets)

<symbol> - reusable template with its own viewBox

<symbol> is like <defs> content but it has its own viewBox and preserveAspectRatio. When instantiated via <use>, the symbol scales to the width/height you give the <use> element. This makes it ideal for icon systems:

<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
  <symbol id="icon-star" viewBox="0 0 24 24">
    <polygon points="12,2 15,9 22,9 16,14 18,21 12,17 6,21 8,14 2,9 9,9"/>
  </symbol>
</svg>

<!-- Use at different sizes  -  the symbol scales to fit -->
<svg viewBox="0 0 300 80" xmlns="http://www.w3.org/2000/svg">
  <use href="#icon-star" x="10" y="10" width="24" height="24" fill="coral"/>
  <use href="#icon-star" x="50" y="5" width="36" height="36" fill="#e5c07b"/>
  <use href="#icon-star" x="110" y="0" width="48" height="48" fill="#61afef"/>
</svg>
defs vs symbol

<defs> is a generic invisible container. <symbol> adds viewBox + preserveAspectRatio, so the content scales proportionally when used at different sizes. Use <defs> for gradients, patterns, clip paths; use <symbol> for reusable graphic components (icons, repeated motifs).

Styling limitations of <use>

A <use> clone creates a shadow boundary. You can set inheritable properties on the <use> element (fill, stroke, font styles), and they'll cascade into the clone - but only if the original element doesn't set them explicitly. You cannot target internal parts of the clone with CSS selectors from outside. This is intentional - it preserves encapsulation.

Quiz: Check Your Understanding

Question 1

What does SVG's rotate(45) rotate around by default?

The centre of the element's bounding box
The origin (0, 0) of the current coordinate system
The centre of the viewport
The centre of the viewBox

Question 2

Given transform="translate(50, 0) scale(2)" on a rect at x="10", where does the rect's left edge end up?

At x = 60 (translated 50, then scaled doesn't move it further)
At x = 20 (only the scale applies, doubling the position)
At x = 70 (scale doubles 10 to 20, then translate adds 50)
At x = 120 (translate 50, then scale doubles the result)

Question 3

What's the difference between <g> and nested <svg>?

They are interchangeable for grouping
<g> creates a new viewport, <svg> does not
<g> inherits the parent coordinate system; nested <svg> establishes a new one
<g> cannot have transforms applied

Hands-On Exercise

Create an SVG that demonstrates transform chaining and grouping:

  1. Create an SVG with width="400" height="300" viewBox="0 0 400 300".
  2. Draw a rectangle at x="0" y="0" width="60" height="40" with any fill. Apply transform="translate(170, 130)" so it appears near the centre.
  3. Now add rotate(45, 30, 20) to the transform (after the translate). Predict: will it rotate around the centre of the rect or around (30,20) in the translated coordinate system? (Hint: both are the same here - 30 = width/2, 20 = height/2, and these are in the local translated space.)
  4. Wrap two shapes in a <g> with transform="translate(100, 50)". Then give one of the children its own transform="rotate(20, 30, 20)". Observe how the child's transform stacks on top of the group's.