Transforms
🔄 Chapter 9 — Transforms
CSS transforms move, rotate, resize, and distort elements visually — without affecting layout flow. A translated element still occupies its original space in the document; nothing around it shifts. This makes transforms the right tool for hover effects, entrance animations, card flips, parallax, and any visual motion where you don't want to disturb surrounding content.
1 — What Transforms Do
The transform property applies one or more transform functions to an element. These functions change how the element is painted to the screen — but the element still occupies its original position in the layout flow. Think of it as moving a sticker on top of an already-laid-out page.
margin-left: 50px shifts an element and pushes its siblings. Setting transform: translateX(50px) shifts the element visually but leaves a "ghost" in its original position — siblings and parent containers are completely unaffected. This is why transforms are used for animation: changing transform never triggers layout recalculation.
/* Single function */
.card { transform: rotate(45deg); }
/* Multiple functions (applied right-to-left — see Section 6) */
.card { transform: translateY(-10px) scale(1.05) rotate(2deg); }
/* Remove a transform */
.card { transform: none; }
/* The transform creates a new stacking context — the element
becomes a containing block for position:fixed children,
and is composited independently by the GPU. */
2 — translate()
translate(x, y) moves an element along the X and Y axes. Both arguments accept any CSS length or percentage — percentages are relative to the element's own size, not the parent's, which enables the classic centering trick.
/* Two-axis shorthand */
.box { transform: translate(100px, -50px); } /* right 100px, up 50px */
/* Single-axis helpers */
.box { transform: translateX(2rem); }
.box { transform: translateY(-1em); }
.box { transform: translateZ(50px); } /* Z-axis — needs perspective */
/* Percentage = % of the element's own size */
.tooltip { transform: translateX(-50%); } /* shift left by half its own width */
/* The centering trick — no need to know the element's size */
.centered {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* top/left move the top-left corner to centre, */
/* translate(-50%,-50%) then shifts back by half its own size */
}
3 — rotate()
rotate(angle) spins an element clockwise for positive angles and counter-clockwise for negative ones. It accepts deg, rad, grad, and turn units.
/* Angle units — all equivalent to 90° clockwise */
rotate(90deg) /* degrees */
rotate(1.5708rad) /* radians */
rotate(0.25turn) /* turns (0–1) */
rotate(100grad) /* gradians (0–400)*/
/* 3D rotation variants (require perspective for visual depth) */
rotateX(45deg) /* tilt towards/away from viewer — horizontal axis */
rotateY(45deg) /* spin left/right — vertical axis */
rotateZ(45deg) /* same as 2D rotate() */
/* Hover rotation effect */
.icon { transition: transform 0.3s ease; }
.icon:hover { transform: rotate(180deg); }
/* Collapsed/expanded indicator (common accordion arrow) */
.arrow { transform: rotate(0deg); transition: transform 0.25s; }
.open .arrow { transform: rotate(90deg); }
4 — scale() and skew()
/* ── scale() ── */
/* Values: 1 = no change, 2 = double size, 0.5 = half size */
scale(1.5) /* uniform — same as scale(1.5, 1.5) */
scale(2, 0.5) /* double width, half height */
scaleX(-1) /* flip horizontally (mirror) */
scaleY(-1) /* flip vertically (upside down) */
/* Pulsing button effect */
.btn:active { transform: scale(0.95); }
/* ── skew() ── */
/* Shears/distorts — positive X-angle tilts right, positive Y tilts down */
skew(20deg) /* X-axis skew only */
skew(15deg, 5deg) /* X and Y axis */
skewX(-30deg) /* single axis */
/* Diagonal banner / parallelogram button */
.banner {
transform: skewX(-15deg);
}
.banner-inner {
transform: skewX(15deg); /* un-skew the text inside */
}
5 — transform-origin
transform-origin sets the pivot point around which transforms are applied. The default is 50% 50% — the centre of the element. Change it to rotate around a corner, an edge, or even a point outside the element entirely.
/* Keywords */
transform-origin: center; /* default — 50% 50% */
transform-origin: top left; /* 0% 0% */
transform-origin: bottom right; /* 100% 100% */
/* Lengths or percentages */
transform-origin: 0 0; /* top-left corner */
transform-origin: 100% 0; /* top-right corner */
transform-origin: -20px 50%; /* 20px left of the element */
transform-origin: 150% 50%; /* point to the right — orbiting effect */
/* Three values include the Z-axis (for 3D transforms) */
transform-origin: 50% 50% 100px;
/* Hinge effect — door swinging from its left edge */
.door {
transform-origin: left center;
transform: rotateY(0deg);
transition: transform 0.5s ease;
}
.door.open { transform: rotateY(-80deg); }
6 — Combining Transforms — Order Matters
When multiple transform functions appear in one transform declaration, they are applied right to left. This mirrors matrix multiplication, and the order dramatically changes the result — because each function operates in the coordinate system established by all previous transforms.
/* Box A — translate right, then rotate */
.a { transform: translateX(50px) rotate(45deg); }
/* Applied order: rotate(45°) THEN translateX(50px)
→ translation is along the ROTATED x-axis (diagonal direction) */
/* Box B — rotate, then translate */
.b { transform: rotate(45deg) translateX(50px); }
/* Applied order: translateX(50px) THEN rotate(45°)
→ translation happens along original x-axis (straight right) */
/* Tip: write transforms in the ORDER you'd narrate them
("move it right, then tilt it") but remember the CSS
declaration reads right-to-left in application. */
/* Common combined hover effect */
.card:hover {
transform: translateY(-6px) scale(1.03) rotate(1deg);
}
7 — 3D Transforms
3D transforms add depth to layouts. To see 3D perspective, you need three things: a perspective value on the parent, transform-style: preserve-3d on the container element so children exist in a shared 3D space, and 3D transform functions on the children.
/* Perspective — how far the viewer is from the screen.
Smaller = more dramatic. Applied to the parent. */
.scene {
perspective: 600px; /* 300–800px = dramatic; 1200px+ = subtle */
perspective-origin: 50% 50%; /* viewpoint position (default) */
}
/* preserve-3d — children share the scene's 3D space */
.container {
transform-style: preserve-3d;
}
/* backface-visibility — hide an element when it faces away from viewer */
.card-face {
backface-visibility: hidden;
}
/* 3D transform functions */
rotateX(45deg) /* tilt towards/away */
rotateY(180deg) /* spin (card flip) */
translateZ(100px) /* move towards viewer */
translate3d(x, y, z) /* three-axis move in one function */
scale3d(sx, sy, sz) /* three-axis scale */
/* perspective() as a function (applied inline on the element) */
.item { transform: perspective(600px) rotateY(45deg); }
/* Use this when you want per-element perspective (no shared scene) */
The card flip pattern
/* HTML structure:
<div class="scene">
<div class="card">
<div class="card-face card-front">Front</div>
<div class="card-face card-back">Back</div>
</div>
</div> */
.scene {
perspective: 600px;
}
.card {
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s ease;
}
.card.flipped { transform: rotateY(180deg); }
.card-face {
position: absolute;
inset: 0;
backface-visibility: hidden; /* hide when facing away */
}
.card-back {
transform: rotateY(180deg); /* pre-rotated so it starts facing away */
}
8 — Transforms and Transitions
Transforms become motion when combined with transition. The browser smoothly interpolates the transform values between states. Chapter 10 (Keyframe Animations) covers complex multi-step motion — here we focus on the state-to-state pattern.
/* Hover lift + shadow */
.card {
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.card:hover {
transform: translateY(-6px) scale(1.02);
box-shadow: 0 12px 32px rgba(0,0,0,0.25);
}
/* Icon spin on hover */
.btn .icon { transition: transform 0.4s ease; }
.btn:hover .icon { transform: rotate(360deg); }
/* Press / click feedback */
.btn { transition: transform 0.1s ease; }
.btn:active { transform: scale(0.95); }
/* Slide-in navigation panel */
.nav { transform: translateX(-100%); transition: transform 0.35s ease; }
.nav.open { transform: translateX(0); }
/* Staggered entrance (JS adds .visible to each in sequence) */
.item { transform: translateY(20px); opacity: 0; transition: transform 0.4s, opacity 0.4s; }
.item.visible { transform: translateY(0); opacity: 1; }
9 — Performance: Why Transforms Are Fast
width, height, top, left, padding, margin…) forces a layout recalculation — the browser must re-measure every affected element before painting. Transforms skip layout and paint entirely; the compositor just moves an already-painted texture, which is essentially free.
.btn:hover {
width: 110%;
top: -4px;
left: -4px;
}
.btn:hover {
transform: scale(1.05);
transform: translateY(-4px);
}
/* Hint the browser to promote the element to its own compositor
layer BEFORE the animation starts — eliminates the initial
"jank" frame when the layer is first promoted. */
.animated-card {
will-change: transform, opacity;
}
/* ⚠️ Overuse is harmful: every will-change element uses GPU memory.
Add it only to elements you know will animate. Remove it after
animation ends via JavaScript if the animation is one-shot. */
/* The three-property GPU rule — animate ONLY these for max perf: */
/* transform (position, size, rotation) */
/* opacity (fade in/out) */
/* filter (blur, brightness — varies by browser/hardware) */
10 — Individual Transform Properties Modern CSS
Modern CSS provides translate, rotate, and scale as standalone properties. They apply on top of transform and let you animate each axis independently in @keyframes or transitions — something previously impossible because changing transform in two different rules would overwrite each other.
/* Old approach — hover and focus both set transform,
the second declaration overwrites the first completely */
.card:hover { transform: translateY(-8px); }
.card:focus { transform: scale(1.05); } /* wipes out translateY! */
/* New approach — individual properties compose independently */
.card { transition: translate 0.25s, scale 0.25s; }
.card:hover { translate: 0 -8px; } /* doesn't affect scale */
.card:focus { scale: 1.05; } /* composes with translate */
/* Hover + focus together: card lifts AND scales — no conflict */
/* Syntax differences from functions */
/* translate: x y (space-separated, not comma) */
/* rotate: angle (single value; or x y z angle) */
/* scale: x (or x y) */
translate: 50px 20px; /* = translateX(50px) translateY(20px) */
rotate: 45deg;
scale: 1.5; /* uniform */
scale: 1.5 1; /* X and Y */
/* Application order (always): translate → rotate → scale
Regardless of declaration order in CSS */
/* Browser support: Chrome 104+, Firefox 72+, Safari 14.1+ */
11 — Quick Reference
| Function | What it does | Units / notes |
|---|---|---|
translate(x, y) | Move element | Length or %; % = % of self |
translateX(x) / translateY(y) | Single-axis move | — |
translateZ(z) / translate3d(x,y,z) | Move along Z-axis | Needs perspective |
rotate(angle) | Spin clockwise (positive) | deg, rad, turn, grad |
rotateX(a) / rotateY(a) / rotateZ(a) | 3D rotation | Needs perspective |
scale(n) | Resize uniformly | Unitless ratio; 1 = no change |
scale(x, y) | Resize each axis | -1 = flip |
scaleX(n) / scaleY(n) | Single-axis scale | — |
skew(x, y) | Shear/distort | deg |
skewX(x) / skewY(y) | Single-axis skew | — |
| Property | Function | Notes |
|---|---|---|
transform-origin | Sets the pivot point | Default: 50% 50% |
transform-style | preserve-3d for 3D children | Applied to container, not element |
perspective | Depth illusion — set on parent | 300–1200px typical range |
backface-visibility | hidden hides back of flipped element | Essential for card flips |
will-change | Hint GPU layer promotion | Use sparingly |
translate / rotate / scale | Individual transform props | Chrome 104+, Firefox 72+, Safari 14.1+ |
✏️ Exercises
Each exercise can be done with just CSS — no JavaScript needed. Focus on which function creates the right visual effect, and which properties need to be on the parent vs. the element.
transform and transition: (a) on hover — lift up 4px and scale to 1.04; (b) on active (click) — press down to scale 0.97; (c) ensure the transition feels snappy on press (0.1s) but smooth on hover (0.25s). Use cubic-bezier or named easings of your choice.:hover and :active are separate rules. :active wins over :hover when both match. Set transition on the base element so both states animate..btn {
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
/* spring-like easing makes the lift feel alive */
}
.btn:hover {
transform: translateY(-4px) scale(1.04);
}
.btn:active {
transform: scale(0.97);
transition: transform 0.1s ease;
/* override transition duration on :active for a fast press snap */
}
<div class="spinner"> that is a 40×40 circle with a partial border (transparent on one side). Use a CSS animation (@keyframes spin) to rotate it 360 degrees continuously. Set transform-origin to its centre. Then write a second version (.spinner-orbit) where the spinning dot travels around a circle by combining rotate() with translateY() and an external transform-origin.transform-origin: 50% 200% (or similar) so the dot rotates around a centre point below itself. The @keyframes just goes from rotate(0deg) to rotate(360deg)./* Standard spinner */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 40px;
height: 40px;
border-radius:50%;
border: 3px solid #4f8ef7;
border-top-color: transparent;
animation: spin 0.8s linear infinite;
/* transform-origin defaults to center — no change needed */
}
/* Orbiting dot */
@keyframes orbit {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner-orbit {
width: 10px;
height: 10px;
border-radius: 50%;
background: #c9a8ff;
transform-origin: 50% 250%; /* orbit radius ≈ 25px below dot center */
animation: orbit 1.2s linear infinite;
}
::after pseudo-element on a .has-tooltip span. At rest: opacity: 0 and translateY(6px). On hover: opacity: 1 and translateY(0). Use transform-origin: bottom center and position the tooltip above the trigger element. Animate both opacity and transform simultaneously.position: absolute; bottom: 100%; left: 50% on the pseudo-element, then use translate(-50%, 0) to centre it horizontally. The transform can contain both centering and the slide animation using multiple functions..has-tooltip {
position: relative;
display: inline-block;
}
.has-tooltip::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%) translateY(6px);
transform-origin: bottom center;
opacity: 0;
transition: transform 0.2s ease, opacity 0.2s ease;
white-space: nowrap;
pointer-events: none;
/* styling… */
background: #1e2235;
color: #e2e4ec;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8em;
}
.has-tooltip:hover::after {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
/* HTML: <span class="has-tooltip" data-tip="Your tooltip text">hover me</span> */
rotateX and rotateY transforms proportional to the cursor position within the card (max ±15 degrees). On mouse leave, smoothly animate back to no rotation. Add a subtle scale up on hover (1.03) and a glare highlight effect using a ::before pseudo-element with a radial gradient that follows the mouse.mousemove handler, calculate x = (e.offsetX / card.offsetWidth - 0.5) * 30 for the Y-axis rotation and the inverse for X. On mouseleave, set transform: rotateX(0) rotateY(0). The transition on the card handles the smooth return./* CSS */
.tilt-scene {
perspective: 800px;
display: inline-block;
}
.tilt-card {
position: relative;
transition: transform 0.5s ease;
transform-style: preserve-3d;
will-change: transform;
border-radius: 12px;
overflow: hidden;
}
/* Glare highlight */
.tilt-card::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at var(--mx,50%) var(--my,50%),
rgba(255,255,255,0.15) 0%,
transparent 60%);
pointer-events: none;
border-radius: inherit;
z-index: 1;
}
// JavaScript
const card = document.querySelector('.tilt-card');
const scene = document.querySelector('.tilt-scene');
const MAX = 15; // degrees
card.addEventListener('mousemove', (e) => {
card.style.transition = 'transform 0.1s ease';
const { left, top, width, height } = card.getBoundingClientRect();
const x = (e.clientX - left) / width - 0.5; // -0.5 → 0.5
const y = (e.clientY - top) / height - 0.5;
card.style.transform =
`scale(1.03) rotateY(${x * MAX * 2}deg) rotateX(${-y * MAX * 2}deg)`;
card.style.setProperty('--mx', `${(x + 0.5) * 100}%`);
card.style.setProperty('--my', `${(y + 0.5) * 100}%`);
});
card.addEventListener('mouseleave', () => {
card.style.transition = 'transform 0.5s ease';
card.style.transform = 'scale(1) rotateY(0deg) rotateX(0deg)';
card.style.setProperty('--mx', '50%');
card.style.setProperty('--my', '50%');
});