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.
Colour
Section titled “Colour”Notations
Section titled “Notations”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().
Why oklch
Section titled “Why oklch”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.
currentColor
Section titled “currentColor”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.
color-mix()
Section titled “color-mix()”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 vs alpha
Section titled “opacity vs alpha”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 */Backgrounds
Section titled “Backgrounds”.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; }Multiple backgrounds
Section titled “Multiple backgrounds”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.
Gradients
Section titled “Gradients”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
Section titled “Gradient text”.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.
Borders, outlines, radius
Section titled “Borders, outlines, radius”.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.
outline
Section titled “outline”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
Section titled “border-radius”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; }Shadows
Section titled “Shadows”box-shadow
Section titled “box-shadow”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.
text-shadow
Section titled “text-shadow”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 */Typography
Section titled “Typography”The font shorthand
Section titled “The font shorthand”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; }Longhands worth knowing
Section titled “Longhands worth knowing”.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 properties
Section titled “Text properties”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
Section titled “@font-face”@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 */}woff2only. Every browser that matters supports it, and it is the smallest.font-displaycontrols the loading behaviour:swapshows the fallback immediately and swaps when ready (a flash of unstyled text, no invisible text);optionalgives the font 100ms and otherwise skips it for this page load — the best choice for performance;blockrisks invisible text.unicode-rangesplits a font into subsets that download on demand.- Declare each weight as a separate
@font-facewith the samefont-familyand a differentfont-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;}Transforms
Section titled “Transforms”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.
Individual transform properties
Section titled “Individual transform properties”.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
Section titled “transform-origin”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.
Transitions
Section titled “Transitions”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.
Timing functions
Section titled “Timing functions”| 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.
What can be transitioned
Section titled “What can be transitioned”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-heightto a value larger than the content (imprecise timing, but simple). - Animate
grid-template-rows: 0fr→1fron 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.
Transitioning in and out of display: none
Section titled “Transitioning in and out of display: none”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.
Keyframe animations
Section titled “Keyframe animations”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; }}Performance and will-change
Section titled “Performance and will-change”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; }Key points
Section titled “Key points”- Use
oklchfor palettes andcolor-mix()to derive variants;currentColorkeeps components coherent. - In multiple backgrounds and multiple shadows, the first layer is on top.
outlinetakes no layout space and followsborder-radius— it is the correct focus ring.- Use unitless
line-height; avoid thefontshorthand outside resets. font-display: swaporoptional,woff2only, 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, andfilter. Everything else costs layout or paint. - Guard every animation with
prefers-reduced-motion.