Lab — Refactor a Stylesheet
Take a tangled stylesheet full of IDs, !important overrides, and duplicated color values and refactor it to use cascade layers, design tokens, and consistent naming.
- Extract color values into semantic CSS custom properties
- Introduce a spacing scale and replace magic-number margins
- Set up cascade layers and eliminate !important declarations
- Replace ID selectors used for styling with class selectors
- Verify a refactor is safe by checking visual output is unchanged
The techniques in this module are straightforward to explain but only become natural through practice. This lab walks you through a realistic refactor: an existing stylesheet that accumulated the most common structural problems over time, fixed in four focused passes.
Work in your own editor. Copy the starting stylesheet, apply each part in sequence, and check the visual output against the original after each pass.
The starting stylesheet
Below is the stylesheet you will refactor. It is intentionally imperfect — every problem is real and common.
/* ==========================================================================
STARTING STYLESHEET — do not submit this version
========================================================================== */
/* Reset */
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; }
/* Header */
#site-header {
background-color: #3a7bd5;
color: #ffffff;
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
#site-header .logo {
font-size: 1.25rem;
font-weight: 700;
color: #ffffff;
text-decoration: none;
}
#site-header nav a {
color: #ffffff;
text-decoration: none;
margin-left: 16px;
opacity: 0.85;
}
#site-header nav a:hover {
opacity: 1;
}
/* Hero */
.hero {
background-color: #eef3fb;
padding: 48px 24px;
text-align: center;
}
.hero h1 {
font-size: 2rem;
color: #1a1a2e;
margin-bottom: 16px;
}
.hero p {
font-size: 1.125rem;
color: #555;
max-width: 560px;
margin: 0 auto 24px;
}
.hero .cta-button {
background-color: #3a7bd5;
color: #ffffff !important;
padding: 12px 24px;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.hero .cta-button:hover {
background-color: #2a5fb0;
}
/* Cards */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
padding: 32px 24px;
}
.card {
background: #ffffff;
border: 1px solid #d1d5e0;
border-radius: 8px;
padding: 20px;
}
.card h2 {
font-size: 1.125rem;
color: #1a1a2e;
margin-top: 0;
margin-bottom: 8px;
}
.card p {
color: #555;
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 16px;
}
.card a {
color: #3a7bd5;
font-weight: 600;
text-decoration: none;
}
.card a:hover {
color: #2a5fb0;
}
/* Featured card — override base card styles */
.card.featured {
border-color: #3a7bd5 !important;
background-color: #eef3fb !important;
}
/* Footer */
#site-footer {
background-color: #1a1a2e;
color: #e8eaf6;
padding: 24px;
text-align: center;
font-size: 0.875rem;
}
#site-footer a {
color: #93bbf8 !important;
text-decoration: none;
}Before you start, take stock of the problems:
- The color
#3a7bd5appears 6 times;#1a1a2eappears 3 times;#ffffffappears 4 times;#eef3fbappears twice;#2a5fb0appears twice. !importantappears 4 times.#site-headerand#site-footerare ID selectors used purely for styling.- Margin and padding values (
8px,12px,16px,20px,24px,32px,48px) are literal numbers with no consistent scale.
Part 1 — Extract color tokens
Add a :root block at the top of the file (above the reset section) with all
named color values. Then replace every hex literal in the rules below with the
appropriate var() call.
Here is the token vocabulary to use:
:root {
/* Primitive palette */
--color-blue-300: #93bbf8;
--color-blue-500: #3a7bd5;
--color-blue-700: #2a5fb0;
--color-blue-subtle: #eef3fb;
--color-neutral-900: #1a1a2e;
--color-neutral-600: #555;
--color-neutral-200: #d1d5e0;
--color-neutral-100: #e8eaf6;
--color-white: #ffffff;
/* Semantic tokens */
--color-bg: var(--color-white);
--color-text: var(--color-neutral-900);
--color-text-muted: var(--color-neutral-600);
--color-border: var(--color-neutral-200);
--color-interactive: var(--color-blue-500);
--color-interactive-hover: var(--color-blue-700);
--color-interactive-subtle: var(--color-blue-subtle);
--color-interactive-link: var(--color-blue-300);
--color-surface-brand: var(--color-blue-500);
--color-surface-dark: var(--color-neutral-900);
--color-on-brand: var(--color-white);
--color-on-dark: var(--color-neutral-100);
}After this pass, no hex value should appear in any rule block. Every color
should come from a var() call.
Check: the visual output should be identical to the original.
Part 2 — Introduce a spacing scale
The spacing values in use (8, 12, 16, 20, 24, 32, 48 px) map cleanly onto a
0.25rem base unit scale. Add spacing tokens to the :root block:
:root {
/* ... color tokens from Part 1 ... */
/* Spacing scale (base unit: 0.25rem = 4px) */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-12: 3rem; /* 48px */
}Then replace every pixel value used for padding, margin, and gap with the
corresponding token. Leave font-size and border-radius as-is for now —
those need their own scales which are outside this lab's scope.
Check: pixel-perfect identical output.
Part 3 — Add cascade layers and remove !important
Wrap the existing rule sections in named layers. The goal is to make
!important unnecessary by ensuring utilities and state variants live in a
higher layer than base component styles.
@layer reset, tokens, base, components, utilities;
/* Move the :root tokens block here: */
@layer tokens {
:root {
/* ... all tokens from Parts 1 and 2 ... */
}
}
@layer reset {
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; }
}
@layer base {
/* header, hero, card-grid, card, footer base styles */
}
@layer components {
/* .cta-button, .card.featured */
}
@layer utilities {
/* nothing yet — reserved */
}Now remove every !important from the rule blocks. Because .card.featured
is in @layer components and .card is also in @layer components, you do
need to ensure .card.featured appears after .card within that layer — or
use slightly more specific selectors to make the intent clear. The point is that
no !important flag is needed.
When two rules in the same layer tie on specificity and source order, the
later one wins — just like unlayered CSS. Layer order controls inter-layer
priority; within a layer, normal cascade rules apply. So move .card.featured
after .card in @layer components and you are done.
Check: featured cards still have the blue border and tinted background; CTA buttons are still white text; footer links are still light blue.
Part 4 — Replace ID selectors with class selectors
#site-header and #site-footer are ID selectors used only for styling. They
carry unnecessarily high specificity and break the layer model (an ID selector
in a lower layer can still beat a class selector in a higher layer if the
specificity is high enough — this is the one situation where specificity
pierces the layer boundary).
Update the HTML:
<!-- Before -->
<header id="site-header">…</header>
<footer id="site-footer">…</footer>
<!-- After -->
<header id="site-header" class="site-header">…</header>
<footer id="site-footer" class="site-footer">…</footer>Keep the id attributes — they may be used as anchor targets or JavaScript
hooks. Add class attributes alongside them. Then update the CSS rules:
/* Replace all #site-header rules with .site-header */
/* Replace all #site-footer rules with .site-footer */Check: header and footer are unchanged visually.
Self-review checklist
Run through these before considering the refactor complete:
- No hex color values remain in rule blocks (all moved to
:roottokens) - No magic-number
pxspacing values inpadding,margin, orgaprules - No
!importantdeclarations anywhere in the stylesheet - No ID selectors used for styling (IDs may remain in HTML for JS/anchor use)
- All layers declared at the top in priority order:
reset, tokens, base, components, utilities - Visual output is indistinguishable from the starting stylesheet in all states (hover, featured card, footer links)
"Visual output is identical" requires actual browser testing — open both versions side by side or use a screenshot diffing tool. It is easy to introduce subtle regressions when changing token names or layer assignments that only become visible in a specific hover state or screen size.
Going further
Once the refactor is stable, consider these next steps on your own:
-
Add a dark-mode theme: create a
[data-theme="dark"]block in@layer tokensthat redefines the semantic color tokens. Toggledata-themeon<html>via a button and confirm that the entire page switches without any component-level CSS changes. -
Add a
--font-size-*scale and a--radius-*scale to the tokens layer, then replace remaining literal values for those properties. -
Rename the component classes to follow BEM:
site-header__logo,site-header__nav,card__title,card__body,card__link,card--featured. This makes the relationship between elements explicit in the class name itself.
CSS Methodology Trade-offs
Utility-first, component-scoped, and BEM are different answers to the same question — how do you stop CSS from growing into an unmanageable tangle at scale?
Core Web Vitals
Core Web Vitals are Google's three user-experience metrics — LCP, INP, and CLS — that measure loading speed, interactivity, and visual stability.