---
title: Transforms
source: 'https://academia.sh/en/courses/layout-and-responsive-design/transforms'
course: 'Layout Systems and Responsive Design'
language: en
updated: '2026-08-17T18:09:27+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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 \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)$, so six numbers are enough; these
are the numbers the `matrix()` function takes.

$$
\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.

```js
// 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, $e$ and $f$; the top-left $2 \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 $c$ cell, `skewY` the bottom $b$ 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)$, the other of $(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)$,
  because $120 \cos 30° \approx 103.9$ and $120 \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)$
stays in place; with the origin at the bottom-right corner, the point $(220, 120)$ stays in
place. The difference is reflected in the $e$ and $f$ 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 \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.

```css
/* 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 \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?
