Three ways to add CSS to a page

HTML describes what the content is; CSS (Cascading Style Sheets) describes how it looks. See What Are HTML and CSS? for how the two fit together. There are three places to put CSS:

  1. An external stylesheet: a separate .css file, linked from the <head> with a <link> element. Every page of the site shares it, and the browser caches it, so the second page loads faster. This is the normal choice.
  2. An internal stylesheet: a <style> element in the <head>. Useful for a single page or a quick test, but other pages cannot reuse it.
  3. Inline styles: a style attribute on one element. Hard to maintain, impossible to reuse, and they beat almost every other rule (see the cascade). A strict Content Security Policy may also block them.

An external stylesheet on the Kestrel Energy customer portal:

<head>
  <meta charset="utf-8">
  <title>Meter readings — Kestrel Energy</title>
  <link rel="stylesheet" href="css/portal.css">
</head>

The live example below uses an internal stylesheet and one inline style, because an example cannot load a separate file. The Result frame under the code is the page as your browser draws it.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Planned maintenance</title>
    <style>
      h1 { font-size: 1.5rem; color: darkblue; }
      p { color: dimgray; }
    </style>
  </head>
  <body>
    <h1>Planned maintenance</h1>
    <p>The customer portal is offline on Sunday from 02:00 to 04:00.</p>
    <p style="color: darkred;">Meter readings sent in that window are saved later.</p>
  </body>
</html>

The anatomy of a rule

A stylesheet is a list of rules. A CSS rule (also called a rule set) has two parts: a selector that says which elements it applies to, and a declaration block in curly braces that says what to change.

/* selector     declaration block                 */
.meter-reading {
  color: darkgreen;      /* declaration: property "color", value "darkgreen" */
  font-weight: bold;     /* declarations end with a semicolon */
}

Each line inside the braces is a CSS declaration: a property name, a colon, and a value. Property names are fixed words from the CSS specifications (color, margin, font-size); the value must be something that property accepts. Comments are written /* like this */; CSS has no // line comments.

CSS is forgiving in a specific way: a declaration the browser does not understand is dropped on its own, and the rest of the rule still applies. That keeps old browsers working when new properties appear, but it also means a typo fails silently.

<p class="reading">Last reading: 4,182 kWh</p>
.reading {
  color: darkgreen;
  font-size: 20pxx;   /* typo: this one declaration is ignored */
  font-weight: bold;  /* still applied */
}

Selectors

A CSS selector is a pattern that matches elements in the document tree. The same selector syntax is used by JavaScript's querySelector(), so learning it once pays off twice.

Selector Matches
p Every <p> element (a type selector)
.notice Every element with notice in its class attribute
#outage-map The element with id="outage-map" (an ID selector)
* Every element (the universal selector)
[required] Elements that have a required attribute
a[href^="https:"] Links whose href starts with https:
p.notice <p> elements that also have class notice (no space: both conditions on one element)
h1, h2, h3 All three (a selector list: one rule for several selectors)

Class and ID selectors

A class selector is a dot followed by a class name: .notice matches every element whose class attribute contains the word notice. An element can have several classes, separated by spaces (class="notice warning"), and the same class can be used on any number of elements. Classes are the workhorse of CSS: they describe a role ("this is a notice") that you can style in one place.

An ID selector (#notice) matches the one element with that id. IDs are useful as link targets (href="#notice") and for connecting a <label> to its input, but they make poor style hooks: they cannot be reused, and their high specificity makes them hard to override.

<nav aria-label="Portal">
  <ul class="menu">
    <li><a href="#" aria-current="page">Dashboard</a></li>
    <li><a href="#">Meter readings</a></li>
    <li><a href="#">Invoices</a></li>
  </ul>
</nav>
<p class="notice">Your next invoice is ready.</p>
.menu { list-style: none; padding: 0; }
.menu a { color: navy; }
a[aria-current="page"] { font-weight: bold; }
.notice { border-left: 4px solid navy; padding-left: 0.5rem; }

The attribute selector a[aria-current="page"] styles the current page in the menu using the same attribute that tells screen reader users which page they are on. Styling the meaning that is already in the HTML keeps the two in step.

Pseudo-classes

A pseudo-class selects elements by their state or position, something that is not written as an attribute in the HTML. It starts with one colon:

  • User interaction: :hover (pointer over it), :focus (has keyboard focus), :focus-visible (has focus and the browser decides a focus ring should show, typically after keyboard use), :active (being pressed).
  • Links and forms: :visited, :checked, :disabled, :required, :invalid.
  • Position in the tree: :first-child, :last-child, :nth-child(odd), :nth-child(3n).
  • Logic: :not(.paid), :is(h2, h3), :where(…) and :has(…), covered in Custom Properties and Modern CSS.
<ul class="readings">
  <li>January: 312 kWh</li>
  <li>February: 288 kWh</li>
  <li>March: 251 kWh</li>
  <li>April: 197 kWh</li>
</ul>
<p><a href="#" class="download">Download as CSV</a></p>
.readings li:nth-child(odd) { background: whitesmoke; }
.readings li:first-child { font-weight: bold; }
.download:hover { text-decoration: none; }
.download:focus-visible { outline: 3px solid navy; outline-offset: 2px; }

Hover over the link, then click inside the result frame and press Tab to see the focus style. Never remove the focus outline without putting a clearly visible one back: keyboard users depend on it (see Accessibility Basics).

Pseudo-elements

A pseudo-element selects a part of an element, or creates a box that is not in the HTML at all. It starts with two colons: ::before and ::after insert generated content (they need a content property), ::first-line and ::first-letter style part of the text, ::marker styles list bullets and numbers, and ::placeholder styles the hint text in an input.

<p class="tip">Read your meter at the same time each month, so the
numbers are easy to compare. The portal draws a chart of your usage
once you have entered three readings.</p>
<ul class="steps">
  <li>Find the meter</li>
  <li>Write down the number</li>
  <li>Enter it in the portal</li>
</ul>
.tip::before { content: "Tip: "; font-weight: bold; color: darkgreen; }
.tip::first-line { text-transform: uppercase; }
.steps li::marker { color: darkgreen; }

Combinators

A combinator joins two selectors and describes how the elements are related in the tree:

Combinator Example Matches
Descendant (a space) .outage p Every p anywhere inside .outage
Child > .outage > p Only p elements that are direct children of .outage
Next sibling + h2 + p The p immediately after an h2, with the same parent
Subsequent sibling ~ h2 ~ footer Every footer after an h2, with the same parent
<article class="outage">
  <h2>Outage in the Riverside district</h2>
  <p>Reported at 14:05. About 1,200 addresses are affected.</p>
  <p>Engineers are on site.</p>
  <footer>
    <p>Last updated 14:40</p>
  </footer>
</article>
.outage p { margin: 0.25rem 0; }       /* all four p elements */
.outage > p { color: darkslategray; }  /* not the p in the footer */
h2 + p { font-weight: bold; }          /* only the first p */
h2 ~ footer { font-size: 0.875rem; color: dimgray; }

Selectors are read by the browser from right to left: for .outage p it takes each p and walks up to look for a .outage ancestor. You rarely need to think about speed, but long chains like body main article div ul li a are fragile: any change in the HTML breaks them. Prefer one or two levels and a class.

The cascade: which rule wins

Often several rules set the same property on the same element. The cascade is the algorithm that decides which declaration wins. It is the "C" in CSS. The browser collects every declaration that applies, then compares them on the criteria below, in this order. The first criterion where they differ decides; later criteria only matter when all earlier ones are equal.

The cascade compares competing declarations in six steps: origin and importance, context, inline style attribute, cascade layers, specificity, and order of appearance. The first step where they differ decides. 1 Origin and importance browser, user, author; !important 2 Context shadow DOM (web components) 3 Inline style attribute style="…" 4 Cascade layers @layer 5 Specificity IDs, classes, types 6 Order of appearance the last one wins
The cascade, highest priority first (CSS Cascading and Inheritance Level 5). In everyday work the last two steps decide most conflicts.

Origin and importance

Declarations come from three origins: the browser's built-in stylesheet (the user agent styles that make headings big and links blue), the user (settings such as a minimum font size, or a user stylesheet), and the author (you, the site's stylesheets). Normal author styles beat user styles, which beat browser defaults. That is why your h1 { font-size: 1.5rem; } simply replaces the browser's heading size.

Order of appearance

When everything else is equal, the declaration that comes last wins: later in the same stylesheet, or in a stylesheet linked later. What counts is the order of the rules in the CSS, not the order of the class names in the HTML.

<p class="notice warning">Payment overdue</p>
.warning { background: lightyellow; }
.notice  { background: lightblue; }   /* same specificity, later: wins */

The background is light blue. Writing class="warning notice" instead would change nothing.

(CSS Cascading Level 6 adds one more step for @scope rules: scope proximity, between specificity and order of appearance.)

Specificity

Specificity is a weight the browser calculates for each selector. When two declarations come from the same origin and layer, the one with the more specific selector wins, wherever it appears in the stylesheet. Specificity is written as three numbers (A, B, C):

  • A: the number of ID selectors.
  • B: the number of class selectors, attribute selectors and pseudo-classes.
  • C: the number of type selectors and pseudo-elements.

The universal selector * and the combinators count for nothing. The numbers are compared from left to right, like version numbers: the first column that differs decides. They do not "carry over": eleven classes (0, 11, 0) still lose to one ID (1, 0, 0).

Calculating the specificity of the selector #outage-map .notice p:first-child: one ID gives A equals 1, the class .notice and the pseudo-class :first-child give B equals 2, the type selector p gives C equals 1, so the specificity is 1, 2, 1. #outage-map .notice p :first-child ID class type pseudo-class A IDs 1 B classes 2 C types 1 = (1, 2, 1)
Each simple selector adds one to its column. The descendant spaces count for nothing.
Selector Specificity Why
* (0, 0, 0) The universal selector counts for nothing
li (0, 0, 1) One type
ul li a (0, 0, 3) Three types
li::marker (0, 0, 2) A type and a pseudo-element
.notice (0, 1, 0) One class: beats ul li a
nav a:hover (0, 1, 2) A pseudo-class and two types
a[aria-current="page"] (0, 1, 1) An attribute selector counts like a class
#notice (1, 0, 0) One ID: beats any number of classes
:is(#notice, .tip) p (1, 0, 1) :is(), :not() and :has() take the specificity of their most specific argument
:where(#notice, .tip) p (0, 0, 1) :where() always counts as zero

In the example below the ID rule wins, even though two rules come after it:

<p id="status" class="status">Meter reading received</p>
#status { color: darkgreen; }   /* (1, 0, 0): wins */
.status { color: darkred; }     /* (0, 1, 0) */
p       { color: navy; }        /* (0, 0, 1) */

Inline style attributes are not part of the specificity count. They are their own step in the cascade, above layers and specificity, so a style attribute beats every normal rule in a stylesheet.

!important

Adding !important after a value moves that declaration into a higher-priority group of the cascade: it beats every normal declaration, including inline styles, whatever their specificity.

<p class="renewal" style="color: navy;">Your contract renews on 1 March.</p>
.renewal { color: darkred !important; }   /* beats the inline style */

For important declarations the origin order is reversed: an important user style beats an important author style, which beats an important browser style. That reversal is deliberate. It lets a person with low vision force larger text or higher contrast, whatever the site says.

The problem with !important in your own stylesheets is that the only way to override it is another !important with higher specificity, later in the file, or in an earlier cascade layer. Each one makes the next fight harder. Reasonable uses are rare: utility classes that must always win (such as a .visually-hidden class), or overriding inline styles from third-party code you cannot change.

Inheritance

Some properties are passed from a parent element to its children when the children have no value of their own. This is CSS inheritance (not the same as inheritance between classes in programming). As a rule of thumb, text properties inherit (color, font-family, font-size, line-height, text-align), while box properties do not (margin, padding, border, width, background). That is why setting font-family once on body changes the whole page, while a border on a section does not appear around every paragraph inside it.

<section class="card">
  <h2>Monthly usage</h2>
  <p>You used <strong>251 kWh</strong> in March.</p>
  <button type="button">Show details</button>
</section>
.card {
  color: darkslategray;        /* inherited by h2, p and strong */
  font-family: Georgia, serif; /* inherited, except by the button */
  border: 1px solid gray;      /* not inherited: only the section */
  padding: 1rem;
}

Notice the button: form controls get their font from the browser stylesheet, and that value beats inheritance (an inherited value only applies when no rule sets the property on the element itself). The usual fix is button, input, select, textarea { font: inherit; }.

Four keywords work on every property and control this explicitly:

  • inherit: take the parent's value, even for a property that normally does not inherit.
  • initial: use the property's initial value from the specification (for display that is inline, even on a div).
  • unset: act like inherit for inherited properties and like initial for the rest.
  • revert: go back to what the browser's own stylesheet (or the user's) would give, for example the normal block display of a div.

Cascade layers

Large stylesheets mix several kinds of CSS: a reset, base styles for plain elements, components, utilities, and third-party styles. Specificity fights between them are common. A cascade layer, declared with @layer, puts rules into a named group, and the cascade compares layers before specificity. A rule in a later layer beats a rule in an earlier layer, however specific the earlier one is. Cascade layers have been Baseline widely available since September 2024.

<p id="banner" class="alert">Read-only mode until 06:00.</p>
<p class="alert note">Changes made now are not saved.</p>
/* Declare the order once: later layers win */
@layer base, components;

@layer base {
  #banner { color: navy; }   /* (1, 0, 0), but in the earlier layer */
}

@layer components {
  .alert { color: darkred; border: 2px solid; padding: 0.5rem; }
}

.note { color: black; }      /* not in a layer: beats every layer */

The result shows both rules of layers:

  • The first paragraph is dark red: components is declared after base, so .alert beats #banner despite its lower specificity.
  • The second paragraph is black: styles that are not in any layer beat all layered styles. (The border is black too, because a border without a color uses the text color.)

Within one layer, specificity and order of appearance work as before. For !important declarations the layer order is reversed, just like the origins: an important declaration in the first layer wins. You can also load a whole file into a layer: @import url("reset.css") layer(base);.