CSS Transforms (2D and 3D)
translate, rotate, scale, skew, and 3D transforms with perspective — how modern interfaces move without touching layout.
transform — Moving, Rotating, and Resizing Without Touching Layout
transform is a single CSS property that lets you translate (move), rotate, scale (resize), and skew an element — and, critically, it does all of this in a way that never affects the position or size of any other element on the page. This is the property's entire reason for existing: it operates on the element's already-computed box, as a purely visual overlay, rather than asking the browser to recalculate where anything belongs.
.box {
transform: translateX(40px); /* move right 40px */
transform: translateY(-20px); /* move up 20px */
transform: rotate(15deg); /* rotate clockwise 15 degrees */
transform: scale(1.2); /* grow to 120% of original size */
transform: skewX(10deg); /* slant along the x-axis */
}Every one of these values describes a change relative to the element's own box — not to the page, not to its parent's content flow. An element moved 200px to the right with translateX(200px) still occupies its original space as far as every other element on the page is concerned; siblings do not shift to fill the gap, and nothing reflows around the new visual position. You will come back to exactly why that matters for performance in Part 07 of this module.
transform: translateX(20px) rotate(10deg) scale(1.1); applies all three at once, in the order written. Order matters: transforms compose left to right, so translate then rotate produces a different result than rotate then translate, because each function operates on the coordinate space already modified by the ones before it.translate() — Moving an Element on the X and Y Axes
translate() shifts an element from its normal position. It accepts one or two values — a single value moves along the x-axis only, while two values move along x and then y. Dedicated translateX() and translateY() functions exist for when you only need to move along one axis and want that intent to be explicit in the code.
.a { transform: translate(50px); } /* 50px right, 0px down */
.b { transform: translate(50px, 20px); } /* 50px right, 20px down */
.c { transform: translateX(-30px); } /* 30px left */
.d { transform: translateY(100%); } /* down by 100% of the element's OWN height */Percentage values in translate are resolved against the element's own box dimensions, not its parent's — this is different from how percentages behave almost everywhere else in CSS (width, padding, and top/left all resolve against the containing block). This detail unlocks a genuinely important pattern: perfectly centering an element of unknown size.
.modal {
position: absolute;
top: 50%;
left: 50%;
/* top/left: 50% positions the TOP-LEFT CORNER at the center of the parent */
transform: translate(-50%, -50%);
/* translate(-50%, -50%) then shifts the box back by HALF ITS OWN WIDTH/HEIGHT,
which centers it exactly — even if you never knew its size in advance */
}Before Flexbox and Grid made centering trivial with justify-content and align-items, this top: 50%; left: 50%; transform: translate(-50%, -50%); trick was the standard way to center an absolutely positioned element without knowing its dimensions up front, and it still shows up constantly in real production code — particularly for modals, tooltips, and dropdown menus layered with position: absolute or position: fixed.
rotate(), scale(), and skew() — The Rest of the 2D Toolkit
rotate() turns an element around a fixed point (by default, its exact center), measured in degrees — positive values rotate clockwise, negative values counter-clockwise.
.card:hover {
transform: rotate(3deg); /* a subtle tilt on hover, common on card UIs */
}
.spinner {
transform: rotate(180deg); /* a half-turn, often paired with a transition or animation */
}scale() resizes an element by a multiplier, not a fixed pixel amount — 1 means "no change," values above 1 grow the element, and values between 0 and 1 shrink it. A single value scales both axes equally; two values scale x and y independently.
.button:hover { transform: scale(1.05); } /* grow to 105% on hover — a very common micro-interaction */
.thumbnail:hover { transform: scale(1.15); } /* a stronger "zoom in" effect on image hover */
.dismissed { transform: scale(0); /* shrink to nothing — often paired with opacity: 0 */ }
.stretched { transform: scale(2, 0.5); /* double the width, halve the height */ }skew() slants an element along one or both axes, distorting its rectangular shape into a parallelogram. It is used far less often than the other three in production UI, but it shows up in decorative section dividers, ticket-stub shapes, and some brand-heavy marketing pages.
.ribbon {
transform: skewX(-15deg); /* slants the box along the x-axis */
}
.diagonal-divider {
transform: skewY(-3deg); /* a subtle diagonal section break, common in landing pages */
}transform-origin — Changing the Point Everything Pivots Around
Every transform function operates relative to a pivot point — by default, the exact center of the element (50% 50%). transform-origin lets you move that pivot point anywhere, which changes the visual result of rotate() and scale() dramatically, even though the transform function itself is unchanged.
.default-rotate {
transform: rotate(45deg);
/* pivots around the CENTER by default — the box spins in place */
}
.corner-rotate {
transform-origin: top left;
transform: rotate(45deg);
/* pivots around the TOP-LEFT CORNER instead — the box swings out and away,
like a door hinged at that corner */
}
.custom-point {
transform-origin: 20px 80%;
transform: rotate(45deg);
/* pivots around a specific point: 20px from the left, 80% down from the top */
}transform-origin accepts keywords (top, bottom, left, right, center), percentages, or length values, and can take one, two, or (for 3D transforms, covered in Part 05) three values for x, y, and z. A common real use case: a hinged "flip open" card effect, where the pivot needs to sit at one edge rather than the center for the flip to look physically plausible.
.panel {
transform-origin: left center;
transition: transform 0.3s ease;
}
.panel.open {
transform: rotateY(0deg);
}
.panel.closed {
transform: rotateY(-90deg);
/* with the origin at the left edge, this reads as the panel swinging shut
like a physical door, hinged on its left side — not spinning around its center */
}rotateX, rotateY, rotateZ, translateZ — Adding a Third Dimension
Every transform covered so far operates on a flat, two-dimensional plane — the x and y axes of the screen. CSS also defines a set of 3D transform functions that introduce a third axis, z, which points directly out of (and into) the screen toward the viewer.
.a { transform: rotateX(45deg); } /* tips the TOP edge toward or away from you — like nodding "yes" */
.b { transform: rotateY(45deg); } /* turns the LEFT/RIGHT edge toward or away from you — like shaking "no" */
.c { transform: rotateZ(45deg); } /* identical to plain rotate() — spins flat, around the z-axis */
.d { transform: translateZ(50px); } /* moves the element TOWARD the viewer, out of the screen */rotateZ() is worth calling out specifically: it produces the exact same visual result as the 2D rotate() function, because rotating "around the z-axis" is precisely what a flat, on-screen rotation already is. rotate() is simply shorthand for rotateZ(). rotateX() and rotateY(), by contrast, tip the element into the third dimension — and by themselves, without the properties in Part 06, they render as a flat squash rather than a convincing 3D tilt, because the browser has no concept yet of how far away the "camera" is.
.card {
transform: rotateY(25deg) translateZ(30px) scale(1.05);
/* multiple 2D and 3D functions can be combined in one transform declaration,
applied in the order written, exactly like the 2D-only examples earlier */
}perspective and perspective-origin — Giving 3D Transforms Actual Depth
A screen is fundamentally flat, so for rotateX() and rotateY() to look like they are genuinely tilting into three-dimensional space rather than just squashing flat, the browser needs to know how far away the imaginary viewer is standing. That distance is what the perspective property controls.
.scene {
perspective: 800px;
/* 800px = the distance from the viewer to the z=0 plane.
Smaller values (e.g. 300px) = closer viewer = more extreme, dramatic 3D distortion.
Larger values (e.g. 2000px) = farther viewer = subtler, more realistic 3D depth. */
}
.card {
transform: rotateY(35deg);
/* NOW this genuinely looks like a card tilting away in 3D space,
because .scene (its parent) established a perspective for it to tilt within */
}perspective is set on the parent of the element being transformed, not on the transformed element itself — it establishes a 3D viewing context that every 3D-transformed child shares, which matters for scenes with multiple elements that need to look like they belong in the same consistent 3D space (a classic card-flip, for instance, where the front and back faces both need to obey the same perspective).
.card {
transform: perspective(800px) rotateY(35deg);
/* applies perspective to just THIS element's transform, rather than the parent.
Equivalent result for a single element, but does not share a consistent
3D space with sibling elements the way the parent-property form does. */
}perspective-origin works alongside perspective the same way transform-origin works alongside transform — it moves the vanishing point (where the viewer is imagined to be looking from) away from the default center, which changes how the 3D depth appears to skew across the scene.
<div class="flip-card">
<div class="flip-card-inner">
<div class="flip-card-front">Front</div>
<div class="flip-card-back">Back</div>
</div>
</div>.flip-card {
perspective: 1000px; /* establishes the 3D scene on the outer wrapper */
}
.flip-card-inner {
position: relative;
transform-style: preserve-3d; /* children keep their own 3D positions, instead of being flattened */
transition: transform 0.6s;
}
.flip-card:hover .flip-card-inner {
transform: rotateY(180deg);
}
.flip-card-front, .flip-card-back {
position: absolute;
inset: 0;
backface-visibility: hidden; /* hides a face when it has rotated to face away from the viewer */
}
.flip-card-back {
transform: rotateY(180deg); /* pre-rotated 180deg, so it faces the viewer once the parent flips */
}transform-style: preserve-3d and backface-visibility: hidden are the two properties that consistently trip people up the first time they build a 3D flip effect — without preserve-3d, the browser flattens child elements back into 2D and the effect silently stops looking three-dimensional; without backface-visibility: hidden, both faces of the card stay visible at once, showing through each other during the flip.Why transform Never Triggers Layout — The Same Idea From the Transitions Module
The CSS Transitions module (Module 31) introduced the idea that some CSS properties are cheap to animate and others are expensive, because of what the browser has to redo every time the property's value changes across a frame. This module is the concrete payoff of that idea: transform is, alongside opacity, one of the two properties every performance-conscious front-end engineer reaches for first, precisely because animating it never triggers layout.
Recall the three stages a browser runs through to put pixels on screen: layout (compute the size and position of every box on the page), paint (fill in pixels — color, text, shadows, borders — for each box), and composite (combine the painted layers into the final image shown on screen). Changing a property like width, top, or margin-left forces the browser back to the very first stage — it does not know the new size or position of the box without recalculating layout for that element and, in many cases, everything around it too.
.box {
position: relative;
left: 0;
transition: left 0.3s ease;
}
.box:hover {
left: 200px;
/* changing "left" changes the box's computed POSITION — the browser must
re-run layout to know where this box (and potentially its siblings) now sit */
}.box {
transform: translateX(0);
transition: transform 0.3s ease;
}
.box:hover {
transform: translateX(200px);
/* the box's LAYOUT POSITION never changes — as far as layout is concerned,
this box never moved. The GPU simply composites the already-painted box
at a shifted position on screen, skipping layout and paint entirely */
}Because transform (and opacity) operate purely at the composite stage, the browser can hand the actual animation work off to the GPU, which is built specifically for exactly this kind of "take an already-rendered layer and move/scale/fade it" operation. That is why transform-based animations stay smooth even on lower-end devices, while animating width, top, or margin can visibly stutter — the CPU is redoing layout and paint on every single frame, sixty times a second, for the entire duration of the animation.
transform and opacity for anything that needs to animate smoothly, and treating properties like width, height, top/left, and margin as "expensive" animation targets to avoid where a transform-based alternative exists. Scaling a box with transform: scale() instead of animating its width/height, and moving a box with transform: translate() instead of animating top/left, are the two substitutions that come up constantly in real performance-focused code review.A Janky Product Carousel at an Austin E-Commerce Startup
An engineer at an Austin-based e-commerce company ships a product image carousel for the homepage — a row of cards that slides horizontally when the user clicks the arrow buttons. On the engineer's own high-end laptop, it looks smooth. Within a day of shipping, support tickets start coming in describing the carousel as "jumpy" and "laggy," almost exclusively from users on mid-range Android phones.
.carousel-track {
position: relative;
left: 0;
transition: left 0.4s ease;
}
.carousel-track.slide-1 { left: -320px; }
.carousel-track.slide-2 { left: -640px; }
.carousel-track.slide-3 { left: -960px; }What the engineer finds in Chrome DevTools
Opening the Performance panel and recording a slide transition shows a wall of purple (layout) and green (paint) bars on every single frame of the animation, and the frame rate drops well below 60fps on throttled mid-tier hardware. The cause is exactly the pattern from Part 07: animating left forces the browser to recompute layout for the entire carousel track — and, because the track contains several image cards, the paint work for each of them — on every frame of a 400ms transition, sixty times a second.
.carousel-track {
transform: translateX(0);
transition: transform 0.4s ease;
will-change: transform;
}
.carousel-track.slide-1 { transform: translateX(-320px); }
.carousel-track.slide-2 { transform: translateX(-640px); }
.carousel-track.slide-3 { transform: translateX(-960px); }With the change deployed, the same DevTools recording shows almost entirely teal (composite) bars — layout and paint barely appear at all, because the browser now just hands the already painted track layer to the GPU and slides it. The carousel holds a steady 60fps on the same throttled test device that previously dropped to the low teens. Nothing about the visual design changed — the entire fix was recognising that left was the wrong property to animate, and transform: translateX() was the cheap equivalent that accomplishes the identical visual movement.
Four Misconceptions About CSS Transforms
5 Interview Questions — With Complete Answers
Transform Mistakes Engineers Make Constantly
.tooltip {
position: absolute;
top: 50%;
left: 50%;
/* forgot the transform entirely */
}.tooltip {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* top/left: 50% alone only aligns the TOP-LEFT CORNER to the center —
without the translate, the box is centered incorrectly, shifted
down and to the right by half its own size */
}.card {
transform: translateX(20px);
transform: rotate(10deg);
/* the SECOND transform declaration silently overwrites the first —
only rotate(10deg) applies; the translateX is gone */
}.card {
transform: translateX(20px) rotate(10deg);
/* multiple transform functions must be combined in ONE declaration,
space-separated — transform is not additive across separate rules
the way some other properties are */
}.panel {
transform: rotateY(60deg);
/* set directly on the element, with no perspective anywhere —
renders as a flat horizontal squash, not a 3D tilt */
}.scene {
perspective: 800px;
}
.panel {
transform: rotateY(60deg);
/* now a genuine 3D tilt, because .scene (the parent) established
a viewing distance for the rotation to happen within */
}.box:hover {
width: 220px;
height: 220px;
transition: width 0.3s, height 0.3s;
/* animating width/height forces layout recalculation on every frame */
}.box:hover {
transform: scale(1.1);
transition: transform 0.3s;
/* achieves a nearly identical visual "grow" effect at compositor
cost instead of layout cost */
}Errors and Rendering Bugs You Will Hit With Transforms — And Exactly Why
🎯 Key Takeaways
- ✓transform moves, rotates, scales, and skews an element purely visually — it never affects the layout position or size of any other element on the page.
- ✓translate() percentages resolve against the element's own dimensions, not its parent's — the basis of the classic top: 50%; left: 50%; transform: translate(-50%, -50%); centering trick.
- ✓transform-origin sets the pivot point (default: center) that rotate() and scale() operate around — changing it changes the visual result of those functions dramatically.
- ✓rotateX/rotateY/rotateZ add a third axis; rotateZ() is identical to plain rotate(). rotateX/rotateY need a perspective value somewhere in the ancestor chain to render with real visible depth.
- ✓perspective is normally set on the parent of the transformed element, establishing a shared 3D viewing context; perspective() as a function inside transform applies it to a single element only.
- ✓transform never triggers layout — the same cheap-vs-expensive-properties principle from the Transitions module (Module 31). Combined with opacity, it is the standard choice for smooth, GPU-composited animation.
- ✓Combine multiple transform functions into a single space-separated declaration — writing separate transform rules causes the later one to silently overwrite the earlier one entirely.
- ✓Building a real 3D flip effect requires transform-style: preserve-3d on the parent and backface-visibility: hidden on each face, not perspective and rotateY alone.
What comes next
Module 34 covers the newest selectors that changed how CSS is written — :has() as the long-awaited native parent selector, :is()/:where() for simplifying repetitive selector lists, and container queries for genuinely component-based responsive design.
Module 34 → Modern Selectors — :has, :is, :where, Container QueriesDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.