Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

CSS  Module 16 – Transitions, animations, scroll and view transitions

Transforms: translate, rotate, scale and 3D

Move, rotate and scale elements with transform and the individual properties, why function order matters, what transforms change, and simple 3D.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 20 minutes

What you will learn

  • Move, rotate and scale elements with the translate, rotate and scale properties and with transform
  • Predict how transform-origin and the order of transform functions change where an element is drawn
  • Explain what transforms leave alone, the layout, and what they change, stacking and fixed positioning
  • Build a 3D card flip with perspective, transform-style and backface-visibility that works without motion

Before you start

What you’ll learn · Before you start

What you will learn

  • Move, rotate and scale elements with the translate, rotate and scale properties and with transform
  • Predict how transform-origin and the order of transform functions change where an element is drawn
  • Explain what transforms leave alone, the layout, and what they change, stacking and fixed positioning
  • Build a 3D card flip with perspective, transform-style and backface-visibility that works without motion

Before you start

On this page

A transform changes where and how an element is drawn, moving it, turning it, resizing it or tilting it in 3D, without changing the layout around it. Four properties do the work. translate moves an element, as in translate: 0 -4px for a lift on hover. rotate turns it, as in rotate: 45deg. scale resizes it, as in scale: 1.05. transform takes a list of functions that do the same jobs and more, such as transform: rotate(45deg) translateX(100px). The neighbours of a transformed element stay exactly where they were, which is why transforms are the cheap way to animate, and also why a scaled element can overlap them. For 3D, perspective sets how far away the viewer is and backface-visibility hides the side of an element that faces away, which is all a card flip needs. The positions and sizes in the tables come straight from getBoundingClientRect() in three browser engines (Chromium 153, Firefox 155, WebKit 26.6), beginning with the result that surprises most people: the same functions in another order put the element somewhere else.

translate, rotate, scale and the transform list

The three individual properties are Baseline widely available, and they are usually the clearer choice. translate takes up to three lengths, and percentages of the element’s own size, so translate: -50% -50% moves an element up and left by half of itself. rotate takes an angle, and optionally an axis for 3D, as in rotate: y 180deg. scale takes one factor for both directions or one each, as in scale: 2 1. Because each is a property of its own, states combine without repeating each other:

Hover, focus and a tilt, with the individual properties and with one transform list CSS · individual.css
/* With the individual properties, each part of the motion has its own property, so states combine freely. */
.card {
  transition: translate 200ms ease-out, scale 200ms ease-out;
}

.card:hover,
.card:focus-within {
  translate: 0 -4px;     /* lift */
  scale: 1.02;           /* and grow a little */
}

.card.is-new {
  rotate: -2deg;         /* a tilt that the hover rule above leaves alone */
}

/* The same states with one transform list: every combination has to repeat every function, in the right order. */
.card-old:hover,
.card-old:focus-within {
  transform: translateY(-4px) scale(1.02);
}

.card-old.is-new {
  transform: rotate(-2deg);
}

.card-old.is-new:hover,
.card-old.is-new:focus-within {
  transform: translateY(-4px) rotate(-2deg) scale(1.02);
}

The transform list can do more: skew(), matrix(), any number of functions, and a different order for each element. The cost shows in the second half of that file. One transform value replaces another completely, so every combination of states needs its own rule that repeats all the functions, and forgetting one drops the tilt or the lift. Use the individual properties for the everyday moves and transform for the rare effect that needs a specific sequence. Both can be used on the same element, and then the order is fixed, as the next section measures.

The same functions in another order

transform applies its functions from left to right, and each one works in the coordinate system that the functions before it have already moved, rotated or scaled. So rotate(45deg) translateX(100px) first turns the box and its x axis by 45°, then moves the box 100px along that turned axis, down and to the right. translateX(100px) rotate(45deg) first moves the box 100px to the right, then turns it where it stands.

Two orders of rotate(45deg) and translateX(100px) on the same box, step by step, ending in different places.transform: rotate(45deg) translateX(100px)transform: translateX(100px) rotate(45deg)1. rotate(45deg)the box and its x axis turn 45°around the box's centre2. translateX(100px)it moves 100px along the turned x axis:down and to the rightcentre drawn at 320.7, 295.71. translateX(100px)it moves 100px to the right2. rotate(45deg)it turns 45° where it now standscentre drawn at 350, 225same functions, other order

The same two transform functions, in two orders

Text description of the diagram

The diagram shows two sequences for a 100 by 50 pixel box whose centre starts at 250, 225.

First, transform: rotate(45deg) translateX(100px). Step 1, rotate(45deg): the box and its x axis turn 45 degrees around the box's centre. Step 2, translateX(100px): the box moves 100 pixels along the turned x axis, which points down and to the right. Its centre is drawn at 320.7, 295.7.

Second, transform: translateX(100px) rotate(45deg). Step 1, translateX(100px): the box moves 100 pixels to the right. Step 2, rotate(45deg): it turns 45 degrees where it now stands. Its centre is drawn at 350, 225.

A dashed arrow between the two sequences notes that they use the same functions in a different order.

This program does the arithmetic with matrices for a 100 × 50 box at x 200, y 200, turning around its centre:

Two orders of the same two functions, worked out JavaScript · order.mjs
// Where a 100 × 50 box at x 200, y 200 ends up for two orders of the same two functions, worked out with 2D matrices
// the way CSS Transforms does it: functions apply from left to right, around the transform-origin (the centre here).
const multiply = ([a, b, c, d, e, f], [g, h, i, j, k, l]) =>
  [a * g + c * h, b * g + d * h, a * i + c * j, b * i + d * j, a * k + c * l + e, b * k + d * l + f];
const translate = (x, y = 0) => [1, 0, 0, 1, x, y];
const rotate = (deg) => {
  const r = (deg * Math.PI) / 180;
  return [Math.cos(r), Math.sin(r), -Math.sin(r), Math.cos(r), 0, 0];
};
const apply = ([a, b, c, d, e, f], [x, y]) => [a * x + c * y + e, b * x + d * y + f];

const box = { x: 200, y: 200, width: 100, height: 50 };
const origin = [box.x + box.width / 2, box.y + box.height / 2];

function place(name, functions) {
  // translate to the origin, apply the list left to right, translate back
  let m = translate(...origin);
  for (const fn of functions) m = multiply(m, fn);
  m = multiply(m, translate(-origin[0], -origin[1]));
  const corners = [[box.x, box.y], [box.x + box.width, box.y], [box.x, box.y + box.height], [box.x + box.width, box.y + box.height]]
    .map((p) => apply(m, p));
  const xs = corners.map(([x]) => x), ys = corners.map(([, y]) => y);
  const centre = apply(m, origin).map((v) => v.toFixed(1));
  const size = [Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)].map((v) => v.toFixed(1));
  console.log(`${name.padEnd(28)} centre at ${centre.join(', ')}; bounding box ${size.join(' × ')}`);
}

place('rotate(45deg) translateX(100px)', [rotate(45), translate(100)]);
place('translateX(100px) rotate(45deg)', [translate(100), rotate(45)]);

Output

rotate(45deg) translateX(100px) centre at 320.7, 295.7; bounding box 106.1 × 106.1
translateX(100px) rotate(45deg) centre at 350.0, 225.0; bounding box 106.1 × 106.1

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node order.mjs

The page below draws four boxes and asks the browser where it put each one:

The two orders, and the individual properties, measured HTML · order.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>The order of transform functions</title>
  <style>
    body { margin: 0; }
    .box { position: absolute; inset-block-start: 200px; inset-inline-start: 200px; inline-size: 100px; block-size: 50px;
      background: #0e7490; }
    #rotate-first { transform: rotate(45deg) translateX(100px); }
    #translate-first { transform: translateX(100px) rotate(45deg); }
    #individual { translate: 100px; rotate: 45deg; }        /* the individual properties: translate, then rotate */
    #mixed { rotate: 45deg; transform: translateX(100px); } /* rotate first, because transform always comes last */
  </style>
</head>
<body>
  <div class="box" id="rotate-first"></div>
  <div class="box" id="translate-first"></div>
  <div class="box" id="individual"></div>
  <div class="box" id="mixed"></div>
  <script>
    // Every box starts at x 200, y 200 with its origin at its centre (250, 225). Where does each one end up?
    for (const box of document.querySelectorAll('.box')) {
      const r = box.getBoundingClientRect();
      const centre = [(r.left + r.right) / 2, (r.top + r.bottom) / 2].map((v) => v.toFixed(1));
      console.log(`${box.id}: centre at ${centre.join(', ')}; bounding box ${r.width.toFixed(1)} × ${r.height.toFixed(1)}`);
    }
  </script>
</body>
</html>
The box has Centre drawn at Bounding box
transform: rotate(45deg) translateX(100px) 320.7, 295.7 106.1 × 106.1
transform: translateX(100px) rotate(45deg) 350.0, 225.0 106.1 × 106.1
translate: 100px; rotate: 45deg 350.0, 225.0 106.1 × 106.1
rotate: 45deg; transform: translateX(100px) 320.7, 295.7 106.1 × 106.1

All three engines matched the program. The last two rows show the fixed order of the individual properties: CSS always applies translate, then rotate, then scale, and only then the functions of transform, whatever order the declarations are written in. So rotate with a transform that translates behaves like the rotate-first list. The bounding box, what getBoundingClientRect() reports, is the rectangle around the turned box: 106.1 px square for a 100 × 50 box at 45°.

transform-origin

Rotation and scaling happen around the transform origin, the point that stays still. It defaults to the centre of the element’s border box, 50% 50%. A 100 × 50 box turned by 90° around its centre ends up 50 wide and 100 tall with the same centre; turned around transform-origin: 0 0, its top-left corner, it swings out to the left of where it was, measured at x 150 to 200 and y 200 to 300 in all three engines. A clock hand turns around one end and a bell swings from its top, so set the origin to the point the real object would hang from. For SVG shapes, transform-box picks the box the origin refers to; it is Baseline newly available.

What a transform changes, and what it leaves alone

A transform happens after layout. The browser sizes and places every box first, then draws the transformed element moved, turned or resized, so nothing else on the page moves to make room. zoom is the exception that proves the rule: it scales an element before layout, so the layout grows with it. Two tiles in a row, measured:

scale against zoom, and a fixed child inside a transformed parent HTML · layout.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>What a transform changes, and what it leaves alone</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .row { display: flex; gap: 8px; margin: 8px; }
    .tile { inline-size: 50px; block-size: 50px; background: #4338ca; }
    #scaled { scale: 2; }
    #zoomed { zoom: 2; }
    .panel { margin: 8px; block-size: 100px; background: #e0e7ff; }
    .panel .pin { position: fixed; inset-block-start: 0; inset-inline-start: 0; inline-size: 40px; block-size: 20px;
      background: #be123c; }
    #moved { translate: 0 0; }           /* a translate of zero still changes the rules for its descendants */
    .spacer { block-size: 2000px; }
  </style>
</head>
<body>
  <div class="row"><div class="tile" id="plain"></div><div class="tile next"></div></div>
  <div class="row"><div class="tile" id="scaled"></div><div class="tile next"></div></div>
  <div class="row"><div class="tile" id="zoomed"></div><div class="tile next"></div></div>
  <div class="panel" id="still"><div class="pin"></div></div>
  <div class="panel" id="moved"><div class="pin"></div></div>
  <div class="spacer"></div>
  <script>
    // The tile after each first tile: where does it start? scale draws the first tile bigger; zoom lays it out bigger.
    for (const row of document.querySelectorAll('.row')) {
      const first = row.firstElementChild;
      const next = row.querySelector('.next');
      const box = first.getBoundingClientRect();
      console.log(`${first.id}: drawn ${box.width} × ${box.height}px; the next tile starts at x ${next.getBoundingClientRect().left}px`);
    }
    // A position: fixed child normally stays put when the page scrolls. Inside a transformed parent it scrolls away.
    scrollTo(0, 150);
    for (const panel of document.querySelectorAll('.panel')) {
      const pin = panel.querySelector('.pin');
      console.log(`fixed child in the ${panel.id} panel, page scrolled 150px: top ${pin.getBoundingClientRect().top}px`);
    }
  </script>
</body>
</html>
The first tile has Drawn size The next tile starts at
nothing 50 × 50px x 66px
scale: 2 100 × 100px x 66px, now overlapped
zoom: 2 100 × 100px x 116px, pushed along

zoom is Baseline newly available. Use it when the layout should grow, such as a magnified preview inside a document, and transforms when it shouldn’t, such as a hover effect.

Transforms do change two things, and both catch people out. Any value other than none, even translate: 0, makes the element a stacking context, so z-index inside it no longer competes with the rest of the page. It also makes the element the containing block for fixed-position descendants, so a position: fixed child stops being fixed to the screen. In the page above, after scrolling 150px, a fixed child of a plain panel stayed at the top of the window (top 0px), while the same child in a panel with translate: 0 0 had scrolled away with its parent (top 190px), in all three engines. A modal or a sticky toolbar written as position: fixed breaks the moment an animation library puts a transform on one of its ancestors.

3D: perspective, preserve-3d and backface-visibility

3D transforms are Baseline widely available. Three properties turn a flat page into a scene:

  • perspective on a parent sets the distance between the viewer and the page. A smaller value means stronger depth: an element 100px closer than the page is drawn 600 ÷ (600 − 100) = 1.2 times larger with perspective: 600px. perspective-origin moves the point the viewer looks from.
  • transform-style: preserve-3d lets an element’s children keep their own positions in depth, so they can be arranged in front of and behind each other inside it. The default, flat, draws the children flat into their parent, like a picture on a card.
  • backface-visibility: hidden stops an element from being drawn when its back faces the viewer, which is how a card shows a different face on each side.

preserve-3d is fragile. Some properties need the element and its children to be drawn together as one flat group, and the specification lists them: overflow other than visible or clip, opacity below 1, any filter, clip-path, mask-image, mix-blend-mode, isolation: isolate and paint containment. One of them on the same element silently flattens it, although getComputedStyle() still says preserve-3d.

A layer 100px closer to the viewer, with and without flattening HTML · flatten.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>When preserve-3d is flattened</title>
  <style>
    body { margin: 0; }
    .scene { perspective: 600px; inline-size: 300px; block-size: 200px; margin: 20px; }
    .stage { transform-style: preserve-3d; inline-size: 300px; block-size: 200px; }
    .layer { inline-size: 100px; block-size: 100px; margin: 50px 100px; background: #9333ea; translate: 0 0 100px; }
    #clipped .stage { overflow: hidden; }
    #faded .stage { opacity: 0.99; }
    #clip-ok .stage { overflow: clip; }
  </style>
</head>
<body>
  <div class="scene" id="preserved"><div class="stage"><div class="layer"></div></div></div>
  <div class="scene" id="clipped"><div class="stage"><div class="layer"></div></div></div>
  <div class="scene" id="faded"><div class="stage"><div class="layer"></div></div></div>
  <div class="scene" id="clip-ok"><div class="stage"><div class="layer"></div></div></div>
  <script>
    // The layer is 100px wide and 100px closer to the viewer. Inside a 3D scene with perspective: 600px it should be
    // drawn 600 ÷ (600 − 100) = 1.2 times as wide. A flattened stage draws it flat instead.
    for (const scene of document.querySelectorAll('.scene')) {
      const layer = scene.querySelector('.layer');
      const used = getComputedStyle(scene.querySelector('.stage')).transformStyle;
      console.log(`${scene.id}: the layer is drawn ${layer.getBoundingClientRect().width.toFixed(1)}px wide (transform-style computes to ${used})`);
    }
  </script>
</body>
</html>
The stage has Layer drawn
preserve-3d only 120.0px wide, in depth
overflow: hidden too 100.0px wide, flat
opacity: 0.99 too 100.0px wide, flat
overflow: clip too 100.0px wide, flat

The first three rows are what the specification predicts. The last is not: CSS Transforms Level 2 exempts overflow: clip, yet all three engines flattened the stage anyway, so don’t count on that exception. When a 3D effect suddenly looks flat, look for one of these properties on the element with preserve-3d, often added for an unrelated reason, such as overflow: hidden to round the corners.

A card flip that works for everyone

The flip puts two faces in the same grid cell, turns the back one away with rotate: y 180deg, hides whichever face points away, and turns the whole card on hover, on keyboard focus inside it and from a real button for touch screens:

A flip card with a reduced-motion version CSS · flip-card.css
/* A card that turns over on hover, on keyboard focus and from its button, and swaps at once for reduced motion. */
.flip-card {
  perspective: 800px;               /* the viewer's distance from the card: closer means stronger depth */
}

.flip-card-inner {
  display: grid;
  transform-style: preserve-3d;     /* the faces keep their own 3D positions inside the turning card */
  transition: rotate 500ms ease-in-out;
}

.flip-card-face {
  grid-area: 1 / 1;                 /* both faces in the same cell, one on top of the other */
  backface-visibility: hidden;      /* a face turned away from the viewer is not drawn */
}

.flip-card-back {
  rotate: y 180deg;                 /* starts turned away, so it faces the viewer once the card has turned */
}

.flip-card:hover .flip-card-inner,
.flip-card:focus-within .flip-card-inner,
.flip-card.is-flipped .flip-card-inner {
  rotate: y 180deg;
}

@media (prefers-reduced-motion: reduce) {
  .flip-card-inner {
    transition: none;               /* the faces swap at once; nothing turns */
  }
}
The card, with its button and a check of which face shows HTML · flip-card.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A flip card, measured</title>
  <link rel="stylesheet" href="flip-card.css">
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .flip-card { inline-size: 240px; margin: 24px; }
    .flip-card-face { padding: 16px; min-block-size: 120px; background: #ecfeff; }
    .flip-card-back { background: #164e63; color: #fff; }
  </style>
</head>
<body>
  <div class="flip-card" id="card">
    <div class="flip-card-inner">
      <div class="flip-card-face flip-card-front">
        <h2>Plan A</h2>
        <button type="button" aria-pressed="false">Show what is included</button>
      </div>
      <div class="flip-card-face flip-card-back">
        <p>Included: 3 projects, weekly backups and email support.</p>
      </div>
    </div>
  </div>
  <script>
    const card = document.getElementById('card');
    const inner = card.querySelector('.flip-card-inner');
    const button = card.querySelector('button');
    button.addEventListener('click', () => {
      const flipped = card.classList.toggle('is-flipped');
      button.setAttribute('aria-pressed', String(flipped));
    });
    // Which face is under the middle of the card? backface-visibility: hidden takes a turned-away face out of the
    // hit test too, so this shows which face people can see and click.
    const faceAtCentre = () => {
      const r = card.getBoundingClientRect();
      const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height - 10);
      return hit?.closest('.flip-card-face')?.classList.contains('flip-card-back') ? 'back' : 'front';
    };
    const settle = () => { for (const a of inner.getAnimations()) a.finish(); };
    const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'reduced motion' : 'full motion';
    console.log(`${reduce}, at rest: rotate ${getComputedStyle(inner).rotate}, ${faceAtCentre()} face shows`);
    button.click();
    const running = inner.getAnimations().length;
    settle();
    console.log(`${reduce}, after the button: rotate ${getComputedStyle(inner).rotate}, ${faceAtCentre()} face shows, ` +
      `${running ? 'it turned' : 'it swapped at once'}, aria-pressed ${button.getAttribute('aria-pressed')}`);
    const faces = [...card.querySelectorAll('.flip-card-face')].map((f) => getComputedStyle(f).backfaceVisibility);
    console.log(`backface-visibility of the two faces: ${faces.join(', ')}`);
  </script>
</body>
</html>

The page asks the browser which face is under the middle of the card, the face a person could see and click. In all three engines the front was there at rest and the back after the button was pressed, with rotate computed as y 180deg and aria-pressed updated to true; a turned-away face with backface-visibility: hidden is left out of hit testing as well as drawing. Under Playwright’s reduced-motion emulation the same press started no animation: the faces swapped at once. Keep the back’s content meaningful on its own, because screen readers read both faces in source order whichever one is showing, and never put the only copy of important information behind a hover.

HTML, CSS & JS Playground (Online Editor) Paste flip-card.css and the card's HTML, then change perspective from 800px to 300px to see the depth grow.

Practise: transform a box

Exercise · Medium · JavaScript

Work out where a transformed box is drawn

Browsers draw a transformed box by moving each of its corners, then take the smallest rectangle around them for getBoundingClientRect(). Write three functions in transform.mjs and export them. A transform function is an array: ['translate', x, y] (y defaults to 0), ['rotate', degrees] (clockwise on screen, because y grows downwards) or ['scale', sx, sy] (sy defaults to sx).

transformPoint([x, y], functions, origin = [0, 0]) returns where the point is drawn. Work relative to the origin: subtract it, apply the functions from the last one to the first, then add the origin back. Applying from the right is what makes rotate(45deg) translateX(100px) move the box along a 45° line: the translation happens inside the rotated coordinate system. Rotating [x, y] by an angle a gives [x × cos a − y × sin a, x × sin a + y × cos a].

individualOrder({ translate, rotate, scale, transform = [] }) returns the single list of functions that the individual properties stand for. Each property is optional: translate is [x, y], rotate a number of degrees and scale [sx, sy]. CSS always applies them in the same order, translate, then rotate, then scale, then the functions of transform, whatever order you write them in.

boundingBox({ x, y, width, height }, functions, origin) returns { left, top, right, bottom } of the rectangle around the four transformed corners. The origin defaults to the box's centre, like transform-origin: 50% 50%.

The sample tests import the three functions from transform.mjs and run in your browser. Their expected boxes were measured with getBoundingClientRect() in three browser engines.

Starter code · transform.mjs

/** Where the point [x, y] is drawn after the transform functions, around the origin. */
export function transformPoint([x, y], functions, origin = [0, 0]) {
  // Replace this line with your code.
  return [x, y];
}

/** The transform functions that translate, rotate, scale and transform stand for, in the order CSS applies them. */
export function individualOrder({ translate, rotate, scale, transform = [] }) {
  // Replace this line with your code.
  return transform;
}

/** The rectangle around the box after the transform functions; the origin defaults to the box's centre. */
export function boundingBox({ x, y, width, height }, functions, origin) {
  // Replace this line with your code.
  return { left: x, top: y, right: x + width, bottom: y + height };
}
The sample tests · transform.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { transformPoint, individualOrder, boundingBox } from './transform.mjs';

const r = (v) => Math.round(v * 10) / 10 + 0;           // one decimal, and no -0
const rp = (p) => p.map(r);
const rb = (b) => ({ left: r(b.left), top: r(b.top), right: r(b.right), bottom: r(b.bottom) });
const box = { x: 200, y: 200, width: 100, height: 50 };
const centre = [250, 225];

test('the same two functions in two orders', () => {
  assert.deepEqual(rp(transformPoint(centre, [['rotate', 45], ['translate', 100]], centre)), [320.7, 295.7]);
  assert.deepEqual(rp(transformPoint(centre, [['translate', 100], ['rotate', 45]], centre)), [350, 225]);
});

test('rotation and scale around the origin', () => {
  assert.deepEqual(rp(transformPoint([200, 200], [['rotate', 90]], centre)), [275, 175]);
  assert.deepEqual(rp(transformPoint([200, 200], [['rotate', 90]], [200, 200])), [200, 200]);
  assert.deepEqual(rp(transformPoint([300, 250], [['scale', 2]], centre)), [350, 275]);
  assert.deepEqual(rp(transformPoint([300, 250], [['scale', 2, 1]], centre)), [350, 250]);
  assert.deepEqual(rp(transformPoint([10, 20], [['translate', 5, -5]])), [15, 15]);
});

test('the individual properties always apply translate, rotate, scale, then transform', () => {
  assert.deepEqual(individualOrder({ rotate: 45, translate: [100, 0] }), [['translate', 100, 0], ['rotate', 45]]);
  assert.deepEqual(individualOrder({ scale: [2, 2], rotate: 30, translate: [20, 10] }),
    [['translate', 20, 10], ['rotate', 30], ['scale', 2, 2]]);
  assert.deepEqual(individualOrder({ rotate: 45, transform: [['translate', 100, 0]] }), [['rotate', 45], ['translate', 100, 0]]);
  assert.deepEqual(individualOrder({}), []);
});

test('bounding boxes measured in three engines', () => {
  assert.deepEqual(rb(boundingBox(box, [['rotate', 45], ['translate', 100]])), { left: 267.7, top: 242.7, right: 373.7, bottom: 348.7 });
  assert.deepEqual(rb(boundingBox(box, [['translate', 100], ['rotate', 45]])), { left: 297, top: 172, right: 403, bottom: 278 });
  assert.deepEqual(rb(boundingBox(box, [['rotate', 90]], [200, 200])), { left: 150, top: 200, right: 200, bottom: 300 });
});

test('individual properties against the same functions in transform', () => {
  const individual = individualOrder({ scale: [2, 2], rotate: 30, translate: [20, 10] });
  assert.deepEqual(rb(boundingBox(box, individual)), { left: 158.4, top: 141.7, right: 381.6, bottom: 328.3 });
  assert.deepEqual(rb(boundingBox(box, [['scale', 2], ['rotate', 30], ['translate', 20, 10]])), { left: 163, top: 169, right: 386.2, bottom: 355.6 });
});
A hint

In transformPoint, loop over the functions with an index that counts down, from functions.length - 1 to 0, and update [x, y] at each step. boundingBox can then call transformPoint on the four corners and take the smallest and largest x and y.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 An element has scale: 2; rotate: 30deg; translate: 20px 10px;. Which transform list draws it the same way?

    Choose one answer.

    Show the answer to question 1

    Answer: translate(20px, 10px) rotate(30deg) scale(2)

    The individual properties always apply in the order translate, rotate, scale, whatever order they are written in, and the transform list comes after them. Measured on a 100 × 50 box, the individual properties gave the same bounding box as the translate-first list, and scale(2) rotate(30deg) translate(20px, 10px) a different one.

  2. Question 2 of 6 A 50px tile in a flex row gets scale: 2. What happens to the tile after it?

    Choose one answer.

    Show the answer to question 2

    Answer: It stays where it was, and the scaled tile overlaps it

    Transforms are applied after layout, so the row is laid out as if the tile were still 50px wide. In the lesson's measurement the next tile started at x 66px with or without scale: 2; only zoom: 2 pushed it to x 116px.

  3. Question 3 of 6 A position: fixed toolbar is inside a wrapper that an animation library gives transform: translateY(0). What happens when the page scrolls?

    Choose one answer.

    Show the answer to question 3

    Answer: The toolbar scrolls away with the wrapper

    Any transform other than none, even one that moves nothing, makes the element the containing block of its fixed-position descendants. The toolbar is then fixed relative to the wrapper and scrolls with it, as the lesson's fixed child did: top 190px instead of 0px after a 150px scroll.

  4. Question 4 of 6 A parent has perspective: 800px, and a child 200px wide is moved 200px towards the viewer with translate: 0 0 200px. How wide is the child drawn?

    Type a number.

    Show the answer to question 4

    Answer: 266.7 px (anything from 266.2 to 267.2 counts)

    An element moved z pixels closer is drawn perspective ÷ (perspective − z) times larger: 800 ÷ (800 − 200) = 1.333, so the 200px child is drawn about 266.7px wide. With perspective: 600px and 100px, the lesson measured exactly 1.2 times.

  5. Question 5 of 6 An element with transform-style: preserve-3d suddenly draws its children flat. Which of these, added to that element, can cause it?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • opacity: 0.9
    • filter: drop-shadow(0 4px 8px black)
    • overflow: hidden

    These are grouping properties: the element and its children must be drawn together as one flat image before they can be clipped, faded or filtered, so the used value of transform-style becomes flat. Colour and padding have no such effect. getComputedStyle() still reports preserve-3d, which makes the cause hard to spot.

  6. Question 6 of 6 A card flips on hover to show a price on its back. What is the most important fix for people on touch screens and keyboards?

    Choose one answer.

    Show the answer to question 6

    Answer: Flip it from a real button and on focus inside the card, not only on hover

    Touch screens have no hover and keyboard users never hover, so the back face must be reachable another way: a button that toggles a class with aria-pressed, and :focus-within for keyboard focus. The lesson's card does both, and swaps the faces at once under reduced motion.

Interview questions

Fresher · What is the difference between transform: translateX(100px) and left: 100px on a positioned element?

Both move the element 100px to the right on screen, but at different stages. left is part of layout: it changes where the element’s box is placed, so the browser must lay out again whenever it changes. translate and the other transforms happen after layout: the box keeps its place, the element is drawn moved, and nothing around it reflows, which is why moving it with a transform is cheaper to animate. Percentages differ as well: in left they refer to the containing block’s width, in translateX() to the element’s own width. A transform also makes the element a stacking context and the containing block of its fixed-position descendants, which left does not. getBoundingClientRect() reports the moved position for both, while offsetLeft only changes with left.

Key takeaways

  • translate, rotate and scale are separate properties that combine freely; CSS applies them in that fixed order, then the transform list.
  • In transform, order matters: rotate(45deg) translateX(100px) and translateX(100px) rotate(45deg) put the same box with centres 29.3 px apart horizontally and 70.7 px apart vertically.
  • Rotation and scale happen around transform-origin, the centre by default.
  • Transforms never move neighbours; zoom does. Any transform creates a stacking context and captures position: fixed descendants.
  • perspective, preserve-3d and backface-visibility are enough for a card flip; overflow, opacity below 1 and filters flatten preserve-3d, and so did overflow: clip in all three engines.
  • Flip on hover, on focus and from a button, and swap the faces at once for reduced motion.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.