CSS Fundamentals
Before you start
You need: semantic HTML (Article 03).
Time: about 45 minutes, plus the practice.
Learning objective
Predict which CSS rule wins for any element, and explain an element's rendered size from its box model.
Topics
- Attaching CSS
- Selectors
- The cascade and specificity
- Inheritance
- The box model and
box-sizing - Units
- Custom properties
- Colours and typography
- Debugging in DevTools
Attaching CSS
<!-- External — the only one to use at scale -->
<link rel="stylesheet" href="/css/site.css">
<!-- Internal — a single page -->
<style>
body { font-family: system-ui, sans-serif; }
</style>
<!-- Inline — highest priority, hardest to override -->
<p style="color: red;">Avoid this.</p>
Inline styles beat everything except !important and cannot be reused or themed. They appear when JavaScript sets element.style.color, which is why that habit makes a page unfixable from CSS.
Toggle classes from JavaScript; keep colours in CSS.
Selectors
p { } /* type */
.student-card { } /* class */
#studentTable { } /* id */
[data-status="active"] { } /* attribute */
* { } /* universal */
.card .title { } /* descendant — any depth */
.card > .title { } /* direct child only */
.title + .subtitle { } /* immediately after */
.title ~ .note { } /* any sibling after */
a:hover, a:focus-visible { }
input:invalid { }
input:disabled { }
tr:nth-child(even) { }
li:first-child { }
li:last-child { }
:not(.excluded) { }
.card:has(> img) { } /* parent selector — modern browsers */
p::first-line { }
.required::after { content: " *"; color: #b91c1c; }
::before and ::after need content, even if empty. They are the standard way to add a required-field asterisk or an icon without extra markup.
:has() is the long-awaited parent selector — .field:has(input:invalid) styles the wrapper when the input inside is invalid, which previously needed JavaScript.
The cascade
When several rules set the same property, the winner is decided in this order:
- Importance —
!importantbeats normal - Specificity — a weight calculated from the selector
- Source order — the last matching rule wins
Specificity
Counted as three numbers: (ids, classes, elements).
| Selector | Specificity |
|---|---|
p | 0,0,1 |
.card | 0,1,0 |
#studentTable | 1,0,0 |
.card p | 0,1,1 |
.card .title | 0,2,0 |
#studentTable td | 1,0,1 |
input[type="text"] | 0,2,0 |
Inline style="" | 1,0,0,0 |
Compare left to right. One id beats any number of classes: #studentTable (1,0,0) beats .card .title .name .text (0,4,0).
#studentTable td { color: #333; } /* 1,0,1 — wins */
.student-table td { color: #666; } /* 0,1,1 */
That is why over-specific selectors are a problem: once a rule uses an id, overriding it requires another id or !important, and the stylesheet only ever gets harder to change.
Keep specificity low and flat. Prefer a single class:
/* Hard to override, and tied to the DOM structure */
#main .content .card .header .title { }
/* One class, easy to override, structure-independent */
.card-title { }
:where() has zero specificity, which is useful for defaults:
:where(.card) p { margin: 0; } /* 0,0,1 — trivially overridden */
!important
.error { color: red !important; }
It beats everything, and once one rule uses it, overriding needs another !important. Within months the stylesheet can only be changed by escalating.
Legitimate uses are narrow: overriding a third-party stylesheet you cannot edit, and utility classes in a framework. Never reach for it to win a fight with your own CSS — read the cascade in DevTools and fix the specificity instead.
Inheritance
Some properties inherit from parent to child; most do not.
| Inherits | Does not inherit |
|---|---|
color | background |
font-family, font-size, font-weight | border |
line-height | padding, margin |
text-align | width, height |
visibility | display |
body {
font-family: system-ui, sans-serif;
color: #1f2937;
line-height: 1.6;
}
Setting typography on body cascades to everything, which is why a stylesheet starts there.
Force it either way when needed:
.card { color: inherit; }
button { font: inherit; } /* buttons do NOT inherit font by default */
.reset { all: unset; }
button { font: inherit; } is worth knowing — form controls have their own default font, so a button looks wrong next to your body text until you say this.
The box model
Every element is a box: content, padding, border, margin.
.card {
width: 300px;
padding: 20px;
border: 2px solid #d1d5db;
margin: 16px;
}
With the default content-box, that element is 344px wide: 300 content + 40 padding + 4 border. Margin sits outside and does not count toward width.
*, *::before, *::after {
box-sizing: border-box;
}
With border-box, width: 300px means the border edge is 300px — padding and border are included. The element is 300px wide as written.
Put that reset at the top of every stylesheet. It is the single most useful three lines in CSS, and without it every width calculation needs mental arithmetic.
Margin collapse
<p style="margin-bottom: 20px;">First</p>
<p style="margin-top: 30px;">Second</p>
The gap is 30px, not 50px. Adjacent vertical margins collapse to the larger of the two.
Margins also collapse through a parent with no padding or border, so a child's top margin can push the parent down instead of creating space inside it. Confusing the first time; display: flex or grid on the parent stops collapsing entirely, which is one reason modern layouts see it less.
Horizontal margins never collapse.
Shorthand
padding: 16px; /* all */
padding: 16px 24px; /* vertical | horizontal */
padding: 16px 24px 8px; /* top | horizontal | bottom */
padding: 16px 24px 8px 12px; /* top | right | bottom | left — clockwise */
margin: 0 auto; /* centre a fixed-width block */
Logical properties adapt to writing direction:
padding-inline: 24px; /* left and right in a left-to-right language */
padding-block: 16px; /* top and bottom */
margin-inline: auto;
Units
| Unit | Relative to | Use for |
|---|---|---|
px | Absolute | Borders, small fixed values |
rem | Root font size | Font sizes, spacing |
em | The element's own font size | Padding that scales with text |
% | The parent | Widths |
vw / vh | Viewport | Full-screen sections |
ch | Width of "0" | Line length |
fr | Free space in a grid | Grid tracks |
html { font-size: 100%; } /* respects the user's browser setting */
.card {
padding: 1.5rem; /* 24px at the default 16px root */
font-size: 1rem;
border-radius: 0.5rem;
max-width: 65ch; /* comfortable line length */
}
Use rem for font sizes. A user who has increased their browser's default font size gets larger text; px ignores them entirely. That is an accessibility failure with a large affected population.
em compounds — nested elements multiply, so a 1.2em list inside a 1.2em list is 1.44×. Predictable for padding on a single element, surprising anywhere nested.
max-width: 65ch keeps lines at a readable length regardless of screen width. Long lines are genuinely harder to read, and a full-width paragraph on a wide monitor is a common design failure.
Custom properties
:root {
--colour-primary: #2563eb;
--colour-text: #1f2937;
--colour-muted: #6b7280;
--colour-danger: #b91c1c;
--colour-success: #15803d;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-4: 1rem;
--space-6: 1.5rem;
--radius: 0.5rem;
--font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.card {
padding: var(--space-6);
border-radius: var(--radius);
color: var(--colour-text);
font-family: var(--font-body);
}
.button-primary {
background: var(--colour-primary);
color: #fff;
}
Unlike a preprocessor variable, a custom property is live: it cascades, it can be read and changed from JavaScript, and it can be overridden per component or per media query.
.card { --card-padding: var(--space-4); padding: var(--card-padding); }
.card-large { --card-padding: var(--space-6); }
@media (prefers-color-scheme: dark) {
:root {
--colour-text: #f3f4f6;
--colour-muted: #9ca3af;
}
}
One block flips the whole theme. Define every colour as a token and never write a raw hex value in a component rule.
document.documentElement.style.setProperty('--colour-primary', '#7c3aed');
var(--x, fallback) supplies a default when the property is not set.
Colours and typography
color: #2563eb;
color: rgb(37 99 235);
color: rgb(37 99 235 / 50%);
color: hsl(217 91% 60%);
color: oklch(0.6 0.2 260);
hsl is easier to reason about for a palette — same hue, different lightness gives a consistent set of shades. oklch is perceptually uniform, so equal lightness values genuinely look equally light.
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 1rem;
line-height: 1.6;
color: var(--colour-text);
}
h1 { font-size: 2rem; line-height: 1.2; }
h2 { font-size: 1.5rem; line-height: 1.3; }
system-ui uses the operating system's interface font — no download, no layout shift, and it looks native.
Headings need tighter line-height than body text. A single-line heading with line-height: 1.6 has visible extra space above and below it.
For a web font:
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: swap;
font-weight: 400 700;
}
font-display: swap shows fallback text immediately and swaps when the font loads, rather than leaving the text invisible for several seconds on a slow connection.
Debugging in DevTools
Open Elements, select the element, and read the Styles pane. Rules are listed most specific first.
| What you see | Meaning |
|---|---|
| Struck-through property | Overridden by a higher-priority rule |
element.style | An inline style, usually set by JavaScript |
| Greyed-out rule | Does not apply to this element |
| Warning triangle | Invalid property or value |
Three causes cover almost every "my CSS is not working":
- A more specific rule wins — your rule is listed, struck through.
- The selector does not match — your rule is not listed at all.
- The file did not load — nothing of yours is listed. Check Network for a 404 or a cached copy.
Use the Computed tab to see the single winning value per property, with an arrow to the rule that produced it.
Read the panel before adding !important. Two seconds there replaces an hour of escalation.
Errors you will hit
| What you see | Cause | How to tell |
|---|---|---|
| The rule is struck through in DevTools | Overridden by a more specific selector | Styles pane shows the winner |
| The rule is not listed at all | Selector does not match, or the file did not load | Check the Network tab |
| The rule applies but nothing moves | The property does not do what you expect | Check Computed and the box model |
| Element is wider than expected | Padding and border add to width | box-sizing: border-box |
!important needed to make anything work | Specificity is out of control | Simplify selectors instead |
The Styles pane tells you which of the three it is in seconds. Guessing costs ten minutes; looking costs ten seconds.
Common mistakes
- No
box-sizing: border-boxreset - Fighting specificity with
!important - Ids in selectors, making rules unoverridable
- Deeply nested descendant selectors tied to DOM structure
pxfor font sizes, ignoring the user's browser settingemin nested contexts, compounding unexpectedly- Raw hex values in component rules instead of tokens
- Forgetting
button { font: inherit; } - Expecting adjacent vertical margins to add up
outline: noneon focus- Text lines running the full width of a wide screen
- Setting styles from JavaScript with
element.style
Practice
The course exercise is recreate a responsive card layout; this article builds the foundation.
- Start a stylesheet with the
box-sizing: border-boxreset and a:roottoken block for colours, spacing and radius. - Style a student card using only tokens — no raw hex values, no raw pixel spacing.
- Give an element
width: 300px; padding: 20px; border: 2px. Measure its rendered width in DevTools with and without the border-box reset. - Put two paragraphs with
margin-bottom: 20pxandmargin-top: 30pxnext to each other. Measure the gap. - Write
#studentTable td { color: red }and.student-table td { color: blue }. Predict the winner, then confirm in DevTools. - Try to override the id rule with a class. Then rewrite both without ids and confirm how much easier it becomes.
- Set body text in
px, increase the browser's default font size, and observe. Switch toremand repeat. - Nest three elements each with
font-size: 1.2emand measure the innermost. Then useremand compare. - Add a
prefers-color-scheme: darkblock redefining only the colour tokens. Confirm the whole page themes. - Change
--colour-primaryfrom the DevTools console withsetPropertyand watch it apply live. - Set
.muted { color: #ccc }on white. Check the contrast ratio in DevTools and fix it to pass 4.5:1. - Set a paragraph to
max-width: 65chand compare readability against full width on a wide monitor.
You can now
- Predict which rule wins for any element
- Read the box model in DevTools
- Explain a rendered size from padding, border and
box-sizing - Diagnose "the CSS isn't working" from the Styles pane
- Avoid
!importantby fixing specificity
Review questions
- In
(ids, classes, elements), why does one id beat four classes? - What does
box-sizing: border-boxchange aboutwidth: 300px? - Why use
remrather thanpxfor font sizes? - What are the three reasons a CSS rule appears not to work?