Colors, Units and Typography
Choosing colors, sizes and fonts: color formats from hex to oklch, absolute and relative units, rem versus em, web fonts and font loading, and readable text.
Color formats
CSS accepts colors in several notations. Most of them describe the
same set of colors (the sRGB color space that ordinary screens show)
and differ only in how you write them. Any property that takes a
color (color, background-color,
border-color, outline-color) accepts all
of them.
-
Named colors: 148 keywords such as
navy,darkredandwhitesmoke. Handy for examples, limited for a real design. - Hex:
#2e5e8c. -
Functions:
rgb(46 94 140),hsl(209 51% 36%),oklch(0.47 0.09 250). -
Keywords:
transparent, andcurrentColor, which means "whatever thecolorproperty is on this element". A border or an SVG icon set tocurrentColorautomatically matches the text.
Hex colors
A hex color is written as
# followed by three pairs of
hexadecimal
digits for red, green and blue, each from 00 (none) to
ff (255, full). #2e5e8c means red 46,
green 94, blue 140: a medium dark blue. Two shorter and longer forms
exist:
-
#rgb: each digit is doubled, so#f80is#ff8800. -
#rrggbbaa: a fourth pair for opacity (alpha), so#2e5e8c80is the same blue at about 50% opacity.
rgb() and hsl()
rgb() takes the same three channels as decimal numbers
(0 to 255) or percentages. hsl() describes a color as a
hue (an angle on the color wheel: 0 red, 120 green,
240 blue), a saturation and a
lightness percentage, which is easier to adjust by
hand: make it lighter by raising the last number. Both take an
optional opacity after a slash:
.badge {
color: rgb(46 94 140); /* modern syntax: spaces */
border-color: rgb(46, 94, 140); /* older comma syntax: same color */
background: hsl(209 51% 36% / 0.1); /* same hue, 10% opacity */
}
oklch()
The weakness of hsl() is that its lightness is a
number, not what your eye sees: at 50% lightness,
yellow looks far brighter than blue. That makes it hard to build a
palette where every color has the same contrast with white text.
oklch() uses the OKLCH color space, which was designed to match human perception. It takes three values: L, perceived lightness from 0 (black) to 1 (white); C, chroma or colorfulness (0 is gray; colors that screens can show stay below about 0.4, and in percentages 100% means 0.4); and H, the hue angle. Keep L and C fixed and change only H, and you get different colors of the same visual weight. It can also describe colors outside sRGB that wide-gamut screens can show; on other screens the browser maps them to the closest color it can display.
<p>Same HSL lightness (40%): yellow, green, blue</p>
<div class="row">
<span class="swatch h1"></span><span class="swatch h2"></span><span class="swatch h3"></span>
</div>
<p>Same OKLCH lightness (0.6): yellow, green, blue</p>
<div class="row">
<span class="swatch o1"></span><span class="swatch o2"></span><span class="swatch o3"></span>
</div>
.row { display: flex; gap: 0.5rem; }
.swatch { width: 4rem; height: 2.5rem; }
.h1 { background: hsl(60 100% 40%); }
.h2 { background: hsl(120 100% 40%); }
.h3 { background: hsl(240 100% 40%); }
.o1 { background: oklch(0.6 0.12 100); }
.o2 { background: oklch(0.6 0.12 145); }
.o3 { background: oklch(0.6 0.12 265); }
In the top row the yellow looks much lighter than the blue, although
HSL calls them equally light. In the bottom row the three look about
equally light. oklch() has been Baseline widely
available since November 2025, so it is safe to use as of 2026.
Mixing colors with color-mix()
color-mix() blends two colors in a color space you
choose. It is a clean way to make a lighter or darker version of a
brand color without picking a new value by hand. Like
oklch(), it has been Baseline widely available since
November 2025.
<p>
<span class="tag">Paid</span>
<span class="tag overdue">Overdue</span>
</p>
.tag {
--brand: oklch(0.45 0.12 250);
color: var(--brand);
background: color-mix(in oklch, var(--brand) 12%, white);
border: 1px solid currentColor;
padding: 0.25rem 0.5rem;
}
.overdue { --brand: oklch(0.45 0.15 25); }
The --brand lines are custom properties (CSS
variables), explained in
Custom Properties and Modern CSS. The border uses currentColor, so it follows the text
color without being repeated.
Color and contrast
Color choices decide whether people can read the page. The Web Content Accessibility Guidelines (WCAG 2.2) set measurable minimums at level AA, the level most laws refer to:
- 4.5:1 contrast between normal text and its background (success criterion 1.4.3).
- 3:1 for large text: at least 18 point (about 24px), or 14 point bold (about 18.66px).
- 3:1 for the parts of controls and graphics that people need to see, such as input borders, focus indicators and chart lines (1.4.11).
The ratio runs from 1:1 (the same color) to 21:1 (black on white). Developer tools show it when you inspect a text color; see Accessibility Basics for the details. Also, never use color as the only way to show something: an overdue invoice marked only in red is invisible to many color-blind readers, so add a word or an icon.
| Text on white | Contrast | Normal text (AA) |
|---|---|---|
gray (#808080) |
3.95:1 | Fails |
dimgray (#696969) |
5.49:1 | Passes |
#2e5e8c |
6.78:1 | Passes |
navy |
16.01:1 | Passes |
Units: absolute and relative
Every length in CSS has a unit, except zero (margin: 0
needs none). Units fall into two groups:
absolute units are a fixed size,
relative units are calculated from something else,
such as a font size or the size of the window.
| Unit | Relative to | Typical use |
|---|---|---|
px |
Absolute: 1/96 of an inch (a reference pixel) | Borders, small fixed details |
pt, cm, mm,
in
|
Absolute: physical units (1pt is 1/72 inch) | Print stylesheets |
rem |
The font size of the root (html) element |
Font sizes, spacing |
em |
The font size of the element itself | Padding that scales with the text |
% |
Depends on the property; for width, the
parent's width
|
Fluid widths |
ch |
The width of the "0" character of the current font | Line length, input widths |
vw, vh |
1% of the viewport's width or height | Full-screen sections |
svh, lvh, dvh |
1% of the small, large or dynamic viewport height | Full-height layouts on phones |
Grid layouts add the fr unit, a fraction of the free
space; see CSS Grid.
Pixels
A CSS pixel is not a pixel on the screen. The specification defines
it as 1/96 of an inch, seen at arm's length. A phone with a high
pixel density
uses two or three device pixels for every CSS pixel, so a
16px font is about the same physical size on a phone
and on a laptop. That is why the
viewport meta tag
matters: without it, phones pretend to be a wide desktop screen and
shrink everything.
Relative units
A relative length unit gets its size from something else: a font size, the parent element, or the viewport. Relative units let a design adapt to the reader's settings and to the screen, which is why most sizes in a modern stylesheet are relative.
Percentages are relative to different things for different
properties: for width and for padding and margins, the
width of the containing block (usually the parent); for
font-size, the parent's font size; for
line-height, the element's own font size.
rem versus em
The rem unit ("root em") is the font
size of the root html element. Browsers set that to
16px by default, but readers can change it in their browser
settings, for example to 20px because they have low vision. A
heading of 2rem is then 40px instead of 32px, and the
whole design grows along. A size in px ignores that
setting (zooming still works, but the default text size does not).
The em unit is relative to the font
size of the element itself. For the font-size property,
which cannot refer to itself, it uses the parent's font size. That
makes em compound: a
font-size: 1.2em inside another 1.2em is
1.44 times the original.
<p>With em, nested lists keep growing:</p>
<ul class="em">
<li>Electricity
<ul>
<li>Day rate
<ul>
<li>Weekdays</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>With rem, every nested list has the same size:</p>
<ul class="rem">
<li>Electricity
<ul>
<li>Day rate
<ul>
<li>Weekdays</li>
</ul>
</li>
</ul>
</li>
</ul>
.em ul { font-size: 1.2em; } /* 19.2px, then 23.04px */
.rem ul { font-size: 1.2rem; } /* 19.2px, then 19.2px */
That compounding is also what makes em useful: padding
in em grows with the text of the element. A button with
padding: 0.5em 1em keeps its proportions whether its
font is 14px or 24px. A good default:
-
remfor font sizes and most spacing, so the page follows the reader's setting. -
emfor spacing that belongs to one component's text, such as button padding or an icon next to a label. -
pxfor things that should not scale, such as a 1px border.
Viewport units
A viewport unit is a percentage
of the browser window (the viewport): 1vw is 1% of its
width, 1vh 1% of its height. Inside the result frame
below, the frame itself is the viewport, so 50vw is
half the frame's width, while 50% is half the width of
the parent box.
<div class="box">
<p class="bar pct">width: 50%</p>
<p class="bar vw">width: 50vw</p>
</div>
.box { width: 60%; border: 1px solid gray; padding: 0.5rem; }
.bar { background: lightsteelblue; margin: 0.25rem 0; }
.pct { width: 50%; } /* half of .box */
.vw { width: 50vw; } /* half of the viewport */
On phones, 100vh is a known trap: the address bar
appears and disappears as you scroll, and 100vh is
measured with the bar hidden, so a "full-screen" section is
partly hidden behind it. The newer units fix that:
svh uses the small viewport (bars shown),
lvh the large one (bars hidden), and
dvh the dynamic one, which changes as the bars move.
They have been Baseline widely available since June 2025. Use
min-height: 100svh for a section that must fit on
screen.
Avoid viewport units on their own for font sizes:
font-size: 3vw does not grow when the reader zooms in.
Responsive Design shows how
clamp() combines them with rem.
Font families and fallbacks
font-family takes a list of fonts in
order of preference, called a font stack. The browser uses the first
one that is available. This is
font fallback, and it works per
character: if the first font is installed but has no glyph for a
character (an emoji, Greek or Arabic text), the browser takes that
character from the next font in the list, or from a system font.
End every stack with one of the generic families,
which always exist: serif, sans-serif,
monospace, system-ui (the operating
system's interface font) or cursive. Put names that
contain spaces in quotes.
<p class="ui">Your account: Kestrel Energy customer portal</p>
<p class="serif">Annual statement for 2026</p>
<p class="code">Meter ID: KE-4471-0932</p>
.ui { font-family: system-ui, sans-serif; }
.serif { font-family: Georgia, "Times New Roman", serif; }
.code { font-family: ui-monospace, Menlo, Consolas, monospace; }
The result differs per computer: on Windows the first paragraph
shows Segoe UI, on a Mac San Francisco. That is the point of
system-ui: it looks native everywhere and needs no
download.
Web fonts
A web font is a font file the
browser downloads together with the page, so every visitor sees the
same typeface whether or not it is installed. The rule that declares
one is @font-face: it gives the
font a family name and says where the file is. After that you use
the name in font-family like any other font.
@font-face {
font-family: "Kestrel Sans";
src: url("../fonts/kestrel-sans.woff2") format("woff2");
font-weight: 400 700; /* one variable font file covers this range */
font-style: normal;
font-display: swap; /* show fallback text at once, swap when loaded */
}
body {
font-family: "Kestrel Sans", system-ui, sans-serif;
}
- Format: use WOFF2 (Web Open Font Format 2), a compressed format made for the web and a W3C Recommendation. It is supported in every current browser; older formats (TTF, EOT, SVG fonts) are no longer needed.
- Variable fonts hold a whole range of weights (and sometimes widths) in one file, instead of one file per weight.
-
font-displaydecides what happens while the file loads.swapshows the text in the fallback font straight away and replaces it when the web font arrives. The text is readable sooner, but it may jump a little when the fonts swap; a fallback with similar proportions keeps that small. -
Preload the most important font so the browser
requests it early:
<link rel="preload" href="fonts/kestrel-sans.woff2" as="font" type="font/woff2" crossorigin>. Thecrossoriginattribute is needed even for your own server, because fonts are always fetched in CORS mode.
Readable text
A few properties do most of the work for comfortable reading:
-
font-size: keep body text at1rem(16px by default). Smaller body text is a frequent complaint, especially on phones. -
line-height: about1.5for body text, a little tighter (1.1 to 1.3) for large headings. Write it without a unit: a unitless value is passed to children as a factor, so each child multiplies it by its own font size.1.5emis calculated once, on the parent, and children inherit the fixed result, which makes large headings inside cramped. -
Line length: lines of about 60 to 75 characters
are commonly recommended.
max-width: 65chon a text column does it. -
font-weight:400is normal,700bold. A web font only has the weights you load; otherwise the browser fakes bold, which looks smudged. -
text-wrap: balancespreads a heading's words evenly over its lines, so no single word dangles on the last one. Baseline since 2024; browsers that do not support it simply wrap normally.
<article class="readable">
<h2>Why your monthly payment changed this year</h2>
<p>From 1 January the energy tax per kilowatt-hour went up. We
recalculated your monthly advance payment with the new rate and your
usage over the last twelve months, so that your annual statement is
as close to zero as possible.</p>
</article>
.readable {
max-width: 65ch;
font-family: system-ui, sans-serif;
font-size: 1rem;
line-height: 1.5;
color: #222;
}
.readable h2 {
font-size: 1.5rem;
line-height: 1.2;
text-wrap: balance;
}
WCAG also requires that text can be zoomed to 200% without content
being lost (success criterion 1.4.4, level AA). Relative units and
layouts that let text wrap make that easy; fixed heights in
px around text are what usually break it.