Skip to content
academia.sh

Lesson 13 / 23

Transforms

The matrix equivalent of the translate, scale, rotate, and skew functions; how function order changes the result, the effect of the transform origin, and the transform being applied after layout.

Contents

The previous lesson read user preferences: color scheme and reduced motion. What the color scheme preference changes is clear — color declarations. The reduced-motion preference removes declarations that “produce a change of position,” and what those declarations are has not been defined yet.

This topic establishes that definition. Motion on a page comes from three layers: geometric operations that translate, scale, and rotate a box in its own place; the spread of a change between two states over time; and keyframe sequences driven by a timeline. This lesson covers the first. The three operations share a common name and a common mathematics; once both are seen, which motion moves what becomes a measurable question.

A Transform Is Applied After Layout

Transform is used to translate, scale, rotate, or skew an element’s box. Its defining property is that it does not touch the layout calculation.

The order is this: the browser computes layout, finds the box’s place and size, then applies the transform to that box. The element’s place in layout does not change; its neighbors stay exactly as if nothing happened. This is why pushing an element to the right with transform: translateX(40px) differs from pushing it with margin-inline-start: 40px: the second recomputes the entire flow inside the containing block, the first only changes that one box’s rendering.

This has three consequences:

  1. A transformed element can overflow onto its neighbors; the overflow calculation comes from layout, not from the transform.
  2. Percentage values are read against the element’s own border box. translateX(50%) is half the element’s own width, whereas left: 50% is half the containing block’s width.
  3. A transform carrying a value other than none establishes a new stacking context and becomes the containing block for fixed-positioned descendants inside it. This rule was defined in the Visual Presentation with CSS course’s stacking-context lesson; it comes back often when writing motion.

The third point is a common source of bugs. Writing a small transform on a container that wraps the entire page turns a fixed-positioned header inside that container into one pinned to the container, not the screen.

Functions and Their Matrix Equivalents

The transform property takes a list of functions. Every function in the list corresponds to a 3×33 \times 3 matrix, and the list is the product of these matrices. In two dimensions, the matrix’s last row is always (0,0,1)(0, 0, 1), so six numbers are enough; these are the numbers the matrix() function takes.

(acebdf001)(xy1)=(ax+cy+ebx+dy+f)\begin{pmatrix} a & c & e \\ b & d & f \\ 0 & 0 & 1 \end{pmatrix} \begin{pmatrix} x \\ y \\ 1 \end{pmatrix} = \begin{pmatrix} ax + cy + e \\ bx + dy + f \end{pmatrix}

A point is carried to its new place by this product. The following program converts functions to matrices, multiplies lists, and tracks a card’s four corners.

// transform.mjs — matrix equivalents of transform functions and order dependence
// matrix [a, b, c, d, e, f] -> | a c e |
//                              | b d f |
//                              | 0 0 1 |
const multiply = (m, n) => [
  m[0] * n[0] + m[2] * n[1],
  m[1] * n[0] + m[3] * n[1],
  m[0] * n[2] + m[2] * n[3],
  m[1] * n[2] + m[3] * n[3],
  m[0] * n[4] + m[2] * n[5] + m[4],
  m[1] * n[4] + m[3] * n[5] + m[5],
];

const toRad = (d) => (d * Math.PI) / 180;
const translation = (tx, ty) => [1, 0, 0, 1, tx, ty];
const scaling = (sx, sy) => [sx, 0, 0, sy, 0, 0];
const rotation = (d) => [Math.cos(toRad(d)), Math.sin(toRad(d)), -Math.sin(toRad(d)), Math.cos(toRad(d)), 0, 0];
const skewX = (d) => [1, 0, Math.tan(toRad(d)), 1, 0, 0];
const skewY = (d) => [1, Math.tan(toRad(d)), 0, 1, 0, 0];

const IDENTITY = [1, 0, 0, 1, 0, 0];
const chain = (list) => list.reduce((t, m) => multiply(t, m), IDENTITY);
const format = (m) => "matrix(" + m.map((v) => (Math.abs(v) < 1e-9 ? 0 : +v.toFixed(4))).join(", ") + ")";
const point = (m, [x, y]) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
const p = ([x, y]) => `(${x.toFixed(1)}, ${y.toFixed(1)})`;

console.log("--- matrix equivalents of single functions ---");
console.log("translate(40px, 12px) ->", format(translation(40, 12)));
console.log("scale(1.25, 1.25)     ->", format(scaling(1.25, 1.25)));
console.log("rotate(30deg)         ->", format(rotation(30)));
console.log("skewX(12deg)          ->", format(skewX(12)));
console.log("skewY(12deg)          ->", format(skewY(12)));

console.log("\n--- order dependence: same two functions, two notations ---");
const A = chain([translation(120, 0), rotation(30)]);
const B = chain([rotation(30), translation(120, 0)]);
console.log("translate(120px, 0) rotate(30deg) ->", format(A));
console.log("rotate(30deg) translate(120px, 0) ->", format(B));

const CORNERS = [[0, 0], [220, 0], [220, 120], [0, 120]];   // a 220x120-unit card
console.log("\ncorner points (top-left, top-right, bottom-right, bottom-left):");
console.log("  notation 1:", CORNERS.map((k) => p(point(A, k))).join(" "));
console.log("  notation 2:", CORNERS.map((k) => p(point(B, k))).join(" "));

console.log("\n--- transform-origin: T(o) * M * T(-o) ---");
const ORIGINS = [[0, 0], [110, 60], [220, 120]];          // top-left, center, bottom-right
for (const [ox, oy] of ORIGINS) {
  const M = chain([translation(ox, oy), rotation(30), translation(-ox, -oy)]);
  console.log(`origin (${ox}, ${oy})`.padEnd(18), format(M));
  console.log("  corners:", CORNERS.map((k) => p(point(M, k))).join(" "));
}

console.log("\n--- order of scaling and translation ---");
const C = chain([scaling(2, 2), translation(50, 0)]);
const D = chain([translation(50, 0), scaling(2, 2)]);
console.log("scale(2) translate(50px, 0) ->", format(C), " top-left corner:", p(point(C, [0, 0])));
console.log("translate(50px, 0) scale(2) ->", format(D), " top-left corner:", p(point(D, [0, 0])));

console.log("\n--- reducing to a single matrix: three functions, one product ---");
const E = chain([translation(0, -8), rotation(-3), scaling(1.04, 1.04)]);
console.log("translate(0, -8px) rotate(-3deg) scale(1.04) ->", format(E));
console.log("card corners:", CORNERS.map((k) => p(point(E, k))).join(" "));
--- matrix equivalents of single functions ---
translate(40px, 12px) -> matrix(1, 0, 0, 1, 40, 12)
scale(1.25, 1.25)     -> matrix(1.25, 0, 0, 1.25, 0, 0)
rotate(30deg)         -> matrix(0.866, 0.5, -0.5, 0.866, 0, 0)
skewX(12deg)          -> matrix(1, 0, 0.2126, 1, 0, 0)
skewY(12deg)          -> matrix(1, 0.2126, 0, 1, 0, 0)

--- order dependence: same two functions, two notations ---
translate(120px, 0) rotate(30deg) -> matrix(0.866, 0.5, -0.5, 0.866, 120, 0)
rotate(30deg) translate(120px, 0) -> matrix(0.866, 0.5, -0.5, 0.866, 103.923, 60)

corner points (top-left, top-right, bottom-right, bottom-left):
  notation 1: (120.0, 0.0) (310.5, 110.0) (250.5, 213.9) (60.0, 103.9)
  notation 2: (103.9, 60.0) (294.4, 170.0) (234.4, 273.9) (43.9, 163.9)

--- transform-origin: T(o) * M * T(-o) ---
origin (0, 0)      matrix(0.866, 0.5, -0.5, 0.866, 0, 0)
  corners: (0.0, 0.0) (190.5, 110.0) (130.5, 213.9) (-60.0, 103.9)
origin (110, 60)   matrix(0.866, 0.5, -0.5, 0.866, 44.7372, -46.9615)
  corners: (44.7, -47.0) (235.3, 63.0) (175.3, 167.0) (-15.3, 57.0)
origin (220, 120)  matrix(0.866, 0.5, -0.5, 0.866, 89.4744, -93.923)
  corners: (89.5, -93.9) (280.0, 16.1) (220.0, 120.0) (29.5, 10.0)

--- order of scaling and translation ---
scale(2) translate(50px, 0) -> matrix(2, 0, 0, 2, 100, 0)  top-left corner: (100.0, 0.0)
translate(50px, 0) scale(2) -> matrix(2, 0, 0, 2, 50, 0)  top-left corner: (50.0, 0.0)

--- reducing to a single matrix: three functions, one product ---
translate(0, -8px) rotate(-3deg) scale(1.04) -> matrix(1.0386, -0.0544, 0.0544, 1.0386, 0, -8)
card corners: (0.0, -8.0) (228.5, -20.0) (235.0, 104.7) (6.5, 116.6)

The first block shows where each of the four families writes into the matrix. Translation touches only the last two numbers, ee and ff; the top-left 2×22 \times 2 section stays the identity matrix. Scaling writes to the diagonal. Rotation uses all four cells, and the values are the angle’s cosine and sine. Skew fills a single off-diagonal cell: skewX fills the top cc cell, skewY the bottom bb cell.

Skewing has a side effect: once an off-diagonal cell is filled, right angles are not preserved. Scaling and rotation leave a rectangle a rectangle; skewing turns it into a parallelogram.

Order Dependence

Matrix multiplication is not commutative. The output’s second block shows this with two notations. The same two functions, written in different order, give two different matrices: one with a translation of (120,0)(120, 0), the other of (103.9,60)(103.9, 60).

The rule is: the list is multiplied left to right, but applied to a point right to left. The function written last is the first to touch the point.

  • translate(120px, 0) rotate(30deg) — the card first rotates in its own place, then, in its rotated form, moves 120 units to the right along the page’s axes. The amount of translation is not affected by the rotation.
  • rotate(30deg) translate(120px, 0) — the card is first translated 120 units, then everything, including that translation, rotates. The translation appears to have happened along the rotated axis: the 120-unit move lands at the point (103.9,60)(103.9, 60), because 120cos30°103.9120 \cos 30° \approx 103.9 and 120sin30°=60120 \sin 30° = 60.

The same event is more exposed in the scale-and-translation pair. In the notation scale(2) translate(50px, 0), the translation also doubles and the card moves 100 units; in the reversed notation, it moves 50 units. Lengths written after a scale are in scaled units.

This is the rule broken most often when writing motion. If a card is meant to both grow and lift at once, the order of the two functions determines both the direction and the amount of the motion.

Transform Origin

Rotation and scaling leave a fixed point in place. This point is called the transform origin, and its default value is the box’s center.

The matrix equivalent is not a separate mechanism: the origin turns the transform into a three-step chain. The origin point is moved to the start, the transform is applied, the point is carried back to its place. The output’s third block computes this chain for three origins.

The results read as follows: with the origin at the top-left corner, the point (0,0)(0, 0) stays in place; with the origin at the bottom-right corner, the point (220,120)(220, 120) stays in place. The difference is reflected in the ee and ff values — the rotation matrix’s first four numbers are the same across all three cases.

transform-origin takes two or three values; percentages are read against the element’s own border box. If a card should open from its left edge, the origin is written as left center.

Moving to Three Dimensions

Once a function such as translateZ, rotateX, or rotateY enters the two-dimensional list, the matrix becomes 4×44 \times 4 and matrix3d() takes sixteen numbers. The rule stays the same: the list is multiplied, and applied to the point right to left.

In three dimensions, two additional declarations come into play. perspective defines a viewing distance; a small value gives a strong depth effect, a large value a weak one. This value can be written on the element itself, inside the transform list, or on its parent with the perspective property; the second lets sibling elements share a common vanishing point.

transform-style: preserve-3d, in turn, tells child elements to keep their own depth instead of being flattened. The default value, flat, presses the subtree onto the parent’s plane. The backface-visibility: hidden declaration says an element’s back face should not be drawn when it turns toward the viewer — it is used to hide the reverse side of a card that has two faces.

Three-dimensional transforms have a cost, and it is covered in this topic’s fourth lesson: an element carrying depth is split into its own layer during rendering.

Transform on the Station Page

Let a touch indicator be added to the measurement cards. When the pointer is over a card, it should lift slightly and grow a little.

/* motion.css — step 1: card highlight */
.measurement-cards > .card {
  transform-origin: center bottom;
}

.measurement-cards > .card:hover,
.measurement-cards > .card:focus-within {
  transform: translateY(-8px) scale(1.04);
}

The order is deliberate. Because translateY(-8px) is written first, the 8-unit lift is not scaled; written in the reverse order, the lift would be 8×1.04=8.328 \times 1.04 = 8.32 units. The difference is small, but the same rule produces an eight-unit deviation for a 200-unit translation.

transform-origin: center bottom fixes the card’s bottom edge: the card grows from its own base and does not spill symmetrically onto the neighboring card.

The :focus-within pseudo-class gives the same declaration to a keyboard user that :hover gives to a pointer user. When a link inside the card receives focus, the card lifts the same way.

This file sits next to the layout.css file. Whether a third file is needed — how visual, layout, and motion declarations should be divided — is the question of the course’s final topic.

Summary

  • A transform is applied after the layout calculation; the element’s place in layout and its neighbors’ positions do not change, only the rendered box is moved.
  • Every function in the transform list is a matrix; the list is the product of these matrices, and the matrix() notation gives a two-dimensional transform’s six numbers.
  • Because the product is not commutative, function order changes the result: the list is multiplied left to right, applied to a point right to left, and lengths written after a scale are in scaled units.
  • transform-origin is not a separate mechanism but a pair of translations added before and after the transform; its default is the box’s center.
  • A transform value other than none establishes a stacking context and becomes the containing block for fixed-positioned descendants.

Next Step

This lesson’s declarations make motion instantaneous: the moment the pointer is over the card, the card is eight units up and four percent larger, with nothing in between. But what is called motion is the path between two states. The next lesson covers the declarations that define that path: which property, over how long, along which speed curve, goes from its old value to its new one?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close