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, darkred and whitesmoke. 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, and currentColor, which means "whatever the color property is on this element". A border or an SVG icon set to currentColor automatically 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 #f80 is #ff8800.
  • #rrggbbaa: a fourth pair for opacity (alpha), so #2e5e8c80 is 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:

  • rem for font sizes and most spacing, so the page follows the reader's setting.
  • em for spacing that belongs to one component's text, such as button padding or an icon next to a label.
  • px for 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-display decides what happens while the file loads. swap shows 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>. The crossorigin attribute 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 at 1rem (16px by default). Smaller body text is a frequent complaint, especially on phones.
  • line-height: about 1.5 for 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.5em is 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: 65ch on a text column does it.
  • font-weight: 400 is normal, 700 bold. A web font only has the weights you load; otherwise the browser fakes bold, which looks smudged.
  • text-wrap: balance spreads 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.