CSS Module 1 – How CSS works
Adding CSS to HTML and browser default styles
Add CSS with a link element, a style element or a style attribute, see what browsers style before your CSS loads, and write a small base stylesheet.
What you will learn
- Add CSS with a link element, a style element and the style attribute, and choose the right one for a job
- Explain what the browser's default (user-agent) styles do before any CSS is written
- Predict which of two linked stylesheets wins when their selectors tie
- Write a small modern base stylesheet instead of copying a large reset
Before you start
What you’ll learn · Before you start
What you will learn
- Add CSS with a link element, a style element and the style attribute, and choose the right one for a job
- Explain what the browser's default (user-agent) styles do before any CSS is written
- Predict which of two linked stylesheets wins when their selectors tie
- Write a small modern base stylesheet instead of copying a large reset
Before you start
On this page
There are three ways to add CSS to an HTML page. A <link rel="stylesheet"> element in the <head> loads a separate
.css file, and it is the right choice for almost all CSS: one file styles every page that links it, and the browser
keeps a copy for the next page. A <style> element holds CSS inside the page itself, which suits styles that only that
page needs. A style attribute puts declarations on a single element. It beats every rule in your stylesheets, but it
cannot use selectors, media queries or states such as :hover. And before any of your CSS arrives, the browser has
already styled the page with its own defaults: a margin around the body, large bold headings, blue underlined links.
This lesson shows the three ways and when each fits, measures the defaults in Chromium, Firefox and WebKit, and ends with a short base stylesheet that keeps the useful defaults and changes the rest.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Order status</title>
<!-- 1. A link element: an external stylesheet, shared by every page that links it -->
<link rel="stylesheet" href="/css/site.css">
<!-- 2. A style element: CSS for this page only -->
<style>
.status {
font-weight: 600;
}
</style>
</head>
<body>
<!-- 3. A style attribute: declarations for this one element -->
<p class="status" style="color: #0b6e4f">Your parcel is on its way.</p>
</body>
</html> Which way to use
| Criterion | <link> | <style> | style= |
|---|---|---|---|
| Lives in | A .css file | The page | One element |
| Styles | Every page linking it | One page | One element |
| Cached | Yes | No | No |
| Can use :hover | Yes | Yes | No |
| Priority | Usual | Usual | Higher |
| Under style-src 'self' | Allowed | Blocked | Blocked |
| When to choose | Almost all CSS | One page, critical CSS | Values set by scripts |
The rest of this lesson takes the three in turn, then looks at what happens when two stylesheets disagree.
Linking a stylesheet
<link rel="stylesheet" href="/css/site.css"> asks the browser to fetch site.css and apply it to the page. Put it in
the <head>. The HTML Standard makes a stylesheet linked there render-blocking: the browser does not show the page
until the file has arrived and been parsed, so nobody sees the bare, unstyled HTML first. A stylesheet linked later in
the <body> does not hold back the first paint, which is why late stylesheets can make a page jump as styles arrive.
One exception helps: a link whose media attribute does not match the screen, such as media="print", does not block
rendering either.
Blocking is the right default, but it means the size and number of stylesheets in the <head> set how soon anything
appears, and @import makes that worse.
Why @import inside CSS is slow
An @import at the top of a stylesheet pulls in another stylesheet. The catch is that the browser only learns about
the imported file after the file that imports it has arrived, so each level of importing adds a full request to the
wait. Here main.css imports theme.css, which imports type.css:
main.css
@import url("theme.css");
.page {
max-inline-size: 40rem;
margin-inline: auto;
} theme.css
@import url("type.css");
:root {
color: #1d3557;
background: #f8fafc;
} type.css
body {
font-family: system-ui, sans-serif;
line-height: 1.5;
} The program below reads those three files, follows the @import lines the way a browser discovers them, and compares
the result with a <link> for each file, assuming every request takes 300 ms:
// When each stylesheet arrives: one <link> to a file that @imports the next, against a <link> for every file.
// A model with one fixed cost per request: a stylesheet arrives ROUND_TRIP_MS after the browser asks for it. The
// browser can ask for an imported file only once the file that imports it has arrived, but it can ask for every
// <link> in the <head> at once. The measured runs in the lesson used the same 300 ms per request.
import { readFileSync } from "node:fs";
const ROUND_TRIP_MS = 300;
const importsOf = (file) =>
[...readFileSync(file, "utf8").matchAll(/@import\s+url\(\s*"([^"]+)"\s*\)/g)].map((m) => m[1]);
// Follow the @import lines from the linked file, the way the browser discovers them.
function importChain(entry) {
const rows = [];
const visit = (file, askedAt) => {
const arrives = askedAt + ROUND_TRIP_MS;
rows.push({ file, askedAt, arrives });
for (const next of importsOf(file)) visit(next, arrives);
};
visit(entry, 0);
return rows;
}
const bar = (r) => " ".repeat(r.askedAt / 100) + "#".repeat((r.arrives - r.askedAt) / 100); // one # per 100 ms
function show(title, rows) {
console.log(title);
for (const r of rows) {
console.log(` ${r.file.padEnd(10)} ${String(r.askedAt).padStart(3)} to ${String(r.arrives).padStart(3)} ms ${bar(r)}`);
}
const done = Math.max(...rows.map((r) => r.arrives));
console.log(` rendering can start at ${done} ms, once every stylesheet has arrived\n`);
}
const chain = importChain("main.css");
show("One <link> to main.css, which imports theme.css, which imports type.css", chain);
show("A <link> for each of the three files", chain.map((r) => ({ file: r.file, askedAt: 0, arrives: ROUND_TRIP_MS }))); Output
One <link> to main.css, which imports theme.css, which imports type.css main.css 0 to 300 ms ### theme.css 300 to 600 ms ### type.css 600 to 900 ms ### rendering can start at 900 ms, once every stylesheet has arrived A <link> for each of the three files main.css 0 to 300 ms ### theme.css 0 to 300 ms ### type.css 0 to 300 ms ### rendering can start at 300 ms, once every stylesheet has arrived
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node waterfall.mjs
Real browsers behave the same way. Served from a test server on an Apple M5 Pro that answered every stylesheet after 300 ms, the page with the import chain and the page with three links were each loaded three times, without a cache:
The table gives the time until all three stylesheets had loaded:
| Engine | Import chain | Three links |
|---|---|---|
| Chromium 153 | 915 to 917 ms | 311 to 313 ms |
| Firefox 155 | 949 to 1,069 ms | 360 to 515 ms |
| WebKit 26.6 | 925 to 947 ms | 311 to 322 ms |
Firefox and WebKit also report when they first painted the page: 3 to 12 ms after the last stylesheet arrived, in
every run that recorded it. Three round trips instead of one is the whole cost, and on a phone network, where a round trip
can take far longer than on a fast connection, it grows with every level of importing. The fix needs no change to the
CSS rules: link each file from the HTML, or combine them into one file when the site is built. @import is still
useful in development and with cascade layers, which the cascade module covers.
The style element
A <style> element holds CSS in the page. A <style> element in the <head> blocks rendering just as a linked
stylesheet does, but there is no extra request: the CSS arrives with the HTML. That makes it a fit for styles only this
page needs, and for a small block of the most important styles, inlined so that the top of the page can appear while
the full stylesheet is still loading in a way that does not block rendering. The cost is that the CSS is sent again
with every page instead of being cached once.
The style attribute
A style attribute holds declarations for one element: style="color: #0b6e4f". The CSS Style Attributes
specification defines its value as the inside of a declaration block, so there is no selector, and with it no
:hover, no ::before and no @media. In the cascade, declarations attached to an element beat declarations from
any style rule of the same importance, however specific the rule’s selector is. From a stylesheet, only an
!important declaration or a running animation wins over it. That is why style attributes are hard to override and
best kept for values a script sets at run time, such as the position of an element someone is dragging.
Style attributes are also the first thing a strict Content Security Policy removes. A policy is a response header
that lists where a page may load things from. With style-src 'self', a page may use stylesheets from its own site,
and nothing else:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A strict style policy</title>
<!-- The server sends the header Content-Security-Policy: style-src 'self' -->
<link rel="stylesheet" href="/site.css"> <!-- site.css holds .card { color: rgb(10, 20, 30); } -->
<style>
.card { background-color: rgb(255, 255, 0); }
</style>
</head>
<body>
<p class="card" style="border: 3px solid rgb(255, 0, 0)">A card</p>
</body>
</html> All three engines applied the linked stylesheet, so the text took its colour, and blocked both the <style> block
and the style attribute: no yellow background and no red border. Each logged two policy violations in the console,
and the violation events named style-src-elem for the block and style-src-attr for the attribute. A policy can allow inline styles
again with 'unsafe-inline', or allow particular ones with a nonce or a hash, but the safer habit is not to need them.
Scripts are affected differently: under the same policy, all three engines applied a colour that a script set with
element.style.setProperty(), and blocked one that a script wrote with setAttribute('style', …).
This site’s own pages send style-src 'self', which is why none of its HTML uses style attributes.
When two stylesheets disagree
When two declarations for the same property tie on everything else the cascade looks at, the one that comes later
wins. Separately linked stylesheets count as if they were joined end to end in the order of their <link> elements,
and an imported stylesheet counts as if its rules were pasted where the @import stands. So if a library’s
stylesheet and yours both set .button { background: … }, link yours after the library’s and yours wins.
That is order between equals. A more specific selector earlier in the page still beats a less specific one later, and the cascade module explains how specificity is counted.
Where an element's styles come from
Text description of the diagram
The diagram stacks the three places an element's normal declarations come from, from the weakest at the top to the strongest at the bottom.
- Browser defaults: the user-agent stylesheet, which sets things like margins, heading sizes and link colours before any of your CSS loads.
- Your stylesheets, from link elements, style elements and @import: selectors decide which elements a rule matches, and when two rules set the same property, specificity and then the order of the rules decide which one wins. They override the browser defaults.
- style attributes: declarations attached to one element, with no selectors and no media queries. They override declarations from your stylesheets.
Declarations marked !important change this order; the cascade module of this track covers them.
What the browser styles before your CSS
Every browser ships a user-agent stylesheet, its own default CSS, and the cascade gives it the lowest priority,
so any rule of yours overrides it. The HTML Standard’s Rendering section describes the defaults browsers are expected
to use, in the form of CSS rules: block display for paragraphs and headings, 2em for a top-level heading, a 1em margin
around paragraphs, 40px of indent for lists, an 8px margin around the page. The Standard calls these “expected” rather
than required, and it leaves some things, such as the font of form controls, entirely to each browser.
For a page with no CSS of its own, at a width of 1280 px, Chromium 153, Firefox 155 and WebKit 26.6 computed the
same values for most elements: an 8px margin around the body, 16px text, a 32px h1 (also inside section and
article), a 24px h2, a 16px top margin on paragraphs, 40px of indent on lists, and links in rgb(0, 0, 238),
underlined. These follow the Standard: 32px is 2em of 16px. Three things differed:
- The body font: Times in Chromium, serif in Firefox and
-webkit-standardin WebKit. - The top margin of an
h1: 21.44px in Chromium and Firefox, 21.4375px in WebKit. - The font of a button: 13.33px Arial in Chromium, 13.33px in the system font in Firefox and 11px
system-uiin WebKit.
The heading margin is 0.67em of 32px; WebKit rounds lengths to 1/64 of a pixel, hence 21.4375px. The default font
for body text has a different name in each engine, and form controls differ most: on the same page, a button’s text was 13.33px in Chromium and
Firefox and 11px in WebKit, each in a different font. Older browsers also made an h1 inside a section or
article smaller; the current Standard has no such rule, and none of the three engines did so.
A small base stylesheet
The defaults are mostly useful: they keep an unstyled page readable, and lists, headings and paragraphs have sensible spacing. A few get in the way of building layouts, and the form-control differences above are a real inconsistency. Instead of a large reset that removes every default and then adds most of them back, a short base stylesheet changes only those:
/* A small base layer: keep the browser's useful defaults, change only the ones that get in the way. */
/* Widths include padding and border, so a 50% box stays half as wide. */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* No 8px gap around the page; easier-to-read lines. */
body {
margin: 0;
line-height: 1.5;
}
/* Media never overflows its container and sits on its own line. */
img,
svg,
video {
display: block;
max-inline-size: 100%;
block-size: auto;
}
/* Form controls use the page's font instead of a smaller system font. */
button,
input,
select,
textarea {
font: inherit;
} With base.css linked, the same page in all three engines had a 0px body margin and 24px lines of text (1.5 × 16px),
images became blocks capped at the width of their container, and every button, input, select and textarea
used the page’s 16px font instead of the 11px to 13.33px each engine chose on its own. Headings, paragraphs and lists
kept their default sizes and spacing, because nothing in the file touches them.
Link the base stylesheet first, before your other stylesheets, so that everything after it can build on it.
Try it: move inline styles into a stylesheet
This promo card is styled entirely with style attributes:
<article style="max-width: 32rem; padding: 1rem 1.25rem; border: 1px solid #c8d3e0; border-radius: 8px">
<h2 style="margin: 0 0 0.5rem; font-size: 1.25rem; color: #1d3557">Monsoon sale</h2>
<p style="margin: 0; line-height: 1.5">Umbrellas and raincoats, 20% off this week.</p>
<a href="#shop" style="display: inline-block; margin-top: 0.75rem; padding: 0.5rem 1rem; color: #ffffff; background: #0b6e4f; text-decoration: none">Shop now</a>
</article> Rewrite it with a class on each element and one stylesheet, so that it looks exactly the same and no style attribute
is left. Then add a hover and keyboard-focus colour for the link, something the attributes could not express. You can
check your version side by side in the HTML, CSS & JS Playground, or open both in DevTools and compare the computed
values of each element. One answer:
HTML · inline-after.html
<article class="promo">
<h2 class="promo-title">Monsoon sale</h2>
<p class="promo-text">Umbrellas and raincoats, 20% off this week.</p>
<a class="promo-link" href="#shop">Shop now</a>
</article> CSS · inline-after.css
.promo {
max-width: 32rem;
padding: 1rem 1.25rem;
border: 1px solid #c8d3e0;
border-radius: 8px;
}
.promo-title {
margin: 0 0 0.5rem;
font-size: 1.25rem;
color: #1d3557;
}
.promo-text {
margin: 0;
line-height: 1.5;
}
.promo-link {
display: inline-block;
margin-top: 0.75rem;
padding: 0.5rem 1rem;
color: #ffffff;
background: #0b6e4f;
text-decoration: none;
}
/* What a style attribute cannot do: states and conditions. */
.promo-link:hover,
.promo-link:focus-visible {
background: #095c42;
} Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
What is the difference between linking a stylesheet with <link> and loading it with @import?
Both load an external stylesheet, but the browser finds them at different times. A <link rel="stylesheet"> sits in
the HTML, so the browser sees it while parsing the <head> and can request every linked stylesheet at once. An
@import sits inside a stylesheet, so the imported file can be requested only after the file containing it has
arrived and been read; a chain of imports therefore costs one full round trip per level, while the page waits. In a
test with 300 ms per request, three chained stylesheets finished loading after 0.9 to 1.1 seconds and three links
after 0.3 to 0.5. For the cascade, the two are equivalent: an imported stylesheet counts as if its rules were written
where the @import stands. Use <link>, or combine files when the site is built.
Key takeaways
- Link stylesheets with
<link rel="stylesheet">in the<head>: it is cached, works on every page and blocks rendering only until it arrives. - A
<style>element is for one page’s styles or a small critical block; it arrives with the HTML and is not cached separately. - A
styleattribute holds declarations only, beats every rule of the same importance, and is blocked by a strict Content Security Policy. - Each level of
@importadds a full request before the page can render; link the files instead. - Between equally specific rules, the later one wins, in the order of the
<link>elements, with imports counted in place. - Browsers agree on most defaults (8px body margin, 32px
h1, 40px list indent) but not on form-control fonts, so a small base stylesheet withfont: inheritevens them out.
References
- HTML Standard: the link element, link type "stylesheet" (WHATWG)
- HTML Standard: the style element (WHATWG)
- HTML Standard: render-blocking mechanism (WHATWG)
- HTML Standard: Rendering (the expected default styles) (WHATWG)
- CSS Cascading and Inheritance Level 5 (W3C CSS Working Group)
- CSS Style Attributes (W3C CSS Working Group)
- Content Security Policy Level 3 (W3C Web Application Security Working Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress