This is a technical reference on advanced, pure-CSS techniques for adding visual polish and motion to a web site: rounded corners, box shadows, gradients, state transitions, transforms, keyframe animations, and typography controls. Every technique is demonstrated on a running example — a bakery web site with a top navigation bar, a left-hand filter menu, and a grid of product (“pie”) cards — but the underlying CSS rules apply to any project.
Table of Contents
- Module 1: Creating Rounded Corners
- Module 2: Adding Box Shadows
- Module 3: Applying Gradients
- Module 4: Transitioning Between States
- Module 5: Transforming Elements
- Module 6: Creating Animation
- Module 7: Text and Typography
- Summary
Module 1: Creating Rounded Corners
The starting site uses a top navigation bar built from an unordered list with the built-in list styling removed and the list items floated left so that the logo, an “All pies” link, and a “Contact” link appear horizontally. A search <input> and search <button> sit on the right of the nav bar; the input uses CSS padding to make it visually larger than a default HTML input box. Below the nav bar, a grid of pie “cards” is built from plain <div> and <img> elements.
Compared to inspirational e-commerce and Bootstrap-style themes, the original site has only sharp, 90-degree corners. The border-radius property is the first tool used to soften the design.
Basic Rounded Corners with border-radius
To round the search input and search button, a border-radius rule is added to each:
/* Navbar search box */
nav input {
border-radius: 5px;
}
/* All buttons */
button {
border-radius: 5px;
}
border-radius: 5px rounds all four corners of the element by the same radius. This is shorthand — it is expanded internally into four individual longhand properties:
border-top-left-radiusborder-top-right-radiusborder-bottom-right-radiusborder-bottom-left-radius
Creating Circular Shapes
border-radius can also turn a rectangular element into a circle when the value is expressed as a percentage instead of a fixed pixel size. For example, to make the navbar’s search button (converted from a text button to an icon button using an <img>) into a circle:
nav button {
border-radius: 50%;
}
Because the browser computes 50% of the element’s own box, and a square element’s width equals its height, the result is a perfect circle.
Applying the same idea to a rectangular hero image does not produce a circle:
.main img {
border-radius: 50%; /* produces an ellipse, not a circle, on a non-square image */
}
Since the source image is not square, border-radius: 50% instead produces an ellipse. To force it into a circle, the image must first be made square (matching height and width), and then object-fit is used to prevent distortion:
.main img {
height: 513px;
width: 513px;
border-radius: 50%;
object-fit: cover;
object-position: center; /* try left, center, right to reposition the crop */
}
object-fit: coverscales the image to fill the box without stretching it, cropping any overflow.object-positioncontrols which part of the source image is visible inside the cropped box (e.g.,left,center,right).
After evaluating the effect, the circular hero image is dropped in favor of a subtler, global rounding rule applied to every image on the site:
img {
border-radius: 1%;
}
Customizing Individual Corners
border-radius accepts 1, 2, 3, or 4 space-separated values, letting each corner be controlled individually without writing out all four longhand properties.
| Values supplied | Meaning |
|---|---|
| 1 value | All four corners use the same radius |
| 2 values | 1st = top-left and bottom-right; 2nd = top-right and bottom-left (diagonal pairs) |
| 3 values | 1st = top-left; 2nd = top-right and bottom-left; 3rd = bottom-right |
| 4 values | top-left, top-right, bottom-right, bottom-left (clockwise from top-left) |
flowchart LR
A["border-radius: 5px (1 value)"] --> A1[Top-Left]
A --> A2[Top-Right]
A --> A3[Bottom-Right]
A --> A4[Bottom-Left]
B["border-radius: 0 25% (2 values)"] --> B1["Top-Left / Bottom-Right = 0"]
B --> B2["Top-Right / Bottom-Left = 25%"]
C["border-radius: 0 10% 25% (3 values)"] --> C1["Top-Left = 0"]
C --> C2["Top-Right / Bottom-Left = 10%"]
C --> C3["Bottom-Right = 25%"]
With two values specified, CSS keeps the shape symmetrical: after the diagonal pair (top-left/bottom-right) is set, the remaining diagonal pair (top-right/bottom-left) reuses the second value for both corners. With four values, the order proceeds clockwise starting at the top-left corner.
Applied to the site’s pie cards:
div.card {
border-radius: 1%;
}
div.card img {
/* All corners 10%, except bottom-left at 50% */
border-radius: 10% 10% 10% 50%; /* space-separated, NOT comma-separated */
}
Note that border-radius values must be space-separated, not comma-separated — mixing this up is a very common source of CSS bugs.
Because div.card img is a more specific selector than the general img { border-radius: 1%; } rule, the more specific rule wins under CSS’s specificity/cascade rules, so the card images show the 10%/10%/10%/50% shape rather than the global 1%.
Elliptical and Custom-Cut Corners
Beyond uniform pixel or percentage radii, border-radius supports a horizontal/vertical syntax using a slash (/) to give each corner a different horizontal and vertical curve — producing shapes with distinct, “cut” or leaf-like corners rather than simple rounding:
/* horizontal-radii / vertical-radii */
.custom-shape {
border-radius: 10px 100px / 100px 500px;
}
The syntax is horizontal-values / vertical-values. Everything before the slash defines the horizontal radius of each corner (top-left, top-right, bottom-right, bottom-left, following the same 1–4 value expansion rules), and everything after the slash defines the vertical radius for the same corners in the same order. In this example:
- Horizontal radius: top-left/bottom-right =
10px, top-right/bottom-left =100px - Vertical radius: top-left/bottom-right =
100px, top-right/bottom-left =500px
This combination lets a single border-radius declaration create irregular, asymmetric shapes — no image assets or clipping paths required.
Module 2: Adding Box Shadows
box-shadow is a single CSS rule capable of giving flat elements a sense of depth, similar to the drop shadow seen under navigation bars or cards in many modern site themes.
Creating a Basic Box Shadow
.mainheader {
box-shadow: 0px 3px;
}
Refreshing the page shows a solid black line under the header. Comparing it (via DevTools) against an actual border: 3px solid white, the box shadow is confirmed to render outside the element’s box, below the border — not merged with it. With no blur specified, the shadow’s edge is perfectly sharp, which does not yet resemble a soft, real-world shadow.
Blurring the Shadow Edge
A third, optional parameter — the blur-radius — softens the shadow:
.mainheader {
box-shadow: 0px 3px 5px;
}
Per the blur-radius definition: the larger the value, the bigger and lighter the blur becomes; negative values are not allowed; if omitted, it defaults to 0 (a perfectly sharp edge).
Because blur adds to the base shadow, a 3px shadow with a 5px blur bleeds out roughly 8px total from the element (3 + 5), not just 5px. Increasing the blur further (e.g., to 15px) softens the edge even more; pushing it to an extreme (e.g., 500px) makes the shadow overwhelm the layout, showing that blur has practical limits.
Positioning Shadows on Multiple Sides
The first two box-shadow values are the x-offset and y-offset — they shift the shadow’s center away from the element’s own center, not simply “left/right” and “up/down” flags:
div.card {
box-shadow: 0px 3px; /* base: shadow centered, shifted down 3px */
}
| Offsets | Resulting shadow position |
|---|---|
0px 0px | Centered directly behind the element |
0px 3px | Shifted down 3px (shadow appears below) |
3px 0px | Shifted right 3px (shadow appears to the right) |
-3px 3px | Shifted left 3px and down 3px (bottom-left) |
Adding a right-hand shadow to match the bottom shadow:
div.card {
box-shadow: 3px 3px; /* shadow appears bottom + right, as if lit from upper-left */
}
Flipping the light source to the upper-right instead requires a negative x-offset:
div.card {
box-shadow: -3px 3px; /* shadow appears bottom + left */
}
Because rounded corners were already applied to the cards (Module 1), the box shadow automatically follows the same rounded shape — shadows inherit the border-radius of the element casting them.
Combining offset and blur shows that blur bleeds the shadow out on all sides, even the sides that have a 0px offset:
div.card {
box-shadow: 0px 0px 25px; /* even sides can show shadow due to blur bleed */
}
div.card {
box-shadow: 5px 5px 15px; /* prominent shadow bottom/right, faint bleed on top/left */
}
flowchart LR
BS["box-shadow"] --> X["x-offset"]
BS --> Y["y-offset"]
BS --> BLUR["blur-radius (optional)"]
BS --> SPREAD["spread-radius (optional)"]
BS --> COLOR["color (optional)"]
BS --> INSET["inset keyword (optional)"]
Controlling Shadow Size with Spread Radius
A fourth, optional parameter controls the overall size of the shadow: the spread-radius.
div.card {
box-shadow: 5px 5px 15px 25px; /* x-offset y-offset blur-radius spread-radius */
}
| box-shadow parameter order | Name |
|---|---|
| 1st | x-offset |
| 2nd | y-offset |
| 3rd | blur-radius |
| 4th | spread-radius |
| optional | color |
| optional keyword | inset |
A positive spread value increases the shadow’s size; a negative value shrinks it:
div.card {
box-shadow: 5px 5px 15px -25px; /* shadow shrinks; only remnants visible on top/left */
}
A color can also be added as an additional value, tinting the shadow (e.g., matching the site’s brand color to create an “under-glow” effect):
div.card {
box-shadow: 5px 5px 15px #c87d52; /* colored shadow, no spread */
}
Multiple Box Shadows and Inset Shadows
The inset keyword moves the shadow inward from the element’s edge instead of outward:
.pink-box {
/* Without inset: shadow starts at the outer edge and grows outward */
box-shadow: 0px 0px 5px #1b1f3b;
}
.pink-box-inset {
/* With inset: shadow starts at the outer edge and grows inward */
box-shadow: 0px 0px 5px #1b1f3b inset;
}
box-shadow also accepts a comma-separated list of shadow definitions, allowing multiple shadows to be layered on the same element (unlike border-radius, where commas are invalid):
.stacked-shadows {
/* Three shadows, no offset or blur, only increasing spread */
box-shadow:
0px 0px 0px 10px blue,
0px 0px 0px 20px green,
0px 0px 0px 30px purple;
}
.shifted-shadows {
/* Same three shadows, but each shifted diagonally */
box-shadow:
10px 10px 0px 10px blue,
20px 20px 0px 20px green,
30px 30px 0px 30px purple;
}
By combining offsets, blur, spread, color, inset, and comma-separated lists, box-shadow alone can create intricate layered artwork — for example, a white div with a blue shadow, further overlaid with additional white shadows that appear to “erase” parts of the blue shadow.
Module 3: Applying Gradients
Replacing a Solid Background with a Linear Gradient
The site’s original body background is a flat gray:
body {
background-color: #e5e5e5;
}
Replacing this with a linear-gradient() blends the header’s brand color into the page background color:
body {
background: linear-gradient(#c87d52, #e5e5e5);
}
Two important details:
- The property name must change from
background-colortobackground— the shorthandbackgroundproperty is required to accept a gradient value;background-coloronly accepts solid colors. - With no direction specified,
linear-gradient()defaults to top-to-bottom.
Making the Gradient Fill the Entire Page
On pages where the content is shorter than the viewport, the gradient does not reach the bottom of the browser window because the body element does not stretch to fill it. The fix is to force both html and body to be as tall as the viewport:
html,
body {
height: 100%;
}
Preventing the Gradient From Repeating on Scroll
On longer pages, the gradient appeared to restart multiple times while scrolling. This happens because the default background-attachment value is scroll, which treats the background like a repeating image tied to page content.
background-attachment value | Behavior |
|---|---|
scroll (default) | Background scrolls with the page content; on a tall page, the background image tiles/repeats for the full content height |
local | Similar to scroll, but scoped to the element itself — if applied to a scrollable div or card, the background only scrolls when that element scrolls |
fixed | Background is a single, fixed image; it does not scroll at all — page content scrolls over the top of it |
body {
background-attachment: fixed;
}
With fixed, the gradient becomes one continuous image running from the nav bar to the bottom of the browser viewport, regardless of page length. A follow-up readability fix darkens card titles that were hard to read over the new background:
div.card h2 {
color: black;
}
Controlling Gradient Direction
linear-gradient() supports keyword directions and arbitrary angles:
flowchart TD
center(["Gradient Start 0%"])
center --> top["to top"]
center --> bottom["to bottom (default)"]
center --> left["to left"]
center --> right["to right"]
center --> topright["to top right (diagonal)"]
center --> topleft["to top left (diagonal)"]
.to-top { background: linear-gradient(to top, pink, orange, yellow, green, blue, purple, #1b1f3b); }
.to-bottom { background: linear-gradient(pink, orange, yellow, green, blue, purple, #1b1f3b); } /* default */
.to-right { background: linear-gradient(to right, pink, #1b1f3b); }
.to-left { background: linear-gradient(to left, pink, #1b1f3b); }
.diagonal { background: linear-gradient(to top right, pink, #1b1f3b); }
.diagonal2 { background: linear-gradient(to top left, pink, #1b1f3b); }
.custom-angle {
background: linear-gradient(67deg, pink, #1b1f3b); /* precise angle instead of a keyword */
}
Keyword directions (to top, to right, to top right, etc.) are convenient presets, but an explicit angle in degrees gives full control over the gradient line — including negative angles, which effectively flip the gradient (e.g., a negative angle behaves like flipping to top to to bottom).
Controlling Color Stops
By default, colors in a gradient are distributed evenly. Explicit color stops — expressed as a percentage or a length — control exactly where each color reaches full strength and where blending begins:
/* Pink is 100% pure until the 50% mark, then blends into blue */
.stop-50-percent {
background: linear-gradient(pink 50%, #1b1f3b);
}
/* Using a pixel-based stop instead of a percentage */
.stop-50-pixels {
background: linear-gradient(pink 50px, #1b1f3b);
}
/* Narrow the blend zone to just 10% of the element's height */
.narrow-blend {
background: linear-gradient(pink 50%, #1b1f3b 60%);
}
For a top-to-bottom gradient, 0% corresponds to the very top and 100% to the very bottom. Stop values are even allowed to exceed this 0–100% range (e.g., a stop at 120%), which pushes part of the blend off the visible element, softening the visible transition.
Specifying two stops for the same color (instead of one) creates crisp, banded transitions instead of smooth blends:
.striped-gradient {
background: linear-gradient(
pink 0%, pink 5%,
orange 6%, orange 17%,
yellow 17%, yellow 35%,
blue 35%, blue 40%,
#1b1f3b 40%
);
}
Each color occupies its own explicit percentage “band,” producing hard edges between bands rather than gradual blends.
Softening and Stacking Gradients
Changing the site’s gradient to a diagonal direction can make one corner too dark:
body {
background: linear-gradient(to top right, #c87d52, #e5e5e5);
}
Moving the primary color’s stop off-screen (a negative percentage) softens the effect while keeping the same overall coloring:
body {
/* Note: the stop value comes immediately after the color, before the comma */
background: linear-gradient(to top right, #c87d52 -30%, #e5e5e5);
}
To restore richer color after softening it off-screen, gradients can be stacked: multiple linear-gradient() functions are supplied as a comma-separated list on the same background property, layered on top of one another. For gradients to visually blend when stacked, the colors must use an alpha channel (rgba()), because opaque hex colors would simply hide the layers beneath them:
body {
background:
linear-gradient(to top right, rgba(200, 125, 82, 0.5), rgba(229, 229, 229, 0.5)),
linear-gradient(to top left, rgba(200, 125, 82, 0.5), rgba(229, 229, 229, 0.5));
}
Here, rgba(200, 125, 82, 0.5) is the brand color at 50% opacity, and rgba(229, 229, 229, 0.5) is the page background gray at 50% opacity. Because both gradients are semi-transparent, they blend where they overlap instead of one fully obscuring the other.
Radial and Conic Gradients
In addition to linear-gradient(), CSS provides radial-gradient() and conic-gradient().
mindmap
root((CSS Gradients))
Linear
"direction: to bottom, to top, to left, to right"
"or an angle in degrees"
Radial
"position, e.g. 0% 0%"
"sizing: closest-side, closest-corner, farthest-side, farthest-corner"
Conic
"starts from an angle"
"midpoint position"
Repeating
repeating-linear-gradient
repeating-radial-gradient
repeating-conic-gradient
Radial gradients radiate outward from a midpoint rather than flowing in a direction, so directional keywords like to bottom do not apply. Instead, the midpoint’s position is specified, and color stops still work exactly as with linear gradients:
.radial-top-left {
background: radial-gradient(at 0% 0%, pink, #1b1f3b);
}
.radial-bottom-left {
background: radial-gradient(at 0% 100%, pink, #1b1f3b);
}
Radial gradient size is controlled with one of four keywords, measured from the midpoint outward:
| Size keyword | Measures from midpoint to… |
|---|---|
closest-side | The nearest edge of the box |
closest-corner | The nearest corner of the box |
farthest-side | The farthest edge of the box |
farthest-corner | The farthest corner of the box |
.gradient-farthest-side {
background: radial-gradient(farthest-side at 10% 50%, pink, #1b1f3b);
}
.gradient-closest-side {
background: radial-gradient(closest-side at 10% 50%, pink, #1b1f3b);
}
With the midpoint placed only 10% from the left, closest-side produces a much smaller gradient (measuring to the nearby left edge) than farthest-side (measuring all the way to the right edge).
Conic gradients also start from a midpoint, but instead of colors radiating outward, they sweep around the midpoint like the hands of a clock — as if a linear gradient were curled into a cone viewed from above:
.conic-basic {
background: conic-gradient(pink, #1b1f3b);
}
.conic-angled {
/* Sweep starts at a 45-degree line and the midpoint is offset */
background: conic-gradient(from 45deg at 10% 75%, pink, #1b1f3b);
}
Repeating gradients — repeating-linear-gradient(), repeating-radial-gradient(), and repeating-conic-gradient() — take a defined pattern of color stops and tile it repeatedly until the element’s space runs out:
.repeating-stripes {
background: repeating-linear-gradient(
pink 0%, pink 5%,
orange 5%, orange 10%,
yellow 10%, yellow 15%,
green 15%, green 20%,
blue 20%, blue 25%,
purple 25%, purple 30%,
#1b1f3b 30%, #1b1f3b 45%
);
}
The pattern (pink through inky blue, with inky blue occupying a wider 15% band) repeats from the start until the container is filled, potentially ending mid-pattern (e.g., cut off on an orange band) depending on the element’s size.
Module 4: Transitioning Between States
Adding a Hover State
CSS transitions animate a property change that happens whenever a value changes — commonly triggered by the :hover pseudo-class, but not limited to it.
To make the left/top navigation react to the mouse, a hover rule is added alongside the existing menu item rule:
nav > ul > li a {
/* existing base styles */
}
nav > ul > li a:hover {
background: #529dc8;
font-size: 18px;
}
Two important details about pseudo-classes:
- A pseudo-class carries the same specificity as the base selector it is attached to; scoping
:hovernarrowly (e.g., to menu item links specifically, rather than every<a>) is best practice. - There must be no space between the selector and the colon —
a:hover, nota :hover. A stray space is one of the most common reasons a hover rule silently fails to apply.
Without a transition, the hover state (background color and font-size) snaps instantly, which can look jarring, especially since the font-size change causes the menu to visibly jump.
Controlling Transition Duration and Timing Functions
Adding transition-duration smooths the change over time:
nav > ul > li a:hover {
background: #529dc8;
font-size: 18px;
transition-duration: 3s;
}
Chrome DevTools includes an Animations panel (More tools → Animations) that visualizes transitions in slow motion (buttons for 100%, 25%, and 10% playback speed) and plots a curve showing the transition’s velocity over time — useful for understanding timing functions visually.
transition-timing-function controls the acceleration curve of the transition:
| Timing function | Behavior |
|---|---|
ease (default) | Starts and ends slowly; speeds up in the middle |
linear | Constant speed throughout |
ease-in | Starts slowly, speeds up, does not slow down at the end |
ease-out | Starts quickly, slows down toward the end |
ease-in-out | Starts slowly and ends slowly (like ease), but with a more symmetric curve |
steps(n, start|end) | Performs the transition in n discrete jumps rather than a smooth curve; end places the first jump before the end of duration, start places a jump immediately at the start |
flowchart LR
ease["ease (default): slow-fast-slow"]
linear["linear: constant speed"]
easein["ease-in: slow start, fast end"]
easeout["ease-out: fast start, slow end"]
easeinout["ease-in-out: slow-fast-slow, symmetric"]
steps["steps(n, start or end): discrete jumps"]
/* Discrete 4-step transition instead of a smooth curve */
nav > ul > li a:hover {
transition-timing-function: steps(4, end);
}
With steps(4, end) and a 3-second duration, the DevTools timeline shows four distinct jumps at 750ms, 1.5s, 2.25s, and 3s, rather than a continuous curve — visually resembling a low-frame-rate or “buffering” effect. In practice, the default ease is appropriate for the vast majority of cases; the other functions are worth knowing but rarely needed.
Transitioning Without Hover (Class-Based Transitions)
Per the MDN definition, CSS transitions occur whenever a property changes — :hover is only one way to trigger a change. Adding or removing a CSS class via JavaScript is another common trigger.
div.card.active {
box-shadow: 0px 0px 15px 5px #529dc8; /* example: spread + color for an "active" card */
}
When JavaScript toggles the .active class on click (a standard pattern for highlighting a selected card), the shadow currently changes instantly. Adding a duration smooths it exactly as with hover:
div.card.active {
box-shadow: 0px 0px 15px 5px #529dc8;
transition-duration: 0.75s;
}
This can be verified purely in DevTools by manually toggling the .active class checkbox on an element (via the “.cls” class editor) — even though the class was added by JavaScript, the timing and easing of the visual change are controlled entirely by CSS.
Running Independent Transitions
Multiple properties can transition at different speeds on the same element by supplying comma-separated lists to the relevant transition properties.
article aside ul li:hover {
background: #529dc8;
color: white;
transition-duration: 0.5s;
}
article aside ul li:hover .count {
opacity: 1;
font-weight: bold;
transition-duration: 2s;
}
Here, the background/color transition (0.5s) completes four times faster than the .count number’s fade-in (2s), so the number visibly appears well after the surrounding colors have already changed.
Adding horizontal movement makes the effect more dynamic — the number slides in from off-screen using padding-right:
article aside ul li:hover .count {
padding-right: 0%;
}
article aside ul li .count {
padding-right: 60%; /* pushes the number off to the right when not hovered */
}
To control which properties are transitioned, and for how long each one individually takes, transition-property and a comma-separated transition-duration list are used together:
article aside ul li:hover .count {
transition-property: opacity, padding-right;
transition-duration: 2s, 5s; /* opacity: 2s, padding-right: 5s */
}
flowchart LR
Hover["Hover starts (t = 0s)"] --> Color["Background color transitions (0.5s)"]
Hover --> Delay["transition-delay: 2s for padding-right"]
Delay --> Pad["padding-right transitions (5s, starts at t = 2s)"]
Pad --> Done["Number fully right; opacity delayed until padding finishes"]
Swapping the order of the duration list (5s, 2s) reverses which property is fast and which is slow — the number can be made to slide into place quickly while its opacity keeps fading in for several more seconds afterward.
Delaying Transitions
transition-delay staggers when each transition starts, also accepting a comma-separated list matched positionally to transition-property:
article aside ul li:hover .count {
transition-property: opacity, padding-right;
transition-duration: 2s, 5s;
transition-delay: 2s, 0s; /* opacity waits 2s before starting; padding-right starts immediately */
}
Rules for list-length mismatches between transition-property, transition-duration, and transition-delay:
- If
durationordelayhas fewer values thantransition-property, the shorter list repeats (cycles) to cover every property. - If
durationordelayhas more values thantransition-property, the extra values are simply ignored/truncated.
Reversing the duration and delay values makes the opacity fade-in wait until the padding-right slide finishes completely before starting:
article aside ul li:hover .count {
transition-property: opacity, padding-right;
transition-duration: 2s, 5s;
transition-delay: 0s, 2s; /* now padding-right starts immediately, opacity waits until it's done */
}
This staging capability lets multiple transitions be sequenced deliberately, rather than all firing simultaneously.
Transition Shorthand
All of the longhand transition properties can be combined into the single transition shorthand property:
/* Longhand */
article aside ul li:hover .count {
transition-property: opacity;
transition-duration: 2s;
transition-delay: 0s;
}
/* Shorthand equivalent */
article aside ul li:hover .count {
transition: opacity 2s 0s;
}
/* Shorthand with an explicit timing function */
article aside ul li:hover .count {
transition: opacity 2s 0s linear;
}
Multiple transitions are expressed shorthand as a comma-separated list, and each group does not need the same number of values:
article aside ul li:hover .count {
transition:
opacity 2s 0.5s,
padding-right 5s 2s;
}
/* No delay needed for opacity; explicit delay only for padding-right */
article aside ul li:hover .count {
transition:
opacity 2s,
padding-right 5s 2s;
}
Not every CSS property can be transitioned. For example, display cannot be transitioned (it cannot animate between none and block/inline) — this is exactly why opacity is typically used instead to fade elements in and out. MDN maintains the authoritative list of animatable CSS properties.
Module 5: Transforming Elements
Rotating an Object
The transform property changes an element’s rendered position/orientation without changing any CSS state (unlike a transition, which needs a state change to trigger). The rotate() function accepts turns, degrees, or radians:
.rotate-one-sixteenth {
transform: rotate(0.0625turn); /* one sixteenth of a full turn */
}
/* Equivalent using degrees */
.rotate-degrees {
transform: rotate(21deg);
}
/* Equivalent using radians */
.rotate-radians {
transform: rotate(0.3665rad);
}
A set of four squares, each rotated slightly more than the last (e.g., incrementing by one-sixteenth of a turn each time), demonstrates how repeatedly applying rotate() with different class names can build a pinwheel-like composition using nothing but plain <div> elements and CSS.
Translate, Scale, and Skew Functions
translate() moves an element along the x-axis and/or y-axis without affecting layout flow:
.translate-both {
transform: translate(300px, 50px); /* 300px right, 50px down */
}
.translate-x-only {
transform: translateX(300px);
}
.translate-y-only {
transform: translateY(50px);
}
Negative values move the element left / up respectively. If only one value is given to translate(), the second (y) defaults to 0.
scale() grows or shrinks an element:
.scale-uniform {
transform: scale(2); /* twice the size, both axes */
}
.scale-non-uniform {
transform: scale(2, 1.25); /* 2x horizontal, 1.25x vertical */
}
.scale-x-only {
transform: scaleX(2);
}
.scale-y-only {
transform: scaleY(2);
}
Key differences from translate():
scale()arguments are unitless numbers, not lengths.- Negative values have no effect on
scale(); to shrink an element, use a fractional value between 0 and 1 (e.g.,scale(0.5)for half size). - A single value passed to
scale()applies to both axes (unliketranslate(), where a single value only affects the x-axis).
skew() distorts the angles of an element’s corners, using degree or radian units:
.skew-both {
transform: skew(21deg, 25deg); /* 21deg horizontal skew, 25deg vertical skew */
}
.skew-x-only {
transform: skewX(21deg);
}
.skew-y-only {
transform: skewY(25deg);
}
As with translate(), a single value passed to skew() behaves like skewX() only.
Multiple transform functions can be combined on a single transform property, space-separated (not comma-separated — commas will cause the entire rule to fail):
.combined-transform {
transform: skew(21deg, 25deg) translate(20px, 35px) rotate(15deg) scale(0.75);
}
| Transform function | Axis argument(s) | Notes |
|---|---|---|
translate(x, y) / translateX() / translateY() | length units (px, %, etc.) | Moves the element; negative values allowed |
scale(x, y) / scaleX() / scaleY() | unitless number | Grows/shrinks; negative values ignored; single value applies to both axes |
skew(x, y) / skewX() / skewY() | degrees or radians | Distorts corner angles; single value behaves as skewX |
rotate(angle) | turns, degrees, or radians | Rotates around the element’s center by default |
3D Transforms and the Z-Axis
Beyond 2D, translate() and friends have a Z counterpart — translateZ() — which, combined with rotateX() / rotateY(), enables true 3D composition, such as building a cube from four flat <div> elements:
.cube-front {
transform: translateZ(250px);
}
.cube-right {
transform: rotateY(-270deg) translateZ(250px);
}
flowchart TD
Cube["Cube built from 4 divs"] --> Front["Front face: translateZ(250px)"]
Cube --> Top["Top face: rotateX + translateZ"]
Cube --> Right["Right face: rotateY(-270deg) + translateZ"]
Note["Each face has its own local axis orientation after rotation"]
Without any rotation or translation, all of these divs would stack directly on top of one another at the center of the screen. The key conceptual point: each face has its own local axis orientation, because each div is rotated independently before being pushed outward.
Once a face has been rotated so that a different edge now faces the viewer, that face’s local Z-axis points in the direction that was originally the X or Y axis from the un-rotated point of view. In practical terms: to push a face outward relative to itself (so it opens up like a lid), use translateZ() on that face — not translateX() or translateY(), which would move it along axes that no longer correspond to “outward” once the face has been rotated.
Preparing the Markup for Flippable Cards
To implement a flip-to-reveal-details card (showing allergen/description information on the back), the markup for a single pie card is restructured:
<div class="card">
<div class="card-inner">
<div class="card-front">
<!-- existing front content: image, h3 title, price, etc. -->
</div>
<div class="card-back">
<!-- description paragraph and allergens (h5), copied from the pie's detail page -->
</div>
</div>
</div>
The wrapping .card-inner div contains both the front and back faces as children. The corresponding CSS establishes a 3D rendering context and stacks the two faces on top of each other:
.card-inner {
transform-style: preserve-3d; /* children render in 3D space, not flattened to 2D */
}
.card-front,
.card-back {
position: absolute; /* both faces occupy the same position, stacked on top of each other */
}
.card-front {
/* front-specific coloring/layout */
}
.card-back {
/* back-specific coloring/layout */
}
At this stage, without any rotation, the .card-back div — appearing later in the DOM — simply renders on top of .card-front, so only the back (description and allergens) is visible; the pie image temporarily disappears.
Flipping the Cards in 3D Space
Two rotation rules complete the flip effect:
.card-back {
transform: rotateY(180deg); /* pre-flip the back face so it faces "inward" at rest */
}
.card:hover .card-inner {
transform: rotateY(180deg); /* rotate the whole card container on hover */
}
stateDiagram-v2
[*] --> Front
Front --> Back: hover (rotateY 180deg on .card-inner)
Back --> Front: mouse leaves
Front: card-front visible, card-back backface hidden
Back: card-inner rotated 180deg, card-back now facing viewer
Without one more rule, the pie image still fails to reappear because the browser, by default, still renders whichever face is topmost in the DOM regardless of its rotated orientation. backface-visibility solves this by hiding a face entirely once it has been rotated to face away from the viewer:
.card-front,
.card-back {
position: absolute;
backface-visibility: hidden;
}
With backface-visibility: hidden, only the face currently oriented toward the viewer is rendered — analogous to a physical object like a die, where only the forward-facing side is visible at any moment.
To smooth the flip instead of having it snap instantly, a transition is added to the rotating container:
.card-inner {
transform-style: preserve-3d;
transition: transform 1s;
}
Finally, a perspective value on the outer .card establishes a simulated viewing distance from the 3D scene, making the flip feel more dramatic and three-dimensional rather than a flat mirror-flip:
div.card {
perspective: 1000px; /* simulated distance (in px) from the viewer to the rotation axis */
}
Smaller perspective values (e.g., 100px) exaggerate the 3D effect strongly; larger values (e.g., 5000px) flatten it, making the flip look nearly 2D.
Module 6: Creating Animation
Setting Up a Simulated Slow Page Load
To demonstrate a use case for CSS animations — a loading indicator — a menu item is added that simulates a slow page load (e.g., waiting several seconds before navigating):
<li><a onclick="slowLoad()">Order</a></li>
The associated (illustrative) JavaScript delays navigation and toggles a .load class onto the site’s round logo element:
function slowLoad() {
document.querySelector('.logo-mark').classList.add('load');
setTimeout(function () {
window.location.href = 'order.html';
}, 6000); // simulate a 6-second load
}
The first step of the loading indicator moves the logo to the center of the screen using a transition on transform:
.logo-mark {
transition: transform 1s;
}
.logo-mark.load {
transform: translateY(-300px) scale(4);
}
The Limits of Transitions
A second transition is added to fade the logo out as the page finishes loading:
.logo-mark {
transition: transform 1s, opacity 5s 1s; /* opacity: 5s duration, 1s delay */
}
.logo-mark.load {
transform: translateY(-300px) scale(4);
opacity: 0.2;
}
This works, but exposes two structural limitations of transitions:
- A single property can only be transitioning toward one target state at a time. Since both “move to center” and “rotate for loading feedback” would each require a
transformvalue, a transition cannot smoothly move an element and then separately rotate it afterward — there is no way to chain two different transform end-states on the same property with plain transitions. - Transitions have no concept of “run until told to stop.” The loading indicator’s duration is effectively hard-coded to the assumed page-load time (6 seconds in this simulation); if the real load takes longer, the effect finishes and the page appears to freeze again.
CSS animations, introduced next, solve both problems.
Basic Animation with keyframes
An animation property, combined with an @keyframes at-rule, provides much finer control than a transition:
.logo-mark.load {
transform: translateY(-300px) scale(4); /* still moves via transition, as before */
animation: spin 3s;
}
@keyframes spin {
from {
transform: translateY(-300px) scale(4);
}
to {
transform: translateY(-300px) scale(4) rotate(360deg);
}
}
spinin theanimationshorthand is not a CSS property — it is a user-defined name that must match the name given to an@keyframesrule.fromandtodefine the object’s state at the start and end of the animation, respectively (equivalent to0%and100%).- Both the initial (pre-animation)
transformand the animation’sfrom/tokeyframes must repeat the translate/scale values — otherwise, the element visually “jumps” back to its un-transformed position the instant the animation starts, since the animation completely overrides the transitioned transform value.
Adding a short animation-delay lets the initial transition (moving the logo to center) finish before the animation (spin) begins:
.logo-mark.load {
animation: spin 3s 1s; /* 3s duration, 1s delay */
}
Looping Animations
By default, an animation plays once and then stops. The animation-iteration-count value controls how many times it repeats:
.logo-mark.load {
animation-name: spin;
animation-duration: 3s;
animation-delay: 1s;
animation-iteration-count: 2;
}
Rather than guessing a large finite number of iterations to “cover” an unpredictable real-world load time, the infinite keyword tells the browser to keep looping indefinitely:
.logo-mark.load {
animation-duration: 1s;
animation-iteration-count: infinite;
}
Fractional iteration counts are also valid — for example, 1.25 completes one full rotation plus an additional quarter-turn before stopping (and, without further configuration, snapping back to the from state).
Preserving the Final Animation Frame
By default, once an (finite) animation finishes, the element resets back to its from (starting) keyframe — which can look jarring. animation-fill-mode controls this behavior:
.logo-mark.load {
animation-iteration-count: 1.25;
animation-fill-mode: forwards;
}
animation-fill-mode value | Behavior when the animation is not running |
|---|---|
none (default) | Element shows the from (starting) keyframe once the animation ends |
forwards | Element holds the to (ending) keyframe state after the animation ends |
backwards | Element shows the from keyframe’s styles even before the animation starts (e.g., during an animation-delay) |
both | Combines forwards and backwards — applies the from styles during any delay, and holds the to styles after the animation completes |
With 1.25 iterations and the default ease timing function, the animation visually appears to travel further than exactly 1.25 rotations, because ease is not a constant-speed curve. Switching the timing function to linear produces a stop at exactly 1.25 rotations, since a linear curve has a perfectly constant angular velocity:
.logo-mark.load {
animation-timing-function: linear;
}
Controlling How Animations Restart
animation-direction controls whether a repeating animation always restarts from the from keyframe, or alternates back and forth (“ping-pongs”) between from and to:
animation-direction value | Behavior |
|---|---|
normal (default) | Always plays from → to, then jumps back to from to repeat |
reverse | Always plays to → from |
alternate | Plays from → to, then to → from, alternating each iteration |
alternate-reverse | Like alternate, but starts with to → from first |
.progress-bar {
animation: progress 6s ease infinite alternate;
}
With alternate and the default ease timing, a progress bar visibly slows to a stop at each end before reversing direction; switching the timing function to linear produces smooth, constant-speed back-and-forth motion instead.
For the site’s loading indicator specifically, animation-direction and animation-fill-mode are not required (the logo simply needs to spin continuously in one direction), but both properties are broadly useful to know.
Running Multiple Animations Together
Just as transition-property accepts a comma-separated list to run multiple transitions independently, every animation-* longhand property accepts a comma-separated list to run multiple named animations simultaneously, each with its own timing:
.logo-mark.load {
animation-name: spin, fade;
animation-duration: 3s, 2s;
animation-delay: 1s;
animation-iteration-count: infinite;
animation-timing-function: linear, ease;
}
@keyframes spin {
from { transform: translateY(-300px) scale(4); }
to { transform: translateY(-300px) scale(4) rotate(360deg); }
}
@keyframes fade {
0% { opacity: 1; }
25% { opacity: 0.5; }
35% { opacity: 0.75; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
Notes:
@keyframespercentage selectors are not limited tofrom/to(0%/100%) — any number of intermediate percentage stops can be defined (here,25%,35%, and50%create a bouncing opacity pattern).- Do not add a colon after
from/toor a percentage selector in@keyframes— it is a selector, styled like a normal rule block, not a property. - With
spinrunning every 3 seconds andfaderunning every 2 seconds, the two animations drift in and out of phase with each other over time, since their durations are different.
flowchart TD
Click["User clicks Order menu item"] --> AddClass["JavaScript adds .load class and waits 6s"]
AddClass --> Transition["transition: transform 1s - logo moves to center, scales 4x"]
Transition --> Animation["animation: spin 3s linear infinite"]
Animation --> Spin["Logo rotates continuously via @keyframes spin"]
Animation --> Fade["fade animation: opacity cycles 1 to 0.5 to 0.75 to 0.5 to 1 every 2s"]
Spin --> PageLoads["Order page finishes loading"]
Fade --> PageLoads
stateDiagram-v2
[*] --> Delay: animation-delay
Delay --> Keyframe_From: start (0%)
Keyframe_From --> Keyframe_To: animate over animation-duration
Keyframe_To --> Keyframe_From: iterate (animation-iteration-count > 1)
Keyframe_To --> Held: animation-fill-mode forwards
Keyframe_From --> Reset: animation-fill-mode none (default)
The end result: clicking the “Order” menu item moves the logo to the center of the screen, then spins it continuously while its opacity pulses, giving clear visual feedback that content is loading — implemented entirely with CSS, with only a small amount of JavaScript needed to toggle the triggering class.
Module 7: Text and Typography
Font Family, Size, Weight, and Color
font-family sets the typeface used for text. A value can be a generic family (serif, sans-serif, monospace, emoji, etc.), which describes only broad characteristics rather than a specific typeface, or a specific font name (e.g., Times New Roman for a serif font, Courier for a monospace font).
Because a specifically named font may not be installed on every user’s machine, CSS supports a font stack: a comma-separated list of fonts to try in order, typically ending in a generic family as a guaranteed fallback:
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
The browser tries Segoe UI first; if unavailable, it moves down the list (Tahoma, Geneva, Verdana) until it finds an installed font, ultimately falling back to the generic sans-serif family if none of the named fonts are present.
Other familiar text properties used earlier in the course include:
nav > ul > li a:hover {
font-size: 18px;
}
aside .count {
font-weight: bold;
color: #529dc8;
}
font-weight value | Meaning |
|---|---|
normal | Default weight |
bold | Bold weight |
lighter | One relative step lighter than the parent element’s weight |
bolder | One relative step bolder than the parent element’s weight |
Numeric (1–1000) | Explicit weight; higher numbers are bolder |
Transforming Text Case
Inconsistent capitalization coming from the back end (e.g., some pie names arriving as caramel popcorn cheesecake in lowercase, others properly capitalized) is a presentation concern, not a data concern — it belongs in CSS, not the database. The text-transform property fixes this purely at the display layer:
div.card h2 {
text-transform: capitalize;
}
text-transform value | Effect |
|---|---|
none (default/initial) | Leaves text exactly as received/authored |
capitalize | Capitalizes the first letter of each word |
uppercase | Converts every letter to uppercase |
lowercase | Converts every letter to lowercase |
uppercase and lowercase correctly apply language-specific casing rules — for example, lowercasing a Greek sigma automatically produces the correct medial or final sigma form depending on the letter’s position within the word, rather than a single one-size-fits-all substitution.
Laying Out Text: Alignment and Word Spacing
text-align controls horizontal alignment of text within its container:
body h2 {
text-align: center;
padding-top: 20px; /* prevents the centered heading from being hidden under the logo */
}
body p {
text-align: justify;
}
text-align value | Effect |
|---|---|
left | Text aligned to the left edge; ragged right edge |
right | Text aligned to the right edge; ragged left edge |
center | Text centered horizontally |
justify | Text stretched to align flush on both the left and right edges (except the last line) |
start | Same as left in left-to-right languages (e.g., English); same as right in right-to-left languages (e.g., Hebrew) |
end | Same as right in left-to-right languages; same as left in right-to-left languages |
word-spacing adjusts the gap between words, accepting fixed lengths, relative em units (relative to the element’s own font size), or even negative values:
p {
word-spacing: 15px; /* wide gaps, spreads paragraph across more lines */
}
p.tight {
word-spacing: 0.5em; /* relative to font-size; half the font size, e.g. 8px on a 16px font */
}
p.overlap {
word-spacing: -1em; /* negative value: words overlap directly on top of each other */
}
p.slightly-tight {
word-spacing: -0.25em; /* words closer together, but not fully overlapping */
}
Adding Text Shadow
text-shadow uses the same first three parameters as box-shadow — x-offset, y-offset, and color — plus an optional blur radius (no spread or inset support, since text has no independent box to spread from):
h2 {
text-shadow: 0px 3px #c87d52; /* shadow directly below the text, matching the header color */
}
Chrome DevTools provides an interactive color/shadow widget (click the small color swatch next to a text-shadow value) with a draggable handle for visually positioning the shadow’s x/y offset, plus a slider for blur radius — useful for quickly experimenting with values (for example, settling on 10px x-offset, 5px y-offset, and a 5px blur-radius) before committing them to the stylesheet.
h2 {
text-shadow: 10px 5px 5px #c87d52; /* x-offset y-offset blur-radius color */
}
Decorating Text
text-decoration controls decorative lines drawn on text. Its most common real-world use is actually to remove the default underline from links, rather than to add anything:
nav a,
aside a {
text-decoration: none;
}
Beyond removing lines, text-decoration (and its longhand components) can add and style lines in several ways:
flowchart LR
TD["text-decoration-line"] --> U[underline]
TD --> O[overline]
TD --> L["line-through"]
TD --> N[none]
TD --> Combo["combine multiple: underline overline line-through"]
.overline-only {
text-decoration-line: overline;
}
.overline-and-underline {
text-decoration-line: overline underline;
}
.all-three-lines {
text-decoration-line: underline overline line-through;
}
.dashed-underline {
text-decoration-line: underline;
text-decoration-style: dashed;
}
.wavy-underline {
text-decoration-line: underline;
text-decoration-style: wavy; /* resembles a spell-checker's squiggly underline */
}
.thick-colored-underline {
text-decoration-line: underline;
text-decoration-color: #529dc8;
text-decoration-thickness: 10px;
}
.relative-thickness {
/* 50% of 1em; on a 24px font, this computes to a 12px-thick line */
font-size: 24px;
text-decoration-thickness: 50%;
}
text-decoration-line value | Effect |
|---|---|
none | No decorative line (commonly used to strip default link underlines) |
underline | Line beneath the text |
overline | Line above the text |
line-through | Line through the middle of the text (strikethrough) |
| Multiple values | Any combination can be applied together (e.g., underline overline line-through) |
text-decoration-thickness accepts fixed lengths or percentages relative to 1em (the element’s font size), and text-decoration-style accepts values such as solid, dashed, dotted, and wavy.
Summary
This course built up a layered toolkit for adding depth, color, motion, and typographic polish to a site using only CSS — no images, no JavaScript animation libraries.
- Border-radius (Module 1) was the entry point: a single shorthand property that expands into four corner-specific longhand properties, supports 1–4 space-separated values for individual corner control, percentage values for circles/ellipses, and a horizontal/vertical slash syntax (
h1 h2 h3 h4 / v1 v2 v3 v4) for asymmetric, “cut” shapes. - Box-shadow (Module 2) extended the same idea with more parameters — x-offset, y-offset, blur-radius, spread-radius, color, and the
insetkeyword — and supports comma-separated lists for multiple layered shadows on one element. - Gradients (Module 3) replace solid
background-colorwithbackground: linear-gradient(),radial-gradient(), orconic-gradient(), with full control over direction/angle, color stops, and even stacking multiple semi-transparent gradients together viargba(). - Transitions (Module 4) animate a property change between two states (such as
:hoveror a toggled class), with control overtransition-property,transition-duration,transition-delay, andtransition-timing-function— including comma-separated lists to stagger multiple properties independently, and atransitionshorthand. - Transforms (Module 5) reposition or reshape elements directly via
translate,scale,skew, androtate— including 3D variants (translateZ,rotateX,rotateY) combined withtransform-style: preserve-3d,backface-visibility, andperspectiveto build effects like a flipping card. - Animations (Module 6) combine transitions and transforms into fully keyframe-driven sequences (
@keyframes,animation-name,-duration,-delay,-iteration-count,-direction,-fill-mode,-timing-function), solving the two core limitations of plain transitions: animating a single property through multiple stages, and looping indefinitely (infinite) regardless of how long an external event actually takes. - Typography (Module 7) rounds things out with
font-familystacks,font-weight/font-size/color,text-transformfor case correction,text-align/word-spacingfor layout,text-shadowfor depth, andtext-decorationfor underlines, overlines, and strikethroughs.
Quick-Reference: Property Cheat Sheet
| Feature | Key property | Core parameters |
|---|---|---|
| Rounded corners | border-radius | 1–4 corner values, optional / for horizontal/vertical split |
| Depth | box-shadow | x-offset, y-offset, blur-radius, spread-radius, color, inset |
| Color blending | background: linear-gradient() / radial-gradient() / conic-gradient() | direction/angle or position, color stops |
| Smooth state change | transition | property, duration, delay, timing-function |
| Reposition/reshape | transform | translate, scale, skew, rotate (+ Z-axis/3D variants) |
| Custom motion sequences | animation + @keyframes | name, duration, delay, iteration-count, direction, fill-mode, timing-function |
| Text case | text-transform | none, capitalize, uppercase, lowercase |
| Text layout | text-align, word-spacing | left/right/center/justify/start/end; length or em |
| Text shadow | text-shadow | x-offset, y-offset, blur-radius, color |
| Text lines | text-decoration | line, style, color, thickness |
Checklist for Applying These Techniques
- Use
border-radiusshorthand for simple cases; switch to per-corner values (or the/syntax) only when a shape truly needs asymmetry. - Remember
border-radiusvalues are space-separated, whilebox-shadowlayers are comma-separated. - Add a
blur-radiustobox-shadow/text-shadowfor a realistic soft shadow instead of a hard line. - Change
background-colortobackgroundbefore attempting to use a gradient. - Set
background-attachment: fixed(or design aroundlocal/scroll) to prevent a page-length background from visibly repeating. - Prefer
rgba()colors when stacking multiple gradients so they can blend instead of fully occluding one another. - Scope
:hover(and other pseudo-classes) narrowly, and never put a space before the colon (a:hover, nota :hover). - Reach for
transition-property/-duration/-delaycomma-separated lists when different properties need to change at different speeds. - Switch from
transitiontoanimation+@keyframesas soon as a single property needs more than one target state, or the effect needs to loop indefinitely (infinite). - Pair 3D
transforms withtransform-style: preserve-3d,backface-visibility: hidden, and aperspectivevalue to build convincing flip/rotate effects. - Handle text casing issues (
text-transform) in CSS rather than “fixing” data at the source, since presentation is CSS’s responsibility.
Search Terms
css · features · html · web · fundamentals · frontend · development · controlling · text · corners · gradient · shadow · transitions · animation · animations · box · gradients · shadows · applying · cards · color · functions · hover · page