Lesson 37 • Advanced Track
Building Reusable UI Components
By the end of this lesson you'll be able to design a small, themeable component system — a button with variants and sizes, and a compound card — using BEM naming, modifier classes, and CSS custom properties as the component's public API.
What You'll Learn
--name and reading it with var(). If variables feel new, review CSS Custom Properties first.💡 Think of It Like This
A reusable component is like a LEGO brick. A single 2×4 brick is one shape (the base), but it comes in red, blue, or green (variants) and in tall or short heights (sizes). You design each brick type once, then snap copies together into any structure — you never re-mould a brick for every model.
The studs on top are the brick's API: a fixed, documented way to connect to it. Your component's CSS custom properties are exactly that — a small set of named knobs (--primary, --radius) that anyone can turn from the outside, without prising the brick open to see how it's made.
1. The Pattern: Base Class + Modifier Classes
A component system follows one consistent shape: a base class that holds the shared structure (layout, spacing, corner radius), plus small modifier classes that change one visual thing each — a colour, a size, a state. You put several classes on one element and they compose: class="btn btn--primary btn--lg".
The win is that the base stays written once. Five colours times three sizes is eight tiny classes that stack, not fifteen fat ones that each repeat the padding and radius. BEM naming keeps this readable: block (the component), block__element (a part inside it), and block--modifier (a variant or state).
| Role | BEM form | Example |
|---|---|---|
| Block (base) | .block | .btn · .card |
| Element (a part) | .block__part | .card__header |
| Modifier (variant) | .block--variant | .btn--primary |
| Modifier (state) | .block--state | .form-input--error |
2. Worked Example — A Button System
Here is the pattern in full. The .btn base sets layout, padding, and radius and no colour at all. Each .btn--* modifier touches only the properties it owns, so colour, size, and state never fight each other and can be combined freely. Every colour and the radius come from :root tokens — that block is the button's theming API.
Worked example: button base + variants + sizes
One .btn base, small modifiers stacked on top
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Button system</title>
<style>
:root {
/* Design tokens = the button's public API. Override these to re-theme. */
--primary: #1976D2; /* brand colour, reused by every primary button */
--primary-hover: #1565C0; /* darker shade for :hover */
--danger: #D32F2F;
--danger-hover: #B71C1C;
--radius: 8px; /* one corner radius for the whole UI */
}
body { font-f
...3. Worked Example — A Compound Card
A card is a compound component: a block made of several named parts — .card__header, .card__body, .card__footer. Each part is optional, so the same block works whether a card has all three or just a body. Notice the selectors stay flat — one class per part — instead of brittle chains like .card div p, so a card's styles never leak into content you drop inside it.
Worked example: compound card with optional parts
Header, body, footer elements plus block-level modifiers
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Card system</title>
<style>
:root {
--surface: #ffffff; /* card background */
--border: #e0e0e0;
--muted: #757575; /* secondary text */
--primary: #1976D2;
--radius: 12px;
--shadow: 0 2px 8px rgba(0,0,0,0.08);
}
body { font-family: system-ui, sans-serif; padding: 24px; background: #fafafa; }
.grid { display: grid; grid-template-columns: repeat(auto-fill,
...🎯 Your Turn #1 — Add a variant and a size to the button
The .btn base is done. Add a success colour variant and a small size, then apply both to a button. Fill in the blanks marked ___ and run it.
Your Turn #1: a success variant + small size
Add .btn--success and .btn--sm, then compose them
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Your Turn 1</title>
<style>
/* 🎯 YOUR TURN — fill in the blanks marked ___ */
:root {
--primary: #1976D2;
--success: #2E7D32; /* a green token, ready to use */
--radius: 8px;
}
body { font-family: system-ui, sans-serif; padding: 24px; }
.demo { display: flex; gap: 8px; align-items: center; }
/* BASE class — already complete, do not change it. */
.btn {
padding: 10
...🎯 Your Turn #2 — Add a card element and a modifier
This card has a body but no footer, and no way to highlight it. Add a .card__footer element and a .card--accent modifier, then use them in the markup.
Your Turn #2: a footer element + accent modifier
Add .card__footer and .card--accent, then apply both
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Your Turn 2</title>
<style>
/* 🎯 YOUR TURN — fill in the blanks marked ___ */
:root { --surface: #fff; --border: #e0e0e0; --primary: #1976D2; --radius: 12px; }
body { font-family: system-ui, sans-serif; padding: 24px; background: #fafafa; }
/* BLOCK + existing elements — already complete. */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow
...🧩 Mini-Challenge — A badge component from scratch
Support is faded now — only an outline is given. Build a small badge component (a pill label) with a base class and colour variants, driven by tokens. Use the button worked example as your reference if you get stuck.
Mini-Challenge: a tokenised badge system
A .badge base, colour variants, and tokens in :root
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Mini-Challenge</title>
<style>
/* 🧩 MINI-CHALLENGE: a reusable badge (pill label) component
1. In :root, declare colour tokens:
--info (a blue), --success (a green), --danger (a red)
2. Write a BASE class .badge with the shared pill shape:
inline-block, small padding (e.g. 2px 10px), border-radius: 99px,
a small bold font. Set NO colour here.
3. Write three VA
...⚠️ Common Errors (and the fix)
- Overly specific selectors. Targeting parts with
.card div por styling by ID (#submit) raises specificity, so a later variant class can't override it and an unrelated nested paragraph gets caught too. Fix: give each part one class —.card__body— and target that single class. - Hard-coded values instead of tokens. Writing
background: #1976D2in ten rules means ten edits to rebrand, and one missed instance breaks consistency. Fix: declare--primaryonce and usebackground: var(--primary)everywhere. - Leaking styles. Putting colour on the base class (
.btn { background: blue; }) forces every variant to fight it back. Fix: keep the base structural only; let each--modifierown exactly the properties it changes. - One class per combination. Writing
.btn-primary-large-disabledexplodes into dozens of classes. Fix: compose small modifiers on the element —class="btn btn--primary btn--lg". - Wrong BEM separators.
.card-headerreads as a sibling block, not a part of the card. Fix: use__for elements and--for modifiers:.card__header,.card--accent.
📋 Quick Reference
| Goal | Use this |
|---|---|
| Define the base (structure only) | .btn { padding; border-radius; ... } |
| Add a colour variant | .btn--primary { background: var(--primary); } |
| Add a size variant | .btn--lg { padding: 14px 28px; } |
| Compose on an element | class="btn btn--primary btn--lg" |
| Name an inner part (element) | .card__header |
| Name a variant (modifier) | .card--accent |
| Expose a theming API | :root { --primary: #1976D2; } |
| Map a state to an attribute | .btn:disabled { opacity: 0.5; } |
❓ Frequently Asked Questions
What is BEM and why do component classes look like .card__title--active?
BEM stands for Block, Element, Modifier — a naming convention that makes a class name tell you what it does at a glance. The Block is the standalone component (.card). An Element is a part that only exists inside it, joined with two underscores (.card__title). A Modifier is a variant or state, joined with two dashes (.card--featured or .btn--lg). You never have to guess whether .title belongs to the card or the modal — the name carries that scope. It keeps specificity flat (everything is a single class) so styles are easy to override and never leak into unrelated parts of the page.
Why should I avoid descendant selectors like .card div p for components?
A selector like .card div p reaches into any paragraph anywhere inside a card, including ones nested in a component you dropped in later. It is fragile (a new wrapper div breaks it), it is hard to override (the longer the selector, the higher its specificity), and it leaks (an unrelated paragraph that happens to land inside a card gets your styling). Give each part its own single class — .card__body — and target that. One class, one job, no surprises.
Should I hard-code colours in a component or use CSS variables?
Use variables (design tokens). If you write background: #1976D2 in twenty rules, rebranding means twenty find-and-replace edits and one missed instance breaks consistency. Declare --primary: #1976D2 once and write background: var(--primary). Now the component has a small, documented API: anyone can re-theme it by overriding --primary on a parent — without ever editing your component's stylesheet. Hard-coded values are fine only for one-off, genuinely-never-reused pixels.
How do I combine a variant and a size on the same element?
Put multiple classes on the element: class="btn btn--primary btn--lg". The base class .btn carries the shared structure (padding, radius, flex layout), .btn--primary sets the colour, and .btn--lg overrides the size. Because each modifier only touches the properties it owns and they do not conflict, they compose cleanly. This is why a base-plus-modifier system scales: five colours times three sizes is eight small classes, not fifteen big ones.
How does a component expose a customisation API without exposing its internals?
Declare the knobs you want consumers to turn as CSS variables on the component's base class, reading them with a fallback: padding: var(--btn-pad, 10px 20px). Consumers set --btn-pad on the element or a parent to customise it; everything else stays private. The variable list is effectively the component's public API — small, named, and documented — while the structural CSS underneath is free to change.
What is the difference between a variant class and a state class?
A variant is a design choice you pick when you place the component — .btn--primary versus .btn--danger, .card--bordered versus .card--accent. A state reflects what is happening to it right now — disabled, error, loading — and often maps to a real attribute or pseudo-class (:disabled, .form-input--error). Keeping them as separate modifier classes means a danger button can still be disabled, and an error input can still be large, without you writing a class for every combination.
🎉 Lesson Complete
You can now design small, themeable component systems instead of one-off styles. The essentials:
- ✅ A component = a base class (structure) plus small modifier classes (one job each)
- ✅ Compose variants on an element:
class="btn btn--primary btn--lg" - ✅ Name with BEM:
block,block__element,block--modifier - ✅ Compound components keep each part a flat, optional class — no deep
.card div pchains - ✅ Custom properties are the component's public API — re-theme by overriding a token
- ✅ Separate variants (a design choice) from states (disabled, error) so they compose freely
Next up: Canvas, where you'll draw and animate graphics directly in the browser.
Sign up for free to track which lessons you've completed and get learning reminders.