Skip to main content
Published / updated

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:

  1. Importance!important beats normal
  2. Specificity — a weight calculated from the selector
  3. Source order — the last matching rule wins

Specificity

Counted as three numbers: (ids, classes, elements).

SelectorSpecificity
p0,0,1
.card0,1,0
#studentTable1,0,0
.card p0,1,1
.card .title0,2,0
#studentTable td1,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.

InheritsDoes not inherit
colorbackground
font-family, font-size, font-weightborder
line-heightpadding, margin
text-alignwidth, height
visibilitydisplay
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

UnitRelative toUse for
pxAbsoluteBorders, small fixed values
remRoot font sizeFont sizes, spacing
emThe element's own font sizePadding that scales with text
%The parentWidths
vw / vhViewportFull-screen sections
chWidth of "0"Line length
frFree space in a gridGrid 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 seeMeaning
Struck-through propertyOverridden by a higher-priority rule
element.styleAn inline style, usually set by JavaScript
Greyed-out ruleDoes not apply to this element
Warning triangleInvalid property or value

Three causes cover almost every "my CSS is not working":

  1. A more specific rule wins — your rule is listed, struck through.
  2. The selector does not match — your rule is not listed at all.
  3. 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 seeCauseHow to tell
The rule is struck through in DevToolsOverridden by a more specific selectorStyles pane shows the winner
The rule is not listed at allSelector does not match, or the file did not loadCheck the Network tab
The rule applies but nothing movesThe property does not do what you expectCheck Computed and the box model
Element is wider than expectedPadding and border add to widthbox-sizing: border-box
!important needed to make anything workSpecificity is out of controlSimplify 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-box reset
  • Fighting specificity with !important
  • Ids in selectors, making rules unoverridable
  • Deeply nested descendant selectors tied to DOM structure
  • px for font sizes, ignoring the user's browser setting
  • em in 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: none on 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.

  1. Start a stylesheet with the box-sizing: border-box reset and a :root token block for colours, spacing and radius.
  2. Style a student card using only tokens — no raw hex values, no raw pixel spacing.
  3. Give an element width: 300px; padding: 20px; border: 2px. Measure its rendered width in DevTools with and without the border-box reset.
  4. Put two paragraphs with margin-bottom: 20px and margin-top: 30px next to each other. Measure the gap.
  5. Write #studentTable td { color: red } and .student-table td { color: blue }. Predict the winner, then confirm in DevTools.
  6. Try to override the id rule with a class. Then rewrite both without ids and confirm how much easier it becomes.
  7. Set body text in px, increase the browser's default font size, and observe. Switch to rem and repeat.
  8. Nest three elements each with font-size: 1.2em and measure the innermost. Then use rem and compare.
  9. Add a prefers-color-scheme: dark block redefining only the colour tokens. Confirm the whole page themes.
  10. Change --colour-primary from the DevTools console with setProperty and watch it apply live.
  11. Set .muted { color: #ccc } on white. Check the contrast ratio in DevTools and fix it to pass 4.5:1.
  12. Set a paragraph to max-width: 65ch and 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 !important by fixing specificity

Review questions

  1. In (ids, classes, elements), why does one id beat four classes?
  2. What does box-sizing: border-box change about width: 300px?
  3. Why use rem rather than px for font sizes?
  4. What are the three reasons a CSS rule appears not to work?

Next: Layout with Flexbox and Grid