CSS — Best Practices & Patterns
CSS is deceptively simple: anyone can change a color or align a box. But as a project grows, the global namespace, cascading rules, and loose coupling between markup and style make CSS one of the hardest languages to keep maintainable. This guide collects field-tested practices for writing CSS that scales from a single landing page to a multi-team design system.
The recommendations here span architecture (how files are organized), naming (how classes are coined), specificity (how conflicts are resolved), responsive behavior, modern features, performance, accessibility, and tooling. Use them as a baseline, and adapt the depth to your team size and product lifecycle.
info
Most CSS pain comes from a small set of recurring mistakes. The table below maps common anti-patterns to recommended alternatives. Internalize the left column as a warning, and the right column as the default choice.
| Avoid | Prefer | Why |
|---|---|---|
| #header | .site-header | IDs have high specificity and are hard to override |
| !important | Lower specificity or cascade layers | Important escalates conflicts and breaks component reuse |
| div.container > div | .container__item | Deep selectors are brittle and slow to match |
| px units everywhere | rem, em, %, ch, viewport units | Relative units support zoom, theming, and fluid layouts |
| float-based layouts | Flexbox or Grid | Modern layouts are simpler, robust, and responsive |
| @import in CSS | <link rel="stylesheet"> or bundler imports | @import blocks rendering and adds sequential requests |
| Magic numbers | CSS custom properties | Tokens document intent and enable theming |
| Global reset everything | Targeted resets and normalize layers | Aggressive resets fight browser defaults unexpectedly |
| 1 | /* Bad — high specificity, hard to reuse */ |
| 2 | #sidebar .widget ul li a { |
| 3 | color: #3B82F6; |
| 4 | } |
| 5 | |
| 6 | /* Better — flat, component-scoped */ |
| 7 | .widget__link { |
| 8 | color: var(--color-link); |
| 9 | } |
| 10 | |
| 11 | /* Bad — magic numbers */ |
| 12 | .hero { |
| 13 | margin-top: 73px; |
| 14 | } |
| 15 | |
| 16 | /* Better — derive from a token */ |
| 17 | .hero { |
| 18 | margin-top: var(--header-height); |
| 19 | } |
warning
Consistent code style reduces cognitive load during review and makes diffs cleaner. Adopt a written style guide, enforce it with a linter, and let the formatter handle the mechanical details so humans can focus on architecture.
| Rule | Standard | Rationale |
|---|---|---|
| Indentation | 2 spaces | Fits nested media queries and modern nesting |
| Quotes | Double for strings and attribute selectors | Consistency with HTML and JSON conventions |
| Trailing semicolons | Required on every declaration | Avoids silent errors when reordering lines |
| Leading zero | Omit before decimal point | .5rem reads cleaner than 0.5rem |
| Shorthand | Use only when you mean all values | background shorthand resets inherited sub-properties |
| Color format | hex for static, oklch/hsl for theming | Perceptually uniform spaces animate better |
| 1 | /* Good — consistent, readable, tokenized */ |
| 2 | .card { |
| 3 | --card-padding: 1rem; |
| 4 | display: flex; |
| 5 | flex-direction: column; |
| 6 | gap: .5rem; |
| 7 | padding: var(--card-padding); |
| 8 | background-color: var(--color-surface); |
| 9 | border: 1px solid var(--color-border); |
| 10 | border-radius: var(--radius-md); |
| 11 | } |
| 12 | |
| 13 | .card__title { |
| 14 | margin: 0; |
| 15 | font-size: var(--text-lg); |
| 16 | line-height: 1.25; |
| 17 | color: var(--color-text-strong); |
| 18 | } |
| 19 | |
| 20 | /* Bad — inconsistent, fragile, hard to scan */ |
| 21 | .card{ display:flex;flex-direction:column;gap:8px; padding:16px; |
| 22 | background:#fff; border:1px solid #ddd; border-radius:8px;} |
| 23 | .card h2{margin:0; font-size:20px; color:#000} |
best practice
Class names are the primary interface of your stylesheet. A good name explains what a thing is, not what it looks like. Names like .red-box age poorly when the design system changes; names like .alert--danger survive rebrands.
BEM (Block Element Modifier) is the most battle-tested naming convention for component CSS. Utility-first frameworks such as Tailwind take the opposite approach and encode style directly in class names. Both are valid; mixing them carelessly is where problems arise.
| 1 | /* BEM — flat, predictable, component-scoped */ |
| 2 | .button { } |
| 3 | .button__icon { } |
| 4 | .button--primary { } |
| 5 | .button--large { } |
| 6 | |
| 7 | /* Utility-first — composable, terse, design-system aligned */ |
| 8 | .flex { display: flex; } |
| 9 | .items-center { align-items: center; } |
| 10 | .gap-4 { gap: 1rem; } |
| 11 | .text-blue-600 { color: #2563EB; } |
| 12 | |
| 13 | /* Hybrid — component shell + utility interior */ |
| 14 | /* In HTML: <article class="card flex flex-col gap-2"> */ |
| 15 | .card { |
| 16 | --card-bg: var(--color-surface); |
| 17 | background-color: var(--card-bg); |
| 18 | border-radius: var(--radius-md); |
| 19 | } |
| Convention | Best for | Trade-off |
|---|---|---|
| BEM | Hand-authored component CSS | Verbose names, zero specificity wars |
| Utility-first | Rapid UI development, design systems | HTML noise, requires build tooling |
| SMACSS | Categorizing rules by function | Less prescriptive, more interpretation |
| ITCSS | Large codebases, strict specificity triangle | Disciplined layering, steeper learning curve |
Specificity determines which rule wins when selectors conflict. The cascade (origin, importance, specificity, source order) is the actual decision path. Most bugs happen when developers fight specificity instead of working with it.
| 1 | /* Specificity from low to high */ |
| 2 | /* 0-0-1 type selector */ |
| 3 | p { color: red; } |
| 4 | |
| 5 | /* 0-1-0 class selector */ |
| 6 | .text { color: blue; } |
| 7 | |
| 8 | /* 1-0-0 ID selector */ |
| 9 | #lead { color: green; } |
| 10 | |
| 11 | /* 0-2-0 two classes beat one */ |
| 12 | .card .text { color: purple; } |
| 13 | |
| 14 | /* Inline style beats ID */ |
| 15 | /* <p style="color: orange;"> ... */ |
| 16 | |
| 17 | /* !important beats everything */ |
| 18 | .text { color: pink !important; } |
Modern CSS gives you better tools than ever to manage the cascade. @layer lets you define explicit precedence groups regardless of source order. :where() removes specificity from complex selectors. Use both before reaching for !important.
| 1 | /* Cascade layers — reset before base before components */ |
| 2 | @layer reset, base, components, utilities; |
| 3 | |
| 4 | @layer reset { |
| 5 | *, *::before, *::after { |
| 6 | box-sizing: border-box; |
| 7 | } |
| 8 | } |
| 9 | |
| 10 | @layer base { |
| 11 | body { |
| 12 | font-family: system-ui, sans-serif; |
| 13 | line-height: 1.5; |
| 14 | } |
| 15 | } |
| 16 | |
| 17 | @layer components { |
| 18 | .button { |
| 19 | padding: .5rem 1rem; |
| 20 | border-radius: var(--radius-md); |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | @layer utilities { |
| 25 | .visually-hidden { |
| 26 | position: absolute; |
| 27 | width: 1px; |
| 28 | height: 1px; |
| 29 | overflow: hidden; |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | /* :where() removes specificity */ |
| 34 | :where(.card, .panel) :where(h2, h3) { |
| 35 | margin-block-start: 0; |
| 36 | } |
| 37 | /* Effective specificity of the selector above is 0-0-1 */ |
info
A clear file structure separates concerns, reduces merge conflicts, and makes dead code easier to spot. The exact shape depends on your stack — plain CSS, Sass, CSS Modules, Tailwind, or a monorepo design system — but the principles remain the same.
| 1 | src/ |
| 2 | styles/ |
| 3 | 1-settings/ # Tokens: colors, spacing, typography, breakpoints |
| 4 | _colors.css |
| 5 | _spacing.css |
| 6 | _tokens.css |
| 7 | 2-tools/ # Mixins, functions (Sass/PostCSS) |
| 8 | _mixins.css |
| 9 | 3-generic/ # Reset / normalize |
| 10 | _reset.css |
| 11 | 4-elements/ # Bare HTML element styles |
| 12 | _typography.css |
| 13 | 5-objects/ # OOCSS layout objects |
| 14 | _container.css |
| 15 | _grid.css |
| 16 | 6-components/ # Buttons, cards, modals, etc. |
| 17 | _button.css |
| 18 | _card.css |
| 19 | 7-utilities/ # Overrides and helpers |
| 20 | _visually-hidden.css |
| 21 | _spacing-utilities.css |
| 22 | main.css # @layer + @import all partials |
| 23 | components/ |
| 24 | Button/ |
| 25 | Button.tsx |
| 26 | Button.module.css |
The numbered folders above follow ITCSS (Inverted Triangle CSS). Lower layers are more generic and lower specificity; higher layers are more specific. This ordering prevents lower-level rules from accidentally overriding component styles.
| 1 | /* main.css — import in specificity order */ |
| 2 | @layer reset, base, objects, components, utilities; |
| 3 | |
| 4 | @import url("1-settings/_tokens.css") layer(base); |
| 5 | @import url("3-generic/_reset.css") layer(reset); |
| 6 | @import url("4-elements/_typography.css") layer(base); |
| 7 | @import url("5-objects/_container.css") layer(objects); |
| 8 | @import url("6-components/_button.css") layer(components); |
| 9 | @import url("6-components/_card.css") layer(components); |
| 10 | @import url("7-utilities/_spacing-utilities.css") layer(utilities); |
best practice
Build mobile-first: the base styles target small screens, and media queries add complexity as the viewport grows. This approach produces leaner CSS, clearer overrides, and better performance on constrained devices.
| 1 | /* Mobile-first grid */ |
| 2 | .gallery { |
| 3 | display: grid; |
| 4 | gap: 1rem; |
| 5 | grid-template-columns: 1fr; |
| 6 | } |
| 7 | |
| 8 | @media (min-width: 640px) { |
| 9 | .gallery { |
| 10 | grid-template-columns: repeat(2, 1fr); |
| 11 | } |
| 12 | } |
| 13 | |
| 14 | @media (min-width: 1024px) { |
| 15 | .gallery { |
| 16 | grid-template-columns: repeat(4, 1fr); |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | /* Fluid typography with clamp() */ |
| 21 | .hero__title { |
| 22 | font-size: clamp(1.75rem, 4vw + 1rem, 3.5rem); |
| 23 | } |
| 24 | |
| 25 | /* Container queries — responsive to component width, not viewport */ |
| 26 | .card { |
| 27 | container-type: inline-size; |
| 28 | } |
| 29 | |
| 30 | @container (min-width: 400px) { |
| 31 | .card { |
| 32 | display: grid; |
| 33 | grid-template-columns: 120px 1fr; |
| 34 | } |
| 35 | } |
info
CSS is evolving rapidly. Features that once required preprocessors or JavaScript are now native: custom properties, nesting, container queries, subgrid, :has(), color-mix, anchor positioning, and scroll-driven animations. Learn them, but ship them with appropriate fallbacks.
| 1 | /* CSS custom properties with fallback */ |
| 2 | .button { |
| 3 | background-color: var(--button-bg, #2563EB); |
| 4 | color: var(--button-text, #FFFFFF); |
| 5 | } |
| 6 | |
| 7 | /* Native nesting */ |
| 8 | .card { |
| 9 | padding: 1rem; |
| 10 | |
| 11 | &:hover { |
| 12 | box-shadow: 0 4px 6px rgb(0 0 0 / 10%); |
| 13 | } |
| 14 | |
| 15 | & .card__title { |
| 16 | font-size: var(--text-lg); |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | /* :has() — parent and relational selection */ |
| 21 | .card:has(.card__image) { |
| 22 | grid-template-columns: 120px 1fr; |
| 23 | } |
| 24 | |
| 25 | .form-group:has(input:invalid) .error { |
| 26 | display: block; |
| 27 | } |
| 28 | |
| 29 | /* color-mix for derived colors */ |
| 30 | .alert--info { |
| 31 | background-color: color-mix(in oklch, var(--color-info) 15%, transparent); |
| 32 | border-left: 4px solid var(--color-info); |
| 33 | } |
When using cutting-edge features, provide fallbacks for older browsers. Use @supports to detect support and layer graceful degradation.
| 1 | /* Progressive enhancement for anchor positioning */ |
| 2 | .tooltip { |
| 3 | position: absolute; |
| 4 | } |
| 5 | |
| 6 | @supports (position-area: bottom) { |
| 7 | .tooltip { |
| 8 | position: absolute; |
| 9 | position-anchor: --trigger; |
| 10 | position-area: bottom; |
| 11 | margin-top: .5rem; |
| 12 | } |
| 13 | } |
| 14 | |
| 15 | /* Fallback for container queries */ |
| 16 | @container not (min-width: 1px) { |
| 17 | /* Styles for browsers without CQ support */ |
| 18 | .card { |
| 19 | display: flex; |
| 20 | flex-direction: column; |
| 21 | } |
| 22 | } |
A theme is a layer of tokens mapped to semantic intent. Define primitive tokens (raw colors, spacing values) and semantic tokens (background, text, border) separately. This separation lets you swap themes without rewriting component CSS.
| 1 | /* Primitive tokens */ |
| 2 | :root { |
| 3 | --blue-500: #3B82F6; |
| 4 | --blue-600: #2563EB; |
| 5 | --gray-100: #F3F4F6; |
| 6 | --gray-900: #111827; |
| 7 | --space-1: .25rem; |
| 8 | --space-4: 1rem; |
| 9 | } |
| 10 | |
| 11 | /* Semantic tokens */ |
| 12 | :root { |
| 13 | --color-bg: #FFFFFF; |
| 14 | --color-surface: var(--gray-100); |
| 15 | --color-text: var(--gray-900); |
| 16 | --color-link: var(--blue-600); |
| 17 | --radius-md: .5rem; |
| 18 | } |
| 19 | |
| 20 | /* Dark theme */ |
| 21 | @media (prefers-color-scheme: dark) { |
| 22 | :root { |
| 23 | --color-bg: var(--gray-900); |
| 24 | --color-surface: #1F2937; |
| 25 | --color-text: var(--gray-100); |
| 26 | --color-link: var(--blue-500); |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | /* Manual theme class */ |
| 31 | [data-theme="dark"] { |
| 32 | --color-bg: var(--gray-900); |
| 33 | --color-surface: #1F2937; |
| 34 | --color-text: var(--gray-100); |
| 35 | --color-link: var(--blue-500); |
| 36 | } |
| 37 | |
| 38 | body { |
| 39 | background-color: var(--color-bg); |
| 40 | color: var(--color-text); |
| 41 | } |
best practice
CSS performance is usually not the bottleneck, but poor practices can compound: unused rules, expensive selectors, render-blocking sheets, and layout thrashing. Measure before optimizing, then fix the real problems.
| 1 | /* Avoid deeply nested selectors — expensive to match */ |
| 2 | /* Bad */ |
| 3 | .sidebar nav ul li a span.icon { } |
| 4 | |
| 5 | /* Better */ |
| 6 | .nav__icon { } |
| 7 | |
| 8 | /* Contain paint/layout for animated regions */ |
| 9 | .carousel { |
| 10 | contain: layout paint; |
| 11 | } |
| 12 | |
| 13 | /* Promote animated elements to their own layer */ |
| 14 | .animated-badge { |
| 15 | will-change: transform; |
| 16 | } |
| 17 | |
| 18 | /* Remove will-change after animation completes in JS */ |
| 19 | |
| 20 | /* Prefer transform and opacity for animations */ |
| 21 | .modal--open { |
| 22 | opacity: 1; |
| 23 | transform: translateY(0); |
| 24 | transition: opacity 200ms ease, transform 200ms ease; |
| 25 | } |
| 26 | |
| 27 | .modal { |
| 28 | opacity: 0; |
| 29 | transform: translateY(-1rem); |
| 30 | } |
For large applications, split CSS by route or component and load it lazily. Inline critical above-the-fold CSS so the first paint is not blocked by a network request. Tools like Lighthouse, Chrome DevTools Coverage, and PurgeCSS help identify waste.
warning
CSS is not typically an attack vector on its own, but it can leak information, enable clickjacking, or be injected through untrusted user content. Treat user-supplied styles as untrusted input.
| Risk | Mitigation |
|---|---|
| User CSS injection | Sanitize or sandbox user-provided styles; never render raw CSS |
| Attribute selectors leaking data | Avoid input[value^="secret"] patterns on sensitive fields |
| Clickjacking overlays | Use X-Frame-Options / CSP frame-ancestors; review z-index stacking |
| Third-party widget styles | Scope vendor CSS inside a class or Shadow DOM |
| 1 | /* Scope untrusted content styles */ |
| 2 | .user-content { |
| 3 | /* whitelist safe properties */ |
| 4 | line-height: 1.6; |
| 5 | color: var(--color-text); |
| 6 | } |
| 7 | |
| 8 | .user-content img { |
| 9 | max-width: 100%; |
| 10 | height: auto; |
| 11 | } |
| 12 | |
| 13 | /* Avoid attribute selectors on sensitive inputs */ |
| 14 | /* Bad: can leak typed values to a background request */ |
| 15 | input[name="password"][value^="a"] { |
| 16 | background-image: url("/leak?char=a"); |
| 17 | } |
| 18 | |
| 19 | /* Instead, rely on the browser's built-in password masking */ |
CSS testing ranges from automated linting to visual regression. At minimum, enforce style consistency with a linter and catch broken builds with a type checker when using CSS Modules or typed design tokens.
| 1 | { |
| 2 | "scripts": { |
| 3 | "lint:css": "stylelint "src/**/*.css"", |
| 4 | "format:css": "prettier --write "src/**/*.css"", |
| 5 | "test:visual": "playwright test visual" |
| 6 | }, |
| 7 | "devDependencies": { |
| 8 | "stylelint": "^16.0.0", |
| 9 | "stylelint-config-standard": "^36.0.0", |
| 10 | "prettier": "^3.0.0" |
| 11 | } |
| 12 | } |
Visual regression testing is the most reliable way to catch unintended CSS changes. Tools like Playwright, Chromatic, or Percy capture screenshots of components and flag pixel differences. Run them in CI before merging layout or styling pull requests.
info
CSS is a powerful accessibility tool. It controls focus indication, color contrast, motion preferences, reading order, and target sizes. Accessibility should not be an afterthought — build it into the design system from the first component.
| 1 | /* Visible, high-contrast focus styles */ |
| 2 | :focus-visible { |
| 3 | outline: 3px solid var(--color-focus); |
| 4 | outline-offset: 2px; |
| 5 | } |
| 6 | |
| 7 | /* Respect reduced motion preferences */ |
| 8 | @media (prefers-reduced-motion: reduce) { |
| 9 | *, *::before, *::after { |
| 10 | animation-duration: .01ms !important; |
| 11 | animation-iteration-count: 1 !important; |
| 12 | transition-duration: .01ms !important; |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | /* Minimum touch target size */ |
| 17 | .button { |
| 18 | min-height: 44px; |
| 19 | min-width: 44px; |
| 20 | } |
| 21 | |
| 22 | /* Do not remove focus outlines without replacement */ |
| 23 | /* Bad */ |
| 24 | button:focus { outline: none; } |
| 25 | |
| 26 | /* Better */ |
| 27 | button:focus-visible { |
| 28 | outline: 2px dashed var(--color-focus); |
| 29 | } |
best practice
Modern CSS workflows rely on a toolchain for preprocessing, bundling, linting, formatting, and optimization. Choose tools that match your stack, lock their versions, and configure them in code so the entire team shares the same rules.
| 1 | // stylelint.config.js |
| 2 | export default { |
| 3 | extends: ["stylelint-config-standard"], |
| 4 | rules: { |
| 5 | "selector-class-pattern": [ |
| 6 | "^([a-z][a-z0-9]*)(_[a-z0-9]+)*$|^([a-z][a-z0-9]*)(__[a-z0-9]+)*(--[a-z0-9]+)?$", |
| 7 | { |
| 8 | message: "Expected class name to follow BEM or kebab-case", |
| 9 | }, |
| 10 | ], |
| 11 | "declaration-property-value-no-unknown": true, |
| 12 | "color-no-invalid-hex": true, |
| 13 | "unit-disallowed-list": [["px"], { ignoreProperties: { px: ["border-width"] } }], |
| 14 | }, |
| 15 | }; |
PostCSS is the standard CSS transformation pipeline. Plugins can add future-syntax support, autoprefix, purge unused styles, inline imports, and optimize the final bundle. Tailwind CSS is implemented as a PostCSS plugin.
| 1 | // postcss.config.js |
| 2 | export default { |
| 3 | plugins: { |
| 4 | "postcss-import": {}, |
| 5 | "tailwindcss": {}, |
| 6 | "autoprefixer": {}, |
| 7 | "cssnano": process.env.NODE_ENV === "production" ? {} : false, |
| 8 | }, |
| 9 | }; |
info
Use this checklist for every stylesheet or styling pull request. Automate the checks that can be automated, and reserve human review for architecture, semantics, and accessibility.
| Check | Automated? | Details |
|---|---|---|
| Formatting | Prettier | Consistent indentation, quotes, semicolons |
| Linting | Stylelint | No invalid values, naming convention followed |
| Specificity | Stylelint / review | No IDs or !important in component CSS |
| Units | Stylelint | rem/em for typography, tokens for spacing |
| Contrast | axe / Lighthouse | WCAG AA minimum for normal text |
| Motion | Review | prefers-reduced-motion respected |
| Focus | Review | Visible focus indicator on interactive elements |
| Responsive | Review | Mobile-first media queries, fluid sizing |
| Dead code | PurgeCSS / review | No unused selectors or commented-out rules |
| Bundle size | CI / Lighthouse | No unexpected growth, CSS inlined if critical |
| 1 | # Run the full CSS CI pipeline locally |
| 2 | npm run lint:css |
| 3 | npm run format:css -- --check |
| 4 | npx tsc --noEmit |
| 5 | npm run test:visual |