Curriculum1 of 13
+ Module 04

CSS as a system

Build responsive layouts and fluid type without a framework.

Phase 0 · Foundations · 13 sections · about 17 minutes

+ 01 / 13

CSS is a system, not a pile of rules

In the last module you wrote HTML, which describes what things are. CSS describes what those things look like. CSS stands for Cascading Style Sheets, and the word that matters most in that name is "cascading".

Most people learn CSS as a list of properties to memorise. That is why CSS feels unpredictable to them. Every time something does not look right they add another rule, and eventually the stylesheet is a pile of overrides that nobody can reason about.

CSS is actually a small system with a few rules that decide, for every element on the page, which value wins. Once you know those rules you stop guessing. This module covers the whole system: how conflicts are resolved, how an element's size is calculated, how layout works, and how to define values once and reuse them everywhere.

By the end you will be able to build a layout that works from a phone to a wide monitor without writing a single media query. A media query is a block of CSS that only applies at certain screen sizes. They are useful, but designers reach for them far too early, and most responsive problems have better answers.

+ 02 / 13

The cascade decides who wins

Write two rules that target the same element and set the same property, and only one of them can apply. The cascade is the process the browser uses to pick.

+ CSS
1p {
2 color: black;
3}
4 
5p {
6 color: red;
7}

Every paragraph is red. When two rules have equal weight, the one written later in the file wins. This is the part of "cascading" people already have an intuition for: styles flow down the file, and later rules override earlier ones.

That rule only applies when the two selectors carry equal weight. A selector is the part before the curly brace, the bit that says which elements the rule targets. When two selectors carry different weight, order stops mattering and specificity takes over.

+ 03 / 13

Specificity is a count, not a vibe

Specificity is how the browser measures the weight of a selector. It counts three things, in order of importance: IDs, then classes, then element names.

+ CSS
1p {
2 color: black;
3}
4 
5.intro {
6 color: blue;
7}
8 
9#lede {
10 color: green;
11}
+ HTML
1<p class="intro" id="lede">Which colour am I?</p>

Green. The ID selector #lede counts one ID, which beats .intro with its one class, which beats p with its one element name. A single ID outranks any number of classes, and a single class outranks any number of element names. Order in the file is irrelevant here, because the weights are not equal.

Write that as three numbers and it becomes easy to compare. #lede is 1,0,0. .intro is 0,1,0. p is 0,0,1. Compare left to right and the first difference decides it.

Selectors combine, so a compound selector adds up:

+ CSS
1.card p {
2 color: black;
3}
4 
5.card .intro {
6 color: blue;
7}

.card p is 0,1,1, one class and one element. .card .intro is 0,2,0, two classes. Two classes beat one class, so blue wins.

+ Checkpoint

Two rules set a colour on the same link. Which one wins: nav a, or .menu a?

This is why !important exists, and why you should almost never use it. !important lifts a declaration above the whole specificity system. It wins, but it also means the next person who needs to override it has no move left except another !important. If you find yourself reaching for it, the real problem is usually a selector that is more specific than it needed to be.

The practical takeaway: keep specificity low and flat. Style with single classes wherever you can. Save IDs for anchors and JavaScript hooks rather than styling. A stylesheet where almost every selector is 0,1,0 is one where file order is the only thing you have to reason about, and file order is easy to reason about.

+ 04 / 13

Every element is a box

The browser draws every element as a rectangle, even a single word inside a <span>. That rectangle has four layers, from the inside out.

The content is the text or image itself. Padding is space inside the box, between the content and the edge. The border sits on the edge. Margin is space outside the box, pushing other elements away.

+ CSS
1.card {
2 width: 300px;
3 padding: 20px;
4 border: 2px solid black;
5 margin: 16px;
6}

Here is the part that catches everybody. By default, width: 300px sets the width of the content only. The padding and border are added on top. So that card actually occupies 300 + 20 + 20 + 2 + 2, which is 344 pixels of horizontal space. Add padding to make a card breathe and it silently gets wider, which breaks whatever it was sitting next to.

box-sizing changes what width means:

+ CSS
1*,
2*::before,
3*::after {
4 box-sizing: border-box;
NotePut this at the top of every stylesheet you write.
5}

With border-box, width: 300px means the whole box is 300 pixels including padding and border, and the content area shrinks to make room. Now the number you type is the number you get, and you can adjust padding without recalculating anything.

The * selector matches every element, and ::before and ::after are generated boxes that CSS can create, which * does not cover on its own. Those three lines are the first thing in almost every professional stylesheet.

+ Checkpoint

With border-box, how much horizontal space does an element take up if its width is 200px, its padding is 16px and it has a 1px border?

+ 05 / 13

Margins collapse, padding does not

One more box behaviour that surprises people. When two vertical margins meet, they do not add up. The larger one wins and the smaller one disappears. This is called margin collapse.

+ CSS
1h2 {
2 margin-bottom: 30px;
3}
4 
5p {
6 margin-top: 20px;
7}

The gap between the heading and the paragraph is 30 pixels, not 50. The browser collapses the two margins into the larger of the two.

Margin collapse only happens vertically, only between margins, and not when there is a border, padding, or a flex or grid container between them. It exists so that stacked text has consistent spacing without you having to zero out every other margin, but it does mean margins are a slightly unreliable way to control space.

This is why a lot of modern CSS avoids margins for layout spacing and uses gap inside a flex or grid container instead. gap never collapses. It puts exactly the space you asked for between every child, and you set it once on the parent rather than on every child.

+ 06 / 13

Flexbox for one direction

Flexbox lays out children along a single axis, either a row or a column. You turn it on with display: flex on the parent, and from that moment the parent controls how its children are positioned.

+ CSS
1.toolbar {
2 display: flex;
3 gap: 12px;
4 align-items: center;
5 justify-content: space-between;
6}

gap is the space between children. justify-content positions children along the main axis, which is horizontal in a row. align-items positions them along the cross axis, which is vertical in a row. space-between pushes the first child to the start, the last to the end, and spreads the rest evenly.

Those two property names are the ones people mix up forever. The fix is to remember that justify follows the direction you set with flex-direction, and align is the other one. Set flex-direction: column and they swap: justify-content now controls vertical position and align-items controls horizontal.

The other property worth knowing is flex, set on a child rather than the parent:

+ CSS
1.sidebar {
2 flex: 0 0 240px;
NoteDo not grow, do not shrink, start at 240px.
3}
4 
5.main {
6 flex: 1;
NoteTake all remaining space.
7}

flex: 1 on one child and a fixed width on another is the whole recipe for a sidebar layout. The main column absorbs whatever is left over, at any screen width, with no calculation on your part.

+ 07 / 13

Grid for two directions

Grid lays out children in rows and columns at the same time. Where flexbox distributes items along one line, grid defines a structure first and places items into it.

+ CSS
1.gallery {
2 display: grid;
3 grid-template-columns: 1fr 1fr 1fr;
4 gap: 20px;
5}

fr is a unit that only exists in grid. It means "one share of the leftover space". Three columns of 1fr split the container into three equal parts, after any fixed sizes and gaps have been subtracted. It is the unit you want most of the time, because it does the subtraction for you. Three columns of 33.33% plus a 20 pixel gap overflows the container. Three columns of 1fr never does.

You can mix fixed and flexible tracks:

+ CSS
1.layout {
2 display: grid;
3 grid-template-columns: 240px 1fr;
4 gap: 32px;
5}

The first column is always 240 pixels, the second takes everything else.

The rule for choosing between the two: if you are lining things up in a row or a column and you want the content to decide the sizes, use flexbox. If you are defining a structure that content gets placed into, use grid. A navigation bar is flexbox. A page layout is grid. A card's internal stack is flexbox. A gallery is grid.

+ Checkpoint

You need a page with a fixed 260px sidebar and a main column that fills the rest. Which is the better fit?

+ 08 / 13

Custom properties are your tokens

A custom property is a value you define once and reference everywhere. The name must start with two dashes, and you read it back with var().

+ CSS
1:root {
2 --colour-text: #141413;
3 --colour-muted: #55534e;
4 --space-s: 8px;
5 --space-m: 16px;
6 --space-l: 32px;
7 --radius: 12px;
8}
9 
10.card {
11 color: var(--colour-text);
12 padding: var(--space-m);
13 border-radius: var(--radius);
14}

:root is a selector that matches the <html> element. Defining custom properties there makes them available to every element on the page, because custom properties inherit: an element that does not define one looks up through its ancestors until it finds a value.

That inheritance is what makes them more useful than a variable in a preprocessor like Sass. A Sass variable is substituted once when the stylesheet is compiled, and after that it is gone. A CSS custom property is live in the browser, so you can redefine it for part of the page and everything inside picks up the new value:

+ CSS
1.panel-dark {
2 --colour-text: #ffffff;
3 --colour-muted: rgba(255, 255, 255, 0.6);
4 background: #141413;
5}

Nothing inside .panel-dark needs to change. Every .card in there reads --colour-text and gets white, because the nearest ancestor that defines it is now the panel. This is how dark mode is built, and it is why you will see custom properties called tokens in design system work. A token is a named design decision, and a custom property is the mechanism that holds it.

The discipline that makes this pay off: never write a raw value in a component rule if it is a decision you might want to change. Colours, spacing steps, radii and type sizes all belong in :root. One pixel value that appears in forty places is forty edits. One custom property is one.

+ 09 / 13

Relative units scale, pixels do not

A pixel is a fixed size. Set font-size: 16px and it is 16 pixels no matter what, including for a reader who has increased their browser's default text size because they cannot comfortably read at 16. Relative units are calculated from something else, so they respond.

rem is relative to the root font size, which is whatever the browser default is, usually 16 pixels. So 1rem is normally 16 pixels, 1.5rem is 24, 0.875rem is 14. If a reader sets their default to 20, every rem in your stylesheet scales with it and your layout still holds together.

em is relative to the font size of the element itself. This makes it useful for spacing that should track the text it belongs to:

+ CSS
1.button {
2 font-size: 1rem;
NoteSet from the root, predictable.
3 padding: 0.75em 1.5em;
NoteSet from this button's own font size.
4}
5 
6.button-large {
7 font-size: 1.25rem;
NotePadding grows with it automatically.
8}

The large button gets proportionally larger padding without a second padding rule, because em inside it now resolves against the larger font size.

That same behaviour makes em a bad choice for font sizes, because it compounds. A 1.2em heading inside a 1.2em container inside another 1.2em container is 1.728 times the base size, and nobody intended that. Use rem for type, em for spacing that should track type.

Finally, line-height should be unitless:

+ CSS
1body {
2 line-height: 1.6;
NoteNo unit. Multiplies the element's own font size.
3}

A unitless line-height is inherited as a multiplier, so a 32 pixel heading gets 51 pixels of line height and a 16 pixel paragraph gets 25.6. Write line-height: 1.6rem instead and every element inherits the same fixed 25.6 pixels, which crushes your headings.

+ 10 / 13

Fluid type with clamp()

The common way to make text responsive is to pick sizes at breakpoints: 32 pixels on mobile, 48 on tablet, 64 on desktop. That gives you three sizes and two sudden jumps, and it means writing and maintaining media queries for every element that needs to scale.

clamp() replaces all of it. It takes three values: a minimum, a preferred value, and a maximum. The browser uses the preferred value, unless that would fall outside the bounds.

+ CSS
1h1 {
2 font-size: clamp(2rem, 5vw, 4rem);
3}

vw is a viewport width unit. 1vw is one percent of the browser window's width, so 5vw on a 1200 pixel window is 60 pixels. As the window narrows the preferred value shrinks continuously, and the heading scales smoothly with it. Below the point where 5vw drops under 2rem the minimum takes over and the text stops shrinking. Above the point where it exceeds 4rem the maximum caps it.

The result is type that scales at every width instead of at three widths, with no media queries and no jumps. Drag the viewport and watch where each of the three values takes over.

+ Try it45px, preferred

clamp(min, preferred, max)

Become an engineer

320px1600pxpreferred value applies

Below 640px the minimum holds the size steady. Above 1280px the maximum caps it. Between them the type scales with the window.

font-size: clamp(2rem, 5vw, 4rem);
/* at 900px, resolves to 45px */

Drag the viewport. The preferred value scales continuously, and the floor and ceiling take over at the two crossover widths.

The two crossover points are the part worth internalising. Below one width the minimum is holding the size steady, above another the maximum is capping it, and only between them is the preferred value actually doing anything.

Two things to get right. Always set a minimum in rem rather than px, so a reader who has increased their default text size is not locked out by your floor. And be careful with the preferred value: something like clamp(1rem, 4vw, 3rem) on body text scales aggressively enough to be uncomfortable. For body copy the range should be narrow, often no scaling at all. Save the dramatic ranges for headings.

+ Checkpoint

On a 900px wide window with a 16px root font size, what does clamp(1.5rem, 4vw, 3rem) resolve to?

+ 11 / 13

Three responsive patterns that replace most media queries

Almost every responsive layout you need is one of these three.

Let type scale itself. Use clamp() for headings, as above. No media query needed.

Let a row become a stack. flex-wrap: wrap tells a flex container that when its children no longer fit on one line, they should move onto the next one instead of squashing.

+ CSS
1.row {
2 display: flex;
3 flex-wrap: wrap;
4 gap: 24px;
5}
6 
7.row > * {
8 flex: 1 1 280px;
NoteGrow, shrink, but never below 280px.
9}

The 280px in flex: 1 1 280px is the basis, the size each child would like to be. When three children at 280 pixels plus gaps no longer fit, one wraps to the next line and the remaining two grow to fill the space. Three columns become two, then one, on their own. The breakpoint is decided by the content rather than by a number you guessed.

Let a grid choose its own column count. auto-fit combined with minmax() does the same job for grid.

+ CSS
1.gallery {
2 display: grid;
3 grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
4 gap: 20px;
5}

Read it right to left. minmax(240px, 1fr) means each column is at least 240 pixels and at most one share of the free space. auto-fit means fit as many of those columns as will go. On a 1000 pixel container that is four columns. On a phone it is one. You never state a breakpoint, and the layout is correct at every width in between, including widths you never tested.

Media queries are still the right tool when the change is structural rather than dimensional: a sidebar that moves below the content, a navigation that becomes a menu button. Those are genuinely different layouts, not the same layout at a different size. Use a media query there and one of the three patterns above everywhere else.

+ 12 / 13

Tailwind is these rules with shorter names

You will meet Tailwind in module 11, and you are going to see it in almost every codebase you touch. It is worth knowing now what it actually is, because it is much less than people assume.

Tailwind is a set of pre-written CSS classes, one per declaration. flex is display: flex. p-4 is padding: 1rem. gap-3 is gap: 0.75rem. text-lg is font-size: 1.125rem; line-height: 1.75rem. That is the whole idea.

+ HTML
1<div class="flex items-center gap-3 p-4 rounded-xl"></div>
+ CSS
1.toolbar {
2 display: flex;
3 align-items: center;
4 gap: 0.75rem;
5 padding: 1rem;
6 border-radius: 0.75rem;
7}

Those two produce identical pixels. Tailwind is not a layout system and it does not replace anything you learned in this module. Every class is one CSS declaration you already know, and the spacing scale is a set of custom properties someone else decided for you.

This matters for a practical reason. When a Tailwind layout does not do what you expect, the problem is never Tailwind. It is the cascade, or the box model, or a flex property doing exactly what it is specified to do. Debugging it means reading the CSS underneath, which is why this module comes first. Designers who learn Tailwind before CSS can build the layouts they have seen before and get stuck on anything new. Designers who learn CSS first find Tailwind is mostly a naming convention.

+ 13 / 13

Checkpoint

Build a pricing page with three plan cards, using no media queries at all.

The requirements:

  • All colours, spacing values and radii defined as custom properties in :root and referenced with var() everywhere else
  • box-sizing: border-box set globally
  • Card headings sized with clamp()
  • Three cards side by side on a wide screen, collapsing to fewer columns and eventually one on a narrow screen, using either flex-wrap or auto-fit
  • Spacing between cards from gap, not margins
  • Type sizes in rem, unitless line-height, padding in em where it should track the text

You can build this in any browser-based editor. Open the developer tools, find the Elements panel, and select a card. The Styles panel on the side shows every rule that applies to it and strikes through the ones that lost. That panel is the cascade and specificity system made visible, and it is the fastest way to answer "why is this not the colour I set".

Work through these before moving on:

+ Checklist0 / 6

If you can do all of that, you can build a responsive layout from an empty file, which is the thing most designers cannot do. Module 5 puts it to work by recreating a real product screen pixel for pixel.

+ Up nextCSS CraftPreviouslyHTML, the language