Skip to content

Visual Styling & Animation

Everything on this page is paint, not layout: the properties that decide how a box looks once its size and position are settled, plus how those looks change over time.

color: #f43f5e; /* hex, 6 digits */
color: #f43f5e80; /* hex + alpha, 8 digits */
color: #f35; /* 3-digit shorthand = #ff3355 */
color: rgb(244 63 94); /* modern: space-separated, no commas */
color: rgb(244 63 94 / 50%); /* alpha after a slash */
color: hsl(350 89% 60%); /* hue 0-360, saturation %, lightness % */
color: hsl(350 89% 60% / 0.5);
color: oklch(0.65 0.22 15); /* lightness 0-1, chroma, hue */
color: oklch(0.65 0.22 15 / 50%);

The legacy comma forms (rgb(244, 63, 94), rgba(...)) still work everywhere and are equivalent; rgba() and hsla() are now aliases of rgb() and hsl().

hsl is easy to reason about but perceptually wrong: hsl(60 100% 50%) (yellow) is far brighter to the eye than hsl(240 100% 50%) (blue), despite both claiming 50% lightness. Palettes built by rotating hue in HSL come out uneven.

oklch is perceptually uniform: equal lightness values look equally bright, so a palette generated by varying hue at constant L and C is visually consistent. It also reaches colours outside sRGB on wide-gamut displays.

:root {
--brand-400: oklch(0.72 0.15 259);
--brand-500: oklch(0.62 0.19 259); /* same hue and feel, one step darker */
--brand-600: oklch(0.53 0.20 259);
}

Supported in Chrome 111+, Safari 15.4+, Firefox 113+. Tailwind v4 ships its default palette in oklch for exactly this reason.

The keyword currentColor resolves to the element’s computed color. It makes components inherit a single colour decision:

.icon { fill: currentColor; stroke: currentColor; }
.alert { color: crimson; border: 1px solid currentColor; }
.alert::before { background: currentColor; }

Change color once and the border, the icon, and the marker all follow.

Blends two colours in a named space — the clean way to derive hover states from a token:

.btn {
background: var(--brand);
}
.btn:hover {
background: color-mix(in oklch, var(--brand) 85%, black);
}
.btn-subtle {
background: color-mix(in oklch, var(--brand) 12%, transparent);
}

Supported in Chrome 111+, Safari 16.2+, Firefox 113+.

opacity applies to the whole element and all its descendants, and creates a stacking context. An alpha channel applies to one colour only.

.a { background: rgb(0 0 0 / 0.5); } /* translucent background, opaque text */
.b { opacity: 0.5; } /* everything inside fades, text included */
.hero {
background-color: #111;
background-image: url("/hero.jpg");
background-size: cover; /* also: contain, 100% auto, 300px 200px */
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed; /* scroll | local | fixed */
background-clip: border-box; /* padding-box | content-box | text */
}

The shorthand takes everything; the / separates position from size:

.hero { background: #111 url("/hero.jpg") center / cover no-repeat; }

Comma-separated. The first layer is on top.

.card {
background:
linear-gradient(rgb(0 0 0 / 0.6), rgb(0 0 0 / 0.6)), /* overlay, on top */
url("/photo.jpg") center / cover; /* image, underneath */
}

background-color is always the bottom layer and can only be set once.

A gradient is an image, so it goes in background-image (or anywhere an image is accepted).

/* Linear: direction first, then colour stops */
background-image: linear-gradient(to right, #f43f5e, #8b5cf6);
background-image: linear-gradient(135deg, #f43f5e 0%, #8b5cf6 100%);
/* 0deg points up; angles increase clockwise */
/* Radial */
background-image: radial-gradient(circle at 30% 20%, #fff, #6366f1 70%);
background-image: radial-gradient(ellipse farthest-corner, #fff, #000);
/* Conic — sweeps around a centre point */
background-image: conic-gradient(from 180deg, red, yellow, lime, aqua, blue, magenta, red);
/* Repeating */
background-image: repeating-linear-gradient(
45deg, #eee 0 10px, #ddd 10px 20px
);

Hard stops come from giving two stops the same position, which produces stripes instead of a blend:

/* a two-colour split at 50% with no blur */
background-image: linear-gradient(to right, #f43f5e 50%, #8b5cf6 50%);
/* the double-position shorthand does the same */
background-image: linear-gradient(to right, #f43f5e 0 50%, #8b5cf6 50% 100%);

Gradients interpolate in sRGB by default, which can pass through a muddy grey between complementary colours. Specify a space to fix it:

background-image: linear-gradient(in oklch, blue, yellow);
.gradient-text {
background-image: linear-gradient(90deg, #f43f5e, #8b5cf6);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}

The -webkit- prefix is still required in Safari.

.box {
border: 2px solid #333; /* width style color */
border-block-end: 4px dashed red; /* one logical side */
border-style: solid dashed; /* per-side, TRBL order */
}

Without a border-style, a border does not render — border: 2px red shows nothing because the default style is none.

An outline is drawn outside the border and takes no space in layout, so toggling it never shifts anything. That is what makes it the correct focus indicator.

:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px; /* gap between element and outline; negative pulls it inward */
}

Since it follows border-radius, an outline on a rounded button is rounded too.

border-radius: 8px; /* all corners */
border-radius: 8px 0; /* TL+BR | TR+BL */
border-radius: 8px 8px 0 0; /* TL TR BR BL, clockwise */
border-radius: 50%; /* ellipse / circle on a square */
border-radius: 20px / 40px; /* elliptical: horizontal / vertical radii */
border-radius: 100vmax; /* pill, regardless of size */

Nested rounded corners look wrong when inner and outer radii match. The rule is inner = outer - padding:

.card { border-radius: 16px; padding: 8px; }
.card > img { border-radius: 8px; }
box-shadow: <x> <y> <blur> <spread> <color>;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.12);
box-shadow: 0 10px 30px -10px rgb(0 0 0 / 0.4); /* negative spread tightens it */
box-shadow: inset 0 2px 4px rgb(0 0 0 / 0.06); /* inner shadow */

spread grows (or with a negative value shrinks) the shadow before blurring. A 0 0 0 3px shadow with no blur is a ring, which is how focus rings are usually built:

.input:focus-visible {
box-shadow: 0 0 0 3px color-mix(in oklch, var(--brand) 40%, transparent);
}

Multiple shadows are comma-separated, first on top. Layering several soft shadows reads as far more natural than one big one:

.card {
box-shadow:
0 1px 2px rgb(0 0 0 / 0.06),
0 4px 8px rgb(0 0 0 / 0.06),
0 16px 32px rgb(0 0 0 / 0.06);
}

box-shadow follows the border box and its radius. To shadow the actual silhouette of a transparent PNG or an SVG, use filter: drop-shadow(0 4px 8px rgb(0 0 0 / 0.3)) instead.

Same syntax, no spread:

text-shadow: 0 1px 2px rgb(0 0 0 / 0.5);
text-shadow: 1px 1px 0 #fff, -1px -1px 0 #fff; /* cheap outline */
font: italic small-caps 700 1.125rem/1.5 "Inter", system-ui, sans-serif;
/* style variant weight size/line-height family */

font-size and font-family are mandatory, in that order, last. Anything omitted resets to its initial value — so font: 1rem sans-serif silently wipes any inherited font-weight and font-style. That makes the shorthand risky except in resets, where it is genuinely useful:

button, input, select, textarea { font: inherit; }
.text {
font-family: "Inter", system-ui, sans-serif;
font-size: 1rem;
font-weight: 500; /* 100–900, or normal/bold */
font-style: italic;
line-height: 1.5; /* unitless: multiplies THIS element's font-size */
letter-spacing: -0.01em;
word-spacing: 0.1em;
font-variant-numeric: tabular-nums; /* fixed-width digits for tables */
font-feature-settings: "ss01" 1; /* OpenType stylistic sets */
}

Set negative letter-spacing on large headings (around -0.02em) and positive spacing on small uppercase text (around 0.05em). Body text should almost always be left alone.

text-align: start; /* start | end | center | justify */
text-transform: uppercase; /* capitalize | lowercase | none */
text-decoration: underline;
text-decoration-color: var(--brand);
text-decoration-thickness: 2px;
text-underline-offset: 0.2em;
white-space: pre-wrap; /* nowrap | pre | pre-line | pre-wrap | normal */
overflow-wrap: break-word; /* break long unbreakable strings */
hyphens: auto; /* needs lang="" on the document */
text-wrap: balance; /* even line lengths — headings only */
text-wrap: pretty; /* avoid orphans in body copy */

text-wrap: balance (Chrome 114+, Safari 17.5+, Firefox 121+) is the right way to stop a heading from leaving one word on the last line. It is limited to a handful of lines by the browser, so do not apply it to paragraphs; text-wrap: pretty is the paragraph version.

Multi-line truncation:

.clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}

Non-standard, but implemented in every browser.

@font-face {
font-family: "Inter";
src: url("/fonts/inter-var.woff2") format("woff2-variations");
font-weight: 100 900; /* a variable font's supported range */
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF; /* only download for Latin text */
}
  • woff2 only. Every browser that matters supports it, and it is the smallest.
  • font-display controls the loading behaviour: swap shows the fallback immediately and swaps when ready (a flash of unstyled text, no invisible text); optional gives the font 100ms and otherwise skips it for this page load — the best choice for performance; block risks invisible text.
  • unicode-range splits a font into subsets that download on demand.
  • Declare each weight as a separate @font-face with the same font-family and a different font-weight. Do not rely on the browser synthesising bold or italic — the result is visibly worse.

Always preload the fonts used above the fold, and self-host rather than using a third-party CDN (an extra connection costs more than the file):

<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />

A system font stack costs nothing at all and looks native:

body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}

transform moves, rotates, scales, or skews a box after layout. The element’s layout box does not change, so transforms never reflow the page — which is why they are cheap.

transform: translate(10px, 20px);
transform: translateX(50%); /* % is relative to the ELEMENT's own size */
transform: rotate(45deg);
transform: scale(1.05);
transform: scale(1.5, 0.5);
transform: skewX(-10deg);
transform: translate(-50%, -50%) rotate(3deg) scale(1.1); /* applied right to left */

Order matters: rotate(45deg) translateX(100px) moves along the rotated axis; translateX(100px) rotate(45deg) does not.

.el {
translate: 10px 20px;
rotate: 45deg;
scale: 1.1;
}

Supported in Chrome 104+, Safari 14.1+, Firefox 72+. These compose independently, so you can transition scale on hover without clobbering a translate set elsewhere — a real problem with the combined transform property.

transform-origin: center; /* default: 50% 50% */
transform-origin: top left;
transform-origin: 0 100%;
.scene { perspective: 800px; } /* on the PARENT */
.card { transform-style: preserve-3d; transition: transform 0.6s; }
.card:hover { transform: rotateY(180deg); }
.face { backface-visibility: hidden; }

perspective on a parent makes children share one vanishing point; perspective() inside a child’s own transform gives it an independent one.

A transition animates a property when its value changes, from whatever caused the change (:hover, a class toggle, an inline style from JS).

.btn {
background: var(--brand);
transition: background-color 150ms ease-out, transform 150ms ease-out;
}
.btn:hover { background: var(--brand-600); transform: translateY(-1px); }

Longhands:

transition-property: opacity, transform;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: 0ms;

Shorthand order is property duration timing-function delay. The first time value is the duration and the second is the delay.

Value Feel
linear mechanical; correct for continuous loops and colour fades
ease the default; fast start, slow end
ease-out fast then settles — best for things entering or responding to input
ease-in slow then fast — best for things leaving
ease-in-out symmetric; good for movement between two resting states
cubic-bezier(x1,y1,x2,y2) custom; y may exceed 0–1 to overshoot
steps(n, jump-end) discrete jumps — sprite sheets, typewriter effects

ease-out at 150–250ms covers most UI. Anything above 400ms feels sluggish for an interaction.

Only properties with interpolatable values — lengths, colours, numbers, transforms, filters. Not display, font-family, or background-image (except gradients between compatible gradients).

height: auto is the classic failure. Reliable workarounds:

  • Animate max-height to a value larger than the content (imprecise timing, but simple).
  • Animate grid-template-rows: 0fr1fr on a wrapper — this works precisely and is the best modern option:
.collapsible {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 250ms ease;
}
.collapsible > div { overflow: hidden; }
.collapsible.open { grid-template-rows: 1fr; }
  • Use transform: scaleY() when a visual approximation is acceptable.

Newer CSS lets an element animate as it appears and disappears:

.popover {
transition: opacity 200ms, display 200ms allow-discrete;
opacity: 1;
}
.popover[hidden] { opacity: 0; display: none; }
@starting-style {
.popover { opacity: 0; } /* the value to animate FROM on first render */
}

transition-behavior: allow-discrete and @starting-style are supported in Chrome 117+, Safari 17.4+, Firefox 129+. Treat them as progressive enhancement — without them the element simply appears instantly.

Use @keyframes when the change is not triggered by a state change, needs more than two steps, or must repeat.

@keyframes fade-in-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.toast {
animation: fade-in-up 250ms ease-out both;
}

Full property set:

.el {
animation-name: pulse;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-delay: 0s;
animation-iteration-count: infinite; /* or a number */
animation-direction: alternate; /* normal|reverse|alternate|alternate-reverse */
animation-fill-mode: both; /* none|forwards|backwards|both */
animation-play-state: running; /* paused */
}

animation-fill-mode is the one people miss:

  • forwards — keep the final keyframe’s values after the animation ends.
  • backwards — apply the first keyframe’s values during the delay.
  • both — do both. Usually what you want for an entrance animation, otherwise the element flashes at its natural state before the delay elapses.

The timing function applies between keyframes, not across the whole animation, so a multi-step ease-in-out animation eases at each step.

Always pair animation with a reduced-motion guard:

@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

The browser can animate transform, opacity, and filter on the compositor thread without re-running layout or paint. Everything else — width, height, top, left, margin, box-shadow — triggers layout or paint on every frame and will drop frames on cheap hardware.

Practical translation: animate position with transform: translate(), not left/top. Animate size with transform: scale(), not width/height. For a moving shadow, animate the opacity of a pseudo-element that holds the larger shadow.

will-change tells the browser to prepare an element for a specific change, usually by promoting it to its own compositor layer:

.modal { will-change: transform, opacity; }
  • Use oklch for palettes and color-mix() to derive variants; currentColor keeps components coherent.
  • In multiple backgrounds and multiple shadows, the first layer is on top.
  • outline takes no layout space and follows border-radius — it is the correct focus ring.
  • Use unitless line-height; avoid the font shorthand outside resets.
  • font-display: swap or optional, woff2 only, self-host and preload.
  • Transforms happen after layout, so they are free of reflow — but they create a stacking context and trap position: fixed.
  • ease-out, 150–250ms, covers most interface transitions.
  • Animate transform, opacity, and filter. Everything else costs layout or paint.
  • Guard every animation with prefers-reduced-motion.