CSS Component Composition Patterns
Composition is the art of combining classes from multiple methodology layers on a single HTML element to build exactly the component you need. Instead of writing a new CSS class for every variation, you compose from existing objects, components, modifiers, states, and utilities.
1. The Composition Mental Model
One element = Multiple class roles:
<article class="o-media c-card c-card--featured is-loading u-w-full js-card">
│ │ │ │ │ │
│ │ │ │ │ └── JS hook (no CSS)
│ │ │ │ └── Utility (override)
│ │ │ └── State (JS-toggled)
│ │ └── Modifier (variant)
│ └── Component (styled)
└── Object (structure)
Each class has ONE responsibility. Together, they compose the complete element.
2. Object + Component Composition
The most fundamental pattern: an Object provides the structure, a Component adds the skin.
<!-- Object: o-media provides flex structure -->
<!-- Component: c-testimonial provides visual design -->
<div class="o-media c-testimonial">
<img class="o-media__figure c-testimonial__avatar" src="avatar.jpg" alt="">
<div class="o-media__body c-testimonial__body">
<p class="c-testimonial__quote">"Best product ever."</p>
<cite class="c-testimonial__author">— John Smith</cite>
</div>
</div>
/* Object: pure structure, no decoration */
.o-media { display: flex; align-items: flex-start; gap: 1.25rem; }
.o-media__figure { flex-shrink: 0; }
.o-media__body { flex: 1; min-width: 0; }
/* Component: styled skin layered on top */
.c-testimonial {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
}
.c-testimonial__avatar { width: 56px; height: 56px; border-radius: 50%; object-fit: cover; }
.c-testimonial__quote { font-size: var(--text-lg); font-style: italic; color: var(--color-text); line-height: 1.6; }
.c-testimonial__author { font-size: var(--text-sm); color: var(--color-primary); font-style: normal; margin-top: 0.5rem; display: block; }
3. Component + Modifier Composition
Apply a block modifier to change only specific visual properties without rewriting the base component:
<!-- Base component -->
<article class="c-card">...</article>
<!-- With single modifier -->
<article class="c-card c-card--featured">...</article>
<!-- With multiple modifiers -->
<article class="c-card c-card--dark c-card--horizontal">...</article>
<!-- Note: never write .c-card--dark--horizontal (no double modifier) -->
<!-- Instead: both modifiers on the same element -->
/* Base component */
.c-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
overflow: hidden;
}
/* Each modifier changes ONLY what it needs to change */
.c-card--featured {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary), var(--shadow-md);
}
.c-card--dark {
background: var(--color-bg);
border-color: rgba(255,255,255,0.04);
}
.c-card--glass {
background: rgba(255,255,255,0.04);
backdrop-filter: blur(20px);
border-color: rgba(255,255,255,0.08);
}
.c-card--horizontal {
display: flex;
flex-direction: row;
}
.c-card--horizontal .c-card__image {
width: 200px;
flex-shrink: 0;
aspect-ratio: auto;
height: 100%;
}
4. Component + Object + Layout Composition
Build a full page section by stacking all layer types:
<!-- Section: layout provides page-level spacing -->
<section class="l-section">
<!-- Container: object constrains max-width -->
<div class="o-container o-container--narrow">
<!-- Stack: object provides vertical rhythm -->
<div class="o-stack o-stack--lg">
<!-- Heading: element-level (no class needed if using @layer elements) -->
<h2>Our Features</h2>
<!-- Grid: object provides responsive columns -->
<div class="o-grid o-grid--3">
<!-- Feature card: component provides styled UI -->
<div class="c-feature-card">
<span class="c-feature-card__icon">⚡</span>
<h3 class="c-feature-card__title">Fast</h3>
<p class="c-feature-card__text">Blazing speed on every device.</p>
</div>
<div class="c-feature-card c-feature-card--highlighted">
<span class="c-feature-card__icon">🔒</span>
<h3 class="c-feature-card__title">Secure</h3>
<p class="c-feature-card__text">Enterprise-grade security.</p>
</div>
<div class="c-feature-card">
<span class="c-feature-card__icon">♾️</span>
<h3 class="c-feature-card__title">Scalable</h3>
<p class="c-feature-card__text">Grows with your business.</p>
</div>
</div>
</div>
</div>
</section>
/* Layout: page-level vertical spacing */
.l-section { padding-block: clamp(4rem, 8vw, 8rem); }
/* Objects: structure patterns */
.o-container { width: 100%; max-width: 1200px; margin-inline: auto; padding-inline: clamp(1rem, 4vw, 2rem); }
.o-container--narrow { max-width: 800px; }
.o-stack { display: flex; flex-direction: column; }
.o-stack--lg { gap: 3rem; }
.o-grid { display: grid; gap: var(--grid-gap, 1.5rem); }
.o-grid--3 { grid-template-columns: repeat(3, 1fr); }
/* Component: feature card */
.c-feature-card {
padding: 2rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.c-feature-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); }
.c-feature-card__icon { font-size: 2.5rem; margin-bottom: 1rem; }
.c-feature-card__title { font-size: var(--text-xl); font-weight: 700; margin-bottom: 0.5rem; color: var(--color-text); }
.c-feature-card__text { color: var(--color-text-muted); line-height: 1.6; }
.c-feature-card--highlighted {
background: rgba(108, 99, 255, 0.08);
border-color: rgba(108, 99, 255, 0.3);
}
5. State Composition (SMACSS is- Integration)
State classes are added and removed by JavaScript. They compose on top of any class:
<!-- Non-submitted form -->
<form class="c-form js-contact-form">
<input class="c-form__input" type="email">
</form>
<!-- After JS validation attempt (invalid) -->
<form class="c-form js-contact-form has-error">
<input class="c-form__input is-invalid" type="email">
</form>
<!-- After successful submission -->
<form class="c-form js-contact-form is-success">
<button class="c-btn c-btn--primary is-loading" type="submit">Sending...</button>
</form>
/* State classes add to component — never standalone */
.c-form__input.is-invalid {
border-color: var(--color-danger);
box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.2);
}
.c-form__input.is-valid {
border-color: var(--color-success);
}
.c-form.has-error .c-form__label { color: var(--color-danger); }
.c-form.has-error .c-form__error { display: block; }
.c-btn.is-loading {
position: relative;
color: transparent;
pointer-events: none;
}
.c-btn.is-loading::after {
content: '';
position: absolute;
width: 1rem; height: 1rem;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
6. Utility Composition (Last Resort Override)
Utilities are the escape valve — when a component needs a one-off adjustment in a specific context:
<!-- Card normally centered; this instance needs left-align text -->
<div class="c-card u-text-left">...</div>
<!-- Feature grid item should span 2 columns — use inline CSS or utility -->
<div class="c-feature-card" style="grid-column: span 2;">...</div>
<!-- Visually hidden text for screen readers -->
<span class="u-sr-only">Learn more about our pricing</span>
<!-- Force full width on mobile via utility -->
<a class="c-btn c-btn--primary u-w-full sm:u-w-auto" href="#">Start Free Trial</a>
<!-- Truncate card title that's too long for a grid layout -->
<h2 class="c-card__title u-truncate">Very Long Title That Would Overflow</h2>
/* Utilities override anything (they're last in the cascade or in @layer utilities) */
.u-sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
.u-truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.u-text-left { text-align: left !important; }
.u-w-full { width: 100% !important; }
.u-hidden { display: none !important; }
.u-min-w-0 { min-width: 0 !important; }
7. OOCSS Skin Composition
OOCSS lets you compose structure + multiple skins:
<!-- Structure class + skin class(es) -->
<button class="btn btn-lg btn-primary">Large Primary</button>
<button class="btn btn-sm btn-outline">Small Outline</button>
<span class="badge badge-success badge-sm">Active</span>
<div class="box box-card box-spacious">Card Content</div>
<div class="alert alert-warning">Warning message</div>
/* Structure objects */
.btn { display: inline-flex; align-items: center; padding: 0.7rem 1.5rem; border-radius: var(--radius-full); font-weight: 600; cursor: pointer; border: 2px solid transparent; transition: all 0.2s; }
.box { display: block; padding: var(--space-6); overflow: hidden; }
.badge { display: inline-flex; align-items: center; padding: 0.2rem 0.6rem; border-radius: var(--radius-full); font-size: 0.75rem; font-weight: 700; }
.alert { display: flex; gap: 0.75rem; padding: 1rem 1.25rem; border-radius: var(--radius-md); border-left: 4px solid transparent; }
/* Skin — color */
.btn-primary { background: var(--color-primary); color: white; }
.btn-outline { border-color: var(--color-primary); color: var(--color-primary); }
.badge-success { background: rgba(56,161,105,0.15); color: var(--color-success); }
.badge-danger { background: rgba(229,62,62,0.15); color: var(--color-danger); }
.box-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); }
.box-glass { background: rgba(255,255,255,0.05); backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.08); border-radius: var(--radius-lg); }
.alert-warning { background: rgba(237,137,54,0.1); border-left-color: var(--color-warning); }
/* Skin — size */
.btn-sm { padding: 0.4rem 1rem; font-size: var(--text-sm); }
.btn-lg { padding: 1rem 2rem; font-size: 1.125rem; }
.badge-sm { font-size: 0.65rem; padding: 0.15rem 0.5rem; }
.box-spacious { padding: var(--space-8); }
.box-compact { padding: var(--space-3); }
8. The Complete Composition Pattern in a Real Page
Build a pricing card using all layers composed together:
<!-- Pricing card: all layers composed -->
<div class="o-grid o-grid--3 l-pricing-section">
<!-- Card 1: Basic -->
<article class="c-pricing-card">
<div class="c-pricing-card__header">
<span class="badge badge-neutral">Basic</span>
<div class="c-pricing-card__price">
<span class="c-pricing-card__amount">$0</span>
<span class="c-pricing-card__period">/month</span>
</div>
</div>
<ul class="c-pricing-card__features o-stack o-stack--sm">
<li class="c-pricing-card__feature">5 projects</li>
<li class="c-pricing-card__feature">Basic analytics</li>
<li class="c-pricing-card__feature c-pricing-card__feature--unavailable">Priority support</li>
</ul>
<a class="c-btn c-btn--outline u-w-full" href="#">Get Started</a>
</article>
<!-- Card 2: Pro — featured state + modifier -->
<article class="c-pricing-card c-pricing-card--featured is-recommended">
<div class="c-pricing-card__header">
<span class="badge badge-primary">Pro</span>
<div class="c-pricing-card__price">
<span class="c-pricing-card__amount">$29</span>
<span class="c-pricing-card__period">/month</span>
</div>
</div>
<ul class="c-pricing-card__features o-stack o-stack--sm">
<li class="c-pricing-card__feature">Unlimited projects</li>
<li class="c-pricing-card__feature">Advanced analytics</li>
<li class="c-pricing-card__feature">Priority support</li>
</ul>
<a class="c-btn c-btn--primary u-w-full" href="#">Start Free Trial</a>
</article>
</div>
/* Layout: section spacing */
.l-pricing-section { padding-block: clamp(4rem, 8vw, 8rem); }
/* Object: grid with CSS variable gap */
.o-grid { display: grid; gap: var(--grid-gap, 1.5rem); }
.o-grid--3 { grid-template-columns: repeat(3, 1fr); }
.o-stack { display: flex; flex-direction: column; }
.o-stack--sm { gap: 0.5rem; }
/* Component: pricing card */
.c-pricing-card {
padding: 2rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Modifier: featured pricing card */
.c-pricing-card--featured {
background: rgba(108, 99, 255, 0.06);
border-color: var(--color-primary);
transform: scale(1.04);
box-shadow: var(--shadow-lg), 0 0 0 1px var(--color-primary);
}
/* State: JS-marked as recommended */
.c-pricing-card.is-recommended::before {
content: 'Most Popular';
display: block;
text-align: center;
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: white;
background: var(--color-primary);
padding: 0.4rem;
margin: -2rem -2rem 0;
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
}
.c-pricing-card__amount { font-size: 3rem; font-weight: 900; color: var(--color-text); }
.c-pricing-card__period { color: var(--color-text-muted); font-size: var(--text-sm); }
.c-pricing-card__feature { font-size: var(--text-sm); color: var(--color-text-muted); }
.c-pricing-card__feature--unavailable { text-decoration: line-through; opacity: 0.4; }
/* Utility: u-w-full makes btn full width */
.u-w-full { width: 100% !important; }
9. Composition Anti-Patterns
<!-- ❌ Modifier stacking that overwrites itself -->
<button class="c-btn c-btn--primary c-btn--outline">
<!-- Both set background — undefined winner, confusing intent -->
<!-- ✅ Only compose modifiers that affect different properties -->
<button class="c-btn c-btn--primary c-btn--lg c-btn--block">
<!-- primary: color, lg: size, block: width — no conflicts -->
<!-- ❌ Utility replacing a component -->
<div class="u-bg-surface u-rounded u-p-6 u-shadow">
<!-- This is a component — write .c-card, not utility soup -->
<!-- ✅ Component for repeated patterns -->
<div class="c-card">...</div>
<!-- ❌ State class with no parent component -->
<div class="is-active"> <!-- Active what? -->
<!-- ✅ State always paired with component context -->
<div class="c-nav__item is-active">
10. Composition Cheat Sheet
Goal Class Pattern
──────────────────────────────────────────────────────────────────
Structure only .o-media, .o-grid, .o-stack
Styled component .c-card, .c-btn, .c-nav
Structural + styled .o-media.c-testimonial
Component + visual variant .c-card.c-card--featured
Component + size variant .c-btn.c-btn--primary.c-btn--lg
Component + JS state .c-btn.is-loading
Component + parent state .c-form.has-error .c-form__input
Component + one-off override .c-card.u-text-center
Component + JS hook .c-btn.c-btn--primary.js-submit
All combined (valid!) .o-media.c-testimonial.c-testimonial--compact.is-selected.u-w-full
The Golden Rule: Each class has ONE job.
Composition = delegating different jobs to the right class type.