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:
- A transformed element can overflow onto its neighbors; the overflow calculation comes from layout, not from the transform.
- Percentage values are read against the element’s own border box.
translateX(50%)is half the element’s own width, whereasleft: 50%is half the containing block’s width. - A transform carrying a value other than
noneestablishes a new stacking context and becomes the containing block forfixed-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 matrix, and the list is the product of these matrices. In two
dimensions, the matrix’s last row is always , so six numbers are enough; these
are the numbers the matrix() function takes.
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, and ; the top-left
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 cell, skewY the bottom 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 , the other of .
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 , because and .
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 stays in place; with the origin at the bottom-right corner, the point stays in place. The difference is reflected in the and 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 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 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-originis 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
noneestablishes 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.