Organize CSS for large applications using proven methodologies like BEM, ITCSS, and utility-first approaches. Covers naming conventions, CSS custom properties, scoping strategies, specificity management, and migration paths from CSS chaos to maintainable stylesheets.
CSS architecture at scale is the discipline of structuring and maintaining CSS across large, multi-team frontend codebases where style consistency, performance, and developer velocity are non-negotiable. It matters when a single project spans hundreds of components, tens of thousands of lines of CSS, and involves developers across multiple domains, all needing to extend, customize, and maintain shared styles without conflict.
When multiple teams work on the same codebase and styles must be shared, the primary goal is to ensure that every new component adheres to a consistent style language, and that changes to base styles ripple predictably across the application.
Establish a dedicated styles/ directory at the project root, containing a globals.css file that imports all foundational styles:
/* styles/globals.css */
@import 'base/typography.css';
@import 'base/spacing.css';
@import 'base/colors.css';
@import 'base/elements.css';
@import 'base/resets.css';
@import 'layout/grid.css';
@import 'layout/typography.css';
@import 'layout/spacing.css';
This file is imported once in the application’s entry point (index.html or main.ts):
<link rel="stylesheet" href="/styles/globals.css" />
The @import syntax is critical: it enables modular, incremental loading and allows build tools to tree-shake individual style modules. Ensure that each imported file is self-contained, with no global dependencies beyond the :root variables and the @layer declaration.
@layer to Partition the Style HierarchyIn styles/base/typography.css, define a layer for all typography rules:
@layer base, components, utilities;
@layer base {
:root {
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.125rem;
--font-weight-regular: 400;
--font-weight-bold: 700;
}
body {
font-family: system-ui, sans-serif;
line-height: 1.5;
color: var(--color-text-primary);
}
h1, h2, h3, h4, h5, h6 {
font-weight: var(--font-weight-bold);
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
}
Use @layer to define the canonical style layering:
base: foundational styles (fonts, colors, spacing, resets)components: style definitions for individual UI components (buttons, cards, modals)utilities: one-off, low-specificity classes (e.g., text-center, mt-4, flex-wrap)This layering enables predictable cascade order and allows build tools to generate a single, optimized CSS file with layer-specific rules grouped and prioritized.
When a codebase grows beyond 100 components, the challenge shifts from defining styles to managing component-specific CSS with minimal coupling and maximal reusability.
Each component lives in its own directory, with a styles.css file that is independent of global styles:
components/
Button/
Button.tsx
Button.css
Button.stories.tsx
index.ts
The Button.css file is declared as a scoped stylesheet:
/* components/Button/Button.css */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: var(--border-radius-md);
padding: 0.5rem 1rem;
font-size: var(--font-size-md);
font-weight: var(--font-weight-regular);
border: 1px solid var(--color-border-default);
/* Default variant */
&.primary {
background-color: var(--color-primary);
color: var(--color-on-primary);
border-color: var(--color-primary);
}
&.secondary {
background-color: var(--color-secondary);
color: var(--color-on-secondary);
border-color: var(--color-secondary);
}
&.small {
padding: 0.25rem 0.75rem;
font-size: var(--font-size-sm);
}
&.large {
padding: 0.75rem 1.5rem;
font-size: var(--font-size-lg);
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
This structure allows the component to be consumed independently:
// components/Button/Button.tsx
import styles from './Button.css';
export function Button({ children, variant = 'primary', size = 'medium', disabled = false, ...props }) {
return (
<button
className={`
${styles.button}
${styles[variant]}
${styles[size]}
${disabled ? styles.disabled : ''}
`}
{...props}
>
{children}
</button>
);
}
The use of className concatenation ensures that component styles are composable and predictable. No additional CSS frameworks or class prefixes are required.
At scale, style consistency cannot rely on developer discipline alone. Build tools must enforce conventions and catch regressions early.
Use stylelint with a configuration file, stylelint.config.js, to enforce a shared style guide across all teams:
// stylelint.config.js
module.exports = {
extends: ['stylelint-config-standard'],
plugins: ['stylelint-order'],
rules: {
'at-rule-empty-line-before': [
'always',
{
except: ['first-nested', 'after-comment', 'after-declaration'],
ignore: ['after-comment'],
},
],
'declaration-block-no-duplicate-properties': true,
'declaration-block-no-shorthand-property-overrides': true,
'declaration-block-trailing-semicolon': 'always',
'declaration-empty-line-before': 'always',
'order/order': [
'custom-properties',
'declarations',
{
type: 'at-rule',
name: 'media',
},
{
type: 'at-rule',
name: 'keyframes',
},
'rules',
],
'property-no-unknown': [
true,
{
ignoreProperties: [
'font-size-sm',
'font-size-md',
'font-size-lg',
'border-radius-md',
'color-primary',
'color-on-primary',
],
},
],
},
};
Run stylelint as part of the CI pipeline:
name: Lint CSS
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm install
- run: npx stylelint 'styles/**/*.css' 'components/**/*.css'
A failing stylelint run produces clear, actionable output:
src/components/Button/Button.css
15:1 ✖ Expected empty line before declaration block declaration-empty-line-before
22:3 ✖ Unknown property "border-radius-md" property-no-unknown
30:1 ✖ Expected a newline after rule rule-empty-line-before
This output is precise, machine-readable, and requires no context to act on.
As the codebase grows, teams begin to rely on shared design tokens and atomic utilities. The challenge is to keep the design system both flexible and performant.
Create a tokens.json file in the design-system/ directory:
{
"spacing": {
"xs": "0.25rem",
"sm": "0.5rem",
"md": "1rem",
"lg": "1.5rem",
"xl": "2rem"
},
"colors": {
"primary": "#3b82f6",
"on-primary": "#ffffff",
"secondary": "#10b981",
"on-secondary": "#ffffff",
"text-primary": "#111827",
"text-secondary": "#6b7280",
"border-default": "#d1d5db"
},
"typography": {
"font-size-sm": "0.875rem",
"font-size-md": "1rem",
"font-size-lg": "1.125rem",
"font-weight-regular": 400,
"font-weight-bold": 700
},
"border-radius": {
"sm": "0.25rem",
"md": "0.5rem",
"lg": "0.75rem"
}
}
This file is consumed by both the CSS build system and the component library.
Use a build script (e.g., generate-styles.js) to convert tokens.json into CSS custom properties:
// scripts/generate-styles.js
const fs = require('fs');
const path = require('path');
const tokens = JSON.parse(fs.readFileSync(path.join(__dirname, '../design-system/tokens.json')));
const css = Object.entries(tokens)
.map(([category, values]) =>
Object.entries(values)
.map(([key, value]) => `--${category}-${key}: ${value};`)
.join('\n')
)
.join('\n');
fs.writeFileSync('styles/base/tokens.css', `:root {\n${css}\n}`);
Run this script before every build:
{
"scripts": {
"build": "node scripts/generate-styles.js && npm run build:css",
"build:css": "postcss styles/globals.css -o dist/styles.css"
}
}
This ensures that every component and page uses the same set of design tokens, and that changes to tokens.json ripple through the entire application.
With thousands of CSS declarations, the challenge shifts from correctness to performance: how to minimize CSS payload, ensure fast rendering, and avoid layout thrashing.
Use postcss with postcss-purgecss to eliminate unused CSS:
// postcss.config.js
const purgecss = require('purgecss');
module.exports = {
plugins: [
require('postcss-import'),
require('postcss-nested'),
require('postcss-custom-properties'),
require('postcss-apply'),
require('postcss-preset-env')({
stage: 3,
features: {
'custom-properties': { preserve: true },
'nesting-rules': true,
},
}),
new purgecss({
content: [
'./src/**/*.html',
'./src/**/*.tsx',
'./src/**/*.ts',
],
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || [],
extractors: [
{
extractor: class {
static extract(content) {
const matches = content.match(/[\w-/:]+(?<!:)/g);
return matches || [];
}
},
extensions: ['html', 'js', 'tsx'],
},
],
safelist: [
'btn',
'primary',
'large',
'flex',
'justify-center',
'items-center',
'text-center',
'shadow',
'rounded',
'bg-primary',
'text-on-primary',
],
}),
],
};
The purgecss configuration ensures that only used classes are included in the final CSS. Classes that appear in templates but not in component code are included via the safelist.
Split the CSS into two parts:
head of index.htmlrel="preload" and media="print"<head>
<link rel="preload" href="/styles/non-critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles/non-critical.css"></noscript>
<link rel="stylesheet" href="/styles/critical.css">
</head>
This ensures that the page is interactive before all styles are loaded.
@layer OrderA common issue is that @layer declarations are applied in the wrong order. The build tool may not reorder layers correctly, leading to unexpected style precedence.
Example:
@layer components, utilities;
@layer components {
.card {
padding: 1rem;
border-radius: 0.5rem;
}
}
@layer utilities {
.text-center {
text-align: center;
}
}
If components is defined after utilities, but the build tool applies utilities first, then .card may not inherit text-center correctly.
Ensure that the build tool explicitly processes layers in the order specified. Use postcss with postcss-order to verify layer order:
require('postcss-order')({
order: ['base', 'components', 'utilities'],
ignore: ['custom-properties'],
}),
&.variant and &-variantDevelopers often mix up nested &.variant with &-variant syntax, leading to unexpected class application.
.button {
&.primary {
background: blue;
}
&-large {
padding: 1rem;
}
}
.button.primary applies the primary rule..button.large applies the large rule, but not the primary rule.This leads to inconsistent styling. Use postcss-nested with merge: true to ensure that &.variant and &-variant are treated as distinct patterns.
@layer in Component FilesComponents are often authored with global styles, but the @layer declaration is omitted. This leads to styles being applied at the wrong layer.
Fix: enforce @layer components in all component .css files using stylelint:
{
"rules": {
"at-rule-name-case": "lower",
"at-rule-empty-line-before": [
"always",
{
except: ["first-nested"],
ignore: ["after-comment"]
}
],
"at-rule-no-unknown": [
true,
{
ignoreAtRules: ["layer"]
}
]
}
}
This catches missing @layer declarations and ensures consistent layering.
@layer to define a predictable cascade order across the codebase.styles.css files that are independent and composable.tokens.json and generate CSS variables from them.stylelint with real rules to enforce style consistency.postcss with purgecss to eliminate unused styles and reduce bundle size.This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy