- Document the project's CSS architecture (the README, component library or
style guide are good places to do this), including things such as:
- Organization of stylesheet directories and CSS files
- Selector naming convention
- Code linting tools and configuration
- Browser support
- Use double colon syntax for pseudo-elements (
::after), single colon for pseudo-classes (:hover) - Vendor prefixes are rarely needed for modern browsers. If a project requires legacy browser support, consider automating prefixes via a build tool rather than maintaining them by hand
- Prefer native CSS over preprocessors like SCSS
For new projects, consider using Roux as a structured starting point. It provides an organized file structure, CSS custom properties for design tokens, and sensible base styles without locking you into a framework or visual opinion.
A reasonable default structure:
css/
├── resets/
│ └── _normalize.css # Browser resets
├── base/
│ ├── _variables.css # CSS custom properties (colors, spacing, fonts)
│ ├── _fonts.css # Font-face declarations
│ ├── _animations.css # Global animations
│ ├── _buttons.css # Button styles
│ ├── _forms.css # Form styling
│ ├── _layout.css # Layout styling
│ ├── _typography.css # Typography defaults
│ └── _[element].css # Other element-level styles
├── components/
│ └── _[component].css # Per-feature/component styles
├── utilities/
│ └── _[utility].css # Global helper classes
└── app.css # Entry point — imports and layer order
Defining @layer order explicitly in app.css makes cascade precedence
clear and intentional, regardless of import order:
@layer reset, base, components, utilities;
@import "reset/normalize.css" layer(reset);
@import "base/variables.css" layer(base);
@import "base/typography.css" layer(base);
/* … */
@import "components/card.css" layer(components);
@import "utilities/visually-hidden.css" layer(utilities);Styles outside any layer take precedence over all layered styles, which can be useful for one-off overrides, but use that intentionally.
Raw @import of CSS will render your CSS, but it's not recommended as a browser will load each @import as a separate network request.
Use your preferred build tool to bundle CSS into a single output. Options may include:
- Lightning CSS is fast, modern, and light
- cssbundling-rails and PostCSS for Rails projects (Roux has a setup guide)
- Dart Sass with
@useif your project already uses Sass
CSS custom properties are the recommended approach for design tokens. Defining
all tokens in a single _variables.css file keeps them easy to find and update. If your system
is complex, you can define tokens in various :root declarations across different files.
- A
--property--variantnaming convention works well: e.g.--color--primary,--space--large,--font-size--small - Mapping primitive values to semantic names makes tokens more meaningful at the point of use, and easier to theme:
:root {
/* Primitives */
--color--blue-100: #57929e;
--color--blue-900: #164650;
/* Semantic */
--color--text: var(--color--blue-900);
--color--background-base: var(--color--blue-100);
}- Dark mode overrides work naturally inside
:rootusing the same semantic names, keeping light and dark values co-located:
:root {
--color--background-base: #f1f1eb;
--color--text: #164650;
@media (prefers-color-scheme: dark) {
--color--background-base: #164650;
--color--text: #f1f1eb;
}
}Modern CSS layout tools can often eliminate the need for breakpoints entirely. Reaching for a media query is reasonable, but it's worth considering whether the layout could respond naturally first.
Flexbox and Grid can adapt to available space without any breakpoints:
/* Wraps into as many rows as needed, each item at least 20ch wide */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20ch, 1fr));
}
/* Items wrap naturally once they'd shrink below their base size */
.flex-list {
display: flex;
flex-wrap: wrap;
gap: var(--space--medium);
}This approach ties layout behavior to content size rather than viewport size, which tends to be more resilient.
Container queries let a component respond to the size of its parent rather than the viewport:
.card-wrapper {
container-type: inline-size;
}
.card {
/* default (narrow) styles */
}
@container (min-width: 40ch) {
.card {
display: grid;
grid-template-columns: auto 1fr;
}
}Media queries can still be the right tool for page-level layout changes: things like large navigation shifts that also should effect other items in the page.
- Start with the smallest viewport and layer upward with
min-width - Prefer
emunits overpxso breakpoints respect user font size preferences - Create breakpoints where the content starts to feel awkward, rather than targeting specific devices
Note: Unfortunately, you can't use a CSS custom property as a value within a media query, so be sure to keep track of your values here and perhaps scope it per partial.
Only ship fonts in woff2 format as it's supported by all modern browsers and
there's no need to include woff, ttf, or other formats as fallbacks.
Always set font-display: swap on @font-face declarations to prevent
invisible text while a web font is loading:
@font-face {
font-display: swap;
font-family: "Inter";
font-style: normal;
font-weight: 100 900;
src: url("inter.woff2") format("woff2");
}If the fallback font differs noticeably in size from the web font, use
font-size-adjust to normalize the x-height across both, which reduces
layout shift when the web font loads:
body {
font-family: "Inter", system-ui;
font-size-adjust: from-font;
}For performance-sensitive projects, a system font stack can be a reasonable alternative to web fonts altogether:
body {
font-family: system-ui, sans-serif;
}Use clamp() for more fluid typography. This is a way to scale type
smoothly between a minimum and maximum size based on viewport width,
without breakpoints.
- You can use fluid typography for larger headings or display type
- Most body and UI copy should normally default to a static size (not fluid)
:root {
--font-size--heading: clamp(1.5rem, 4vw, 3rem);
}
h2 {
font-size: var(--font-size--heading);
}The first value is the floor, the second is the preferred fluid value, and the third is the ceiling.
If your project uses a variable font that varies weight, you can define a weight range in
@font-face and let the font render any weight within that range:
@font-face {
font-family: "Inter";
font-weight: 100 900;
src: url("inter-variable.woff2") format("woff2");
}Enable optical sizing if the font supports an opsz axis. Browsers will
adjust letterform details automatically based on the rendered size:
body {
font-optical-sizing: auto;
}For other variable axes (width, slant, etc.), use font-variation-settings
when font-weight or font-style alone aren't enough:
.heading {
font-variation-settings: "wdth" 110;
}-webkit-font-smoothing: antialiased renders fonts thinner on macOS and iOS by
using greyscale antialiasing instead of the default subpixel rendering. It can be a stylistic
choice and some designers prefer the lighter appearance, while others
find it reduces legibility at small sizes. If your project uses it, apply it
consistently:
body {
-webkit-font-smoothing: antialiased;
}If your typeface has them, enable common ligatures for more natural text rendering:
body {
font-variant-ligatures: common-ligatures;
}- Body text should be at least
1rem(or16pxequivalent) at its smallest. - Avoid setting
font-sizeinpxon thehtmlorbodyelement as it overrides the user's browser font size preference, which is a common accessibility accommodation. - When using
clamp()for fluid type, the first argument acts as the floor.- Make sure it's never smaller than
1remfor reading-level text:
- Make sure it's never smaller than
/* Good — floor is 1rem */
--font-size--body: clamp(1rem, 2.5vw, 1.25rem);
/* Avoid — floor is below accessible threshold */
--font-size--body: clamp(0.75rem, 2.5vw, 1.25rem);Supporting text like captions or labels can go smaller, but staying at or above
0.75rem (12px equivalent) is a reasonable lower bound for anything a user is
expected to read.
Use text-wrap to improve how text breaks across lines without manual
intervention.
- Prefer
prettyas a general default for headings because it prevents orphaned words at the end of a block and works well at any length.- It is still however, not fully supported as of writing this guide.
balanceis a fine fallback for browsers that don't supportpretty.- The spec gives browsers latitude in how they implement these algorithms, so Chrome and Safari may produce slightly different line breaks for the same text.
h1, h2, h3, h4 {
text-wrap: balance;
text-wrap: pretty;
}Use a unitless value for line-height as it scales correctly in nested
elements where em or % values can compound unexpectedly:
body {
line-height: 1.5;
}
h1 {
line-height: 1.2;
}The lh unit is useful for spacing that should feel proportional to the
current line height. For example, paragraph margins that stay in rhythm with
the text:
p {
margin-block-end: 1lh;
}Stylelint is a good option for enforcing CSS conventions. If adopting a shared config, it's worth reviewing its rules to make sure they reflect native CSS rather than Sass-specific conventions.
- Avoid shorthand properties when setting a single value:
background-color: #ff0000rather thanbackground: #ff0000- Shorthand itself can obscure your intentions, so err on the side of longhand for most properties.
- Use
/* */for comment blocks - Keeping files under 100 lines makes them easier to scan; split larger files into focused partials
Alphabetical ordering is a reasonable default because it's predictable and easy to lint. The stylelint-order plugin can handle alphabetical declaration ordering.
Treat vendor-prefixed properties as if the prefix isn't there when alphabetizing:
.class {
font-family: system-ui;
-webkit-font-smoothing: antialiased;
font-weight: var(--font-weight--normal);
}- Avoid ID selectors
- Avoid over-qualifying selectors (e.g.
h1.page-title) unless the element type is semantically meaningful to the rule :where()is useful for resets and defaults because it carries zero specificity, making overrides straightforward:
:where(img, video, picture) {
display: block;
max-width: 100%;
}For more on how specificity is calculated, see the MDN specificity docs.
- Use lowercase and hyphens (kebab-case) for class names and custom properties:
block-name,block-name__element,block-name--modifierinstead ofblockNameorblock_name. - BEM is a reasonable default for a naming system within your CSS structure
- Regardless, be consistent with whatever naming convention the project is already using. Introducing a new system (like BEM) mid-project tends to create inconsistency rather than clarity
- Avoid concatenating selector names with
&(e.g.&__child) as it makes class names harder to search for in the codebase- An exception is using
&with pseudo-classes (e.g.&:hover) and pseudo-elements (e.g&::after).
- An exception is using
- A
.u-prefix for utility classes helps distinguish them from component selectors: e.g..u-visually-hidden
Native CSS has made significant advances. Before reaching for a JavaScript solution or a complex workaround, it's worth checking whether a native CSS approach exists.
Native CSS nesting is well-supported in modern browsers. It's useful for keeping component styles self-contained, though deep nesting can make specificity and readability harder to manage.
- Keep nesting to a maximum of 3 levels deep when possible.
- Favor nesting for pseudo-classes, pseudo-elements, media queries, and feature queries.
- Err on the side of unnesting elements if they can be specified in other ways (e.g. naming).
.card {
padding: var(--space--medium);
&:hover {
background-color: var(--color--surface-raised);
}
@media screen and (min-width: 30em) {
padding: var(--space--small);
}
}
.card .another-component {
font-size: var(--font-size--large);
}:has() enables styling a parent based on its children, which is particularly
useful for form layouts and state-driven styling:
label:has(input, select, textarea) {
display: flex;
flex-direction: column;
gap: var(--space--x-small);
}@scope limits where styles apply, which can help with component isolation
without relying on naming conventions alone:
@scope (.card) {
.title {
font-size: var(--font-size--large);
}
}Wrap animations and transitions in a motion preference check so users who
prefer reduced motion aren't affected. Err on the side of the opt-in approach
where your default is designed for no/little motion and you enhance it with motion
within a prefers-reduced-motion: no-preference query:
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}Visually hiding content while keeping it accessible to screen readers is a common need. A utility class works well for this:
.u-visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}You can enhance this if you want the element to be visible on focus (like a skip nav link):
.u-visually-hidden:not(:focus) {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}