Project: Component Library
Chapter 12 — Project: Component Library with Modern CSS
This capstone project brings together every technique from the course into a
single, coherent component library. You'll build a production-ready set of
components backed by a three-tier token system, scoped with @layer,
sized with container queries, internationalized with logical properties,
animated with scroll-driven and @property transitions, and
performance-hardened with contain and will-change.
No framework required — pure, modern CSS.
@layer
Container queries
Subgrid
:has()
Scroll-driven
Logical props
oklch colors
@property
contain
Token system
light-dark()
1. Project Structure
tokens/
- primitives.css
- semantic.css
- components.css
base/
- reset.css
- typography.css
- houdini.css
components/
- button.css
- badge.css
- card.css
- input.css
- modal.css
- disclosure.css
- progress.css
layouts/
- grid.css
- stack.css
- cluster.css
- sidebar.css
utilities/
- spacing.css
- typography.css
- display.css
- a11y.css
index.css
- @layer declarations
- @import ordering
/* ── index.css — @layer order is declared first, always ─────── */
/* This is the single most important architectural decision. */
/* Layers are resolved in this order regardless of import order. */
@layer reset, tokens, base, layouts, components, utilities, overrides;
/* Tokens load first — all custom properties available globally */
@import 'tokens/primitives.css' layer(tokens);
@import 'tokens/semantic.css' layer(tokens);
@import 'tokens/components.css' layer(tokens);
@import 'base/reset.css' layer(reset);
@import 'base/typography.css' layer(base);
@import 'base/houdini.css' layer(base);
@import 'layouts/grid.css' layer(layouts);
@import 'layouts/stack.css' layer(layouts);
@import 'layouts/cluster.css' layer(layouts);
@import 'layouts/sidebar.css' layer(layouts);
@import 'components/button.css' layer(components);
@import 'components/badge.css' layer(components);
@import 'components/card.css' layer(components);
@import 'components/input.css' layer(components);
@import 'components/modal.css' layer(components);
@import 'components/disclosure.css' layer(components);
@import 'components/progress.css' layer(components);
@import 'utilities/spacing.css' layer(utilities);
@import 'utilities/a11y.css' layer(utilities);
Layer resolution order — lowest (first declared) to highest (overrides):
7. overrides
Per-page, theme, a/b test
6. utilities
Spacing, display, a11y helpers
5. components
Button, Card, Input, Modal…
4. layouts
Grid, Stack, Cluster, Sidebar
3. base
Typography, Houdini @property
2. tokens
All CSS custom properties
1. reset
Normalize browser defaults
2. Reset and Base Styles
/* ── base/reset.css ─────────────────────────────────────────── */
@layer reset {
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
color-scheme: light dark; /* unlocks light-dark() */
-webkit-text-size-adjust: none;
tab-size: 4;
}
body {
min-block-size: 100dvb; /* logical, dynamic viewport */
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family-body);
font-size: var(--font-size-md);
line-height: 1.6;
}
img, video, svg {
max-inline-size: 100%; /* logical property — replaces max-width */
display: block;
}
button {
font: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
border-radius: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
/* ── base/houdini.css — @property registrations ─────────────── */
/* Registered before use so transitions animate correctly */
@layer base {
@property --progress-pct {
syntax: '<percentage>';
inherits: false;
initial-value: 0%;
}
@property --overlay-opacity {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
@property --ripple-scale {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
}
3. Layout Primitives
Layout primitives are small, composable CSS classes built on a handful of properties each. They handle flow rather than visual appearance, and they compose cleanly with any component.
/* ── layouts/stack.css — vertical rhythm ────────────────────── */
/* .stack > * + * gets a top margin — the "lobotomised owl" */
@layer layouts {
.stack {
display: flex;
flex-direction: column;
gap: var(--stack-gap, var(--space-4));
}
/* ── layouts/cluster.css — wrapping row of items ─────────── */
.cluster {
display: flex;
flex-wrap: wrap;
gap: var(--cluster-gap, var(--space-2));
align-items:center;
}
/* ── layouts/sidebar.css — sidebar + main content ───────── */
.sidebar-layout {
display: flex;
flex-wrap: wrap;
gap: var(--space-6);
}
.sidebar-layout > .sidebar {
flex-basis: var(--sidebar-width, 16rem);
flex-grow: 1;
}
.sidebar-layout > .main {
flex-basis: 0;
flex-grow: 999;
min-inline-size: 60%; /* breakpoint: wraps when main < 60% */
}
/* ── layouts/grid.css — auto-responsive card grid ───────── */
.auto-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(var(--min-col, 16rem), 1fr));
gap: var(--grid-gap, var(--space-4));
}
/* Subgrid card rows — all cards share the same row heights */
.auto-grid.subgrid-rows > * {
display: grid;
grid-row: span 3;
grid-template-rows: subgrid; /* Ch. 3 — card rows align across columns */
}
.auto-grid.subgrid-rows {
grid-template-rows: auto 1fr auto; /* header / body / footer */
}
}
4. Button Component
Live output
Button variants
/* ── components/button.css ───────────────────────────────────── */
@layer components {
/* Base — logical properties throughout (Ch. 6) */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding-block: var(--btn-padding-block);
padding-inline: var(--btn-padding-inline);
border-radius: var(--btn-radius);
font-size: var(--btn-font-size);
font-weight: 600;
border: none;
cursor: pointer;
text-decoration:none;
white-space: nowrap;
user-select: none;
contain: layout style; /* Ch. 9 — isolates layout impact */
will-change: transform; /* Ch. 9 — promotes on hover */
transition:
background var(--duration-short) var(--easing-default),
transform var(--duration-short) var(--easing-default),
box-shadow var(--duration-short) var(--easing-default);
}
/* :has() — button containing a spinner is loading (Ch. 4) */
.btn:has(.spinner) {
opacity: 0.72;
pointer-events: none;
cursor: not-allowed;
}
/* Variants — each overrides only component tokens (Ch. 11) */
.btn--primary {
background: var(--btn-bg);
color: var(--btn-text);
}
.btn--primary:hover {
background: var(--btn-bg-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px oklch(from var(--btn-bg) l c h / 0.35);
}
.btn--secondary {
--btn-bg: var(--color-accent-subtle);
--btn-text: var(--color-accent);
background: var(--btn-bg);
color: var(--btn-text);
border: 1px solid var(--color-border);
}
.btn--ghost {
background: transparent;
color: var(--color-accent);
border: 1px solid currentColor;
}
.btn--danger {
--btn-bg: var(--color-danger);
--btn-bg-hover:oklch(from var(--color-danger) calc(l + 0.07) c h);
background: var(--btn-bg);
color: var(--color-text-on-accent);
}
/* Size modifier */
.btn--sm {
--btn-padding-block: var(--space-1);
--btn-padding-inline: var(--space-2);
--btn-font-size: var(--font-size-xs);
}
.btn--lg {
--btn-padding-block: var(--space-4);
--btn-padding-inline: var(--space-8);
--btn-font-size: var(--font-size-lg);
--btn-radius: var(--radius-lg);
}
}
5. Card Component with Container Queries
Live output
Card Title
Secondary content fills this area. Grows to fill available rows when subgrid is used.
New
Another Card
When these cards are in a subgrid, all footers align automatically — no JavaScript height matching needed.
Active
Third Card
Short body.
Draft
/* ── components/card.css ─────────────────────────────────────── */
@layer components {
.card {
container-type: inline-size; /* Ch. 2 — card reports its own width */
container-name: card;
background: var(--card-bg);
border: var(--card-border);
border-radius:var(--card-radius);
padding: var(--card-padding);
box-shadow: var(--card-shadow);
/* Subgrid: card becomes a three-row subgrid participant (Ch. 3) */
display: grid;
grid-template-rows: subgrid;
grid-row: span 3;
contain: layout style paint; /* Ch. 9 */
}
.card__title {
font-size: var(--font-size-lg);
font-weight: 700;
color: var(--color-text);
}
.card__body {
color: var(--color-text-subtle);
font-size: var(--font-size-sm);
line-height: 1.5;
}
.card__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-block-start: var(--space-2); /* logical — replaces padding-top */
border-block-start: 1px solid var(--color-border);
}
/* Container query — wide card switches to horizontal layout (Ch. 2) */
@container card (min-inline-size: 480px) {
.card {
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr auto;
}
.card__media {
grid-row: 1 / -1;
border-radius: calc(var(--card-radius) - 2px) 0 0
calc(var(--card-radius) - 2px);
}
}
/* :has() — card with a featured badge gets elevated shadow (Ch. 4) */
.card:has(.badge--featured) {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent), var(--shadow-card);
}
}
6. Progress Bar — @property Animated
Progress bars (animated via @property)
72% complete
38% complete
90% complete
/* ── components/progress.css ─────────────────────────────────── */
@layer components {
/* @property registered in base/houdini.css — enables smooth */
/* transition of --progress-pct (Ch. 8). Without registration, */
/* the browser can't interpolate a custom property value. */
.progress {
block-size: 6px; /* logical — replaces height */
background: var(--color-surface-2);
border-radius:var(--radius-full);
position: relative;
overflow: hidden;
contain: strict; /* Ch. 9 — completely isolated */
}
.progress::after {
content: '';
position: absolute;
inset-block: 0; /* logical — replaces top/bottom */
inset-inline-start:0; /* logical — replaces left */
inline-size: var(--progress-pct); /* set via data attr or JS */
background: var(--progress-color, var(--color-accent));
border-radius: inherit;
transition: inline-size 600ms var(--easing-default);
}
/* Animated stripe pattern using oklch (Ch. 7) */
.progress--striped::after {
background-image: repeating-linear-gradient(
-45deg,
oklch(1 0 0 / 0.1) 0px,
oklch(1 0 0 / 0.1) 10px,
transparent 10px,
transparent 20px
);
animation: progress-stripes 600ms linear infinite;
}
@keyframes progress-stripes {
from { background-position-x: 0; }
to { background-position-x: 28px; }
}
}
/* JavaScript — update progress with CSS custom property */
function setProgress(el, pct) {
el.style.setProperty('--progress-pct', pct + '%');
}
7. Input and Form Components
Input variants
/* ── components/input.css ────────────────────────────────────── */
@layer components {
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.field__label {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text);
}
.input {
background: var(--input-bg);
border: var(--input-border);
border-radius: var(--input-radius);
padding: var(--input-padding);
color: var(--color-text);
font-size: var(--font-size-md);
inline-size: 100%; /* logical — replaces width */
outline: none;
transition: border-color var(--duration-short) ease,
box-shadow var(--duration-short) ease;
}
.input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in oklch,
var(--color-accent) 20%,
transparent); /* Ch. 7 */
}
.input:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* :has() — field with an invalid input shows error state (Ch. 4) */
.field:has(.input:invalid:not(:placeholder-shown)) {
.field__label { color: var(--color-danger); }
.input {
border-color: var(--color-danger);
box-shadow: 0 0 0 3px color-mix(in oklch,
var(--color-danger) 20%,
transparent);
}
.field__hint { display: block; } /* reveal error hint */
}
}
8. Modal — View Transition and Enter Animation
/* ── components/modal.css ────────────────────────────────────── */
@layer components {
/* Overlay — @property makes opacity transition animatable (Ch. 8) */
.modal-overlay {
position: fixed;
inset: 0; /* logical shorthand for all four edges */
background: oklch(0 0 0 / var(--overlay-opacity));
transition: --overlay-opacity var(--duration-medium) ease;
--overlay-opacity: 0;
display: grid;
place-items:center;
}
.modal-overlay[aria-hidden="false"] {
--overlay-opacity: 0.6;
}
/* Dialog panel */
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-8);
max-inline-size:min(90vw, 540px); /* logical + fluid constraint */
box-shadow: var(--shadow-lg);
contain: layout style paint; /* Ch. 9 */
/* Enter animation via @keyframes + will-change */
animation: modal-enter var(--duration-medium) var(--easing-default) both;
will-change:transform, opacity; /* Ch. 9 */
}
@keyframes modal-enter {
from {
opacity: 0;
transform: translateY(24px) scale(0.96);
}
}
/* View Transition API — smooth cross-page modal navigation */
::view-transition-old(modal) {
animation: 200ms ease out both fade-out;
}
::view-transition-new(modal) {
animation: 300ms var(--easing-default) both modal-enter;
}
.modal {
view-transition-name: modal; /* Ch. 5 technique */
}
}
9. Scroll-Driven Content Reveal
/* ── utilities/a11y.css — screen-reader and scroll-driven utils ─ */
@layer utilities {
/* Screen reader only */
.sr-only {
position: absolute;
inline-size:1px;
block-size: 1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space:nowrap;
}
/* Scroll-driven entry reveal — no IntersectionObserver needed (Ch. 5) */
.reveal {
animation: reveal-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 30%;
}
@keyframes reveal-in {
from {
opacity: 0;
transform: translateY(32px);
}
}
/* Scroll progress indicator (Ch. 5) */
.scroll-progress {
position: fixed;
inset-block-start: 0;
inset-inline-start: 0;
inline-size: 100%;
block-size: 3px;
background: var(--color-accent);
transform-origin: 0 0;
animation: scroll-track linear both;
animation-timeline: scroll(root);
}
@keyframes scroll-track {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
}
10. Technique Reference Map
| Technique (Chapter) | Where it lives in the library | What it solves |
|---|---|---|
| @layer (Ch. 1) | index.css — seven-layer declaration | Predictable specificity. Third-party CSS imports go into a named layer and can never win against local component styles, even with low-specificity selectors. |
| Container queries (Ch. 2) | components/card.css, components/sidebar.css | Cards switch between vertical and horizontal layout based on their own width, not the viewport. No media-query breakpoints on the component — consumers place it anywhere. |
| Subgrid (Ch. 3) | layouts/grid.css + .card | Card headers, bodies, and footers align across columns without JavaScript height matching. |
| :has() (Ch. 4) | components/button.css, components/input.css, components/card.css | Loading state (button with spinner), form error state (field containing invalid input), featured styling (card containing featured badge) — all without JavaScript class toggling. |
| Scroll-driven (Ch. 5) | utilities/a11y.css (.reveal), any layout (.scroll-progress) | Entry animations driven by scroll position with no JavaScript or IntersectionObserver. The browser's compositor thread handles it — zero JS thread cost. |
| Logical properties (Ch. 6) | All components and layouts | Every sizing, spacing, and border property uses logical equivalents (inline-size, padding-block, inset-inline-start…). RTL languages get correct layout for free. |
| oklch / color-mix() (Ch. 7) | tokens/primitives.css, tokens/semantic.css, everywhere | Perceptually uniform colour scale. light-dark() semantic tokens. Focus ring glow derived from the accent with color-mix(). Hover shadows computed from button background via relative colour syntax. |
| @property (Ch. 8) | base/houdini.css, components/progress.css, components/modal.css | CSS can animate --progress-pct and --overlay-opacity because they are registered with a type. Without registration, transition on custom properties does nothing. |
| contain / will-change (Ch. 9) | All interactive components (.btn, .card, .modal, .progress) | contain: layout style isolates each component from triggering ancestor reflows. will-change: transform promotes hover targets to their own compositor layer — animations stay at 60fps. |
| Token system (Ch. 11) | tokens/ directory — three files, three tiers | All visual values live in tokens. Swapping data-theme attribute changes the brand colour everywhere. Semantic tokens use light-dark() so dark mode is zero extra code. |
Capstone Exercises
- Complete the library: Using the patterns established in this chapter, implement the remaining components:
Badge,Disclosure/Accordion,Tooltip,Toast, andTabs. Each must consume only semantic or component tokens (no raw values), use logical properties throughout, and be placed in the@layer componentslayer. The Disclosure should use:has()to style the trigger based on whether the panel is open. - Add a second brand theme: Implement a
data-theme="warm"theme using an amber/orange hue (H≈55 in oklch). Override only--brand-hueand--brand-chromain the selector. Verify that every component — buttons, progress bars, focus rings, card borders — switches colour without any component CSS being modified. Add a theme toggle button that saves the preference tolocalStorageand restores it on load. - Build a dashboard layout: Compose the layout primitives into a full page: a
.sidebar-layoutwrapping a navigation sidebar and a main content area, with an.auto-grid.subgrid-rowsof cards inside the main area. Add the.scroll-progressbar to the layout. All cards should use the subgrid row alignment so their footers are flush. Use container queries on the card so it switches to horizontal layout at 480px. Verify RTL layout by addingdir="rtl"to<html>— nothing should look broken. - Performance audit: Open Chrome DevTools Performance panel and record a 3-second scroll of your library demo page. Identify any frames that drop below 60fps. Use the Layers panel to verify that animated elements (buttons on hover, modals, progress bars) are on their own compositor layers. If any hover animation triggers a paint, add
will-change: transformto the relevant rule and confirm the paint disappears from the profile. - Export to Style Dictionary: Take the token files from this library and convert them to the W3C Design Token Community Group
$value/$typeJSON format. Set up a Style Dictionary config that outputs CSS custom properties (for the web), a JavaScript ESM export (for React components using inline styles or CSS-in-JS), and a Tailwindtheme.extendconfig. Run the build and verify all three outputs are consistent. Change one primitive (e.g.--blue-500hue from 248 to 240) and confirm the change propagates correctly to all three outputs.
CSS Advanced Course — Complete
You've worked through all twelve chapters of the CSS Advanced course: cascade control
with
@layer, component-aware layouts with container queries and subgrid,
powerful selectors with :has(), declarative animations with scroll-driven
timelines, internationalisation-ready layouts with logical properties, perceptually
uniform colour with oklch, low-level API access with CSS Houdini, rendering-pipeline
performance, framework integration patterns, and production-grade design tokens.
These are the tools that separate CSS written for today's web from CSS written for
the web of ten years ago. Build something with them.