CSS Best Practices

Simple, maintainable, low-specificity CSS for a Rails app with ERB, Turbo/Stimulus, and Propshaft.


Principles

  • Visual register: subtle, intellectual, academic. The reference is the bridge contrastive components (bridge.css, annotated-reading.css, live at /admin/design_system/bridge): hairline borders and muted ink over filled boxes, typography and spacing doing the structural work, the accent color used sparingly and meaningfully. See “Confirmed visual register” in docs/features/immersive_redesign_extension_design.md.
  • Prefer clarity over cleverness. Make it easy to find where the styling lives.
  • Low specificity by default. Most rules should be single-class selectors.
  • Components over pages. Style reusable UI pieces, not individual screens.
  • Design tokens first. Colors, spacing, and typography via CSS custom properties.
  • Progressive enhancement. Add polish without breaking baseline UX.
  • Minimal dependencies. Use modern CSS; avoid frameworks unless necessary.

File Layout

All CSS files live in a flat app/assets/stylesheets/ directory – no subdirectories.

app/assets/stylesheets/
  _reset.css
  base.css
  colors.css
  utilities.css
  buttons.css
  inputs.css
  spinners.css
  dialog.css
  [other-feature].css
  ...

Key points:

  • One file per concept (buttons, inputs, spinners, dialog, etc.)
  • No subdirectories – Propshaft’s :all safely loads everything at the top level
  • Specificity and ordering are managed with low-specificity selectors plus alphabetical file load order, not folder hierarchy or cascade layers

Loading with Propshaft

<%= stylesheet_link_tag :all, "data-turbo-track": "reload" %>

With Propshaft, :all includes all CSS files at the top-level of the stylesheets load path. It does not recurse into subfolders. The flat directory approach means:

  • No concatenation needed
  • No subfolders needed
  • Files load alphabetically (_global.css first via its underscore prefix) – for equal-specificity rules, the later file wins

Cascade Strategy

No @layer. The cascade stays understandable through low specificity and load order, the same approach 37signals’ open-source apps (writebook, campfire, fizzy) converged on:

  • reset.css wraps every selector in :where() (zero specificity), so any authored rule beats the reset.
  • Component rules are single-class selectors wherever possible; equal-specificity conflicts resolve by alphabetical file load order (later file wins).
  • Contextual element defaults (e.g. label inside .form) are written as .parent :where(element) so they sit at class-level specificity and any explicit class on the element can override them.
/* reset.css - zero specificity */
:where(img, picture) {
  max-width: 100%;
}

/* form.css - contextual default, still overridable by any class */
.form :where(label) {
  font-weight: 700;
}

/* verbs.css - loads after form.css, wins the tie */
.filter-checkbox-label {
  font-weight: 400;
}

Guidelines:

  • Don’t reach for higher specificity to win a conflict; prefer a single class and rely on load order, or weaken the competing rule with :where().

Class Naming (BEM-ish)

Class names resemble BEM when a component has meaningful internal parts, and use simpler names elsewhere.

  • Block: .collection-card
  • Element: .collection-card__title
  • Modifier: .collection-card--compact
  • State: .is-active, .is-disabled (or [aria-disabled="true"])

Rules:

  • Use BEM-like element names (component__part) when the component has multiple stable sub-parts that need styling without deep selector nesting.
  • Use modifier names (component--variant) for explicit variants combined with the base component.
  • Don’t force BEM for everything. For simple things, .button, .badge, .card is fine.
  • Avoid chaining elements of elements (.a__b__c). If you need that, you probably need a new block.

Selectors and Specificity

Prefer:

  • .component { ... }
  • .component__part { ... }
  • .component--variant { ... }
  • .is-open .component { ... } (sparingly)
  • Attribute selectors for semantics: [aria-current="page"], [data-state="open"]

Avoid:

  • IDs for styling
  • Deep nesting: .a .b .c .d
  • Type selectors as the main hook (button { ... }) unless it’s truly base styling
  • !important (except for utility classes that intentionally win)

Practical limits:

  • Most selectors should be 1 class.
  • Component internals: 2 classes max (e.g. .modal .modal__title is fine, but usually .modal__title alone suffices).

Design Tokens

Centralize design decisions as CSS custom properties on :root (see _global.css).

Colors

Use semantic tokens (what it means) rather than raw color names (what it is). Each semantic token wraps a raw --lch-* triplet, so a color’s light and dark values live in one place and the semantic name is defined exactly once:

:root {
  --lch-bg: 0.99 0.01 250;
  --lch-ink: 0.20 0.02 250;

  --color-bg: oklch(var(--lch-bg));
  --color-ink: oklch(var(--lch-ink));
}

Dark mode redefines only the primitives — never the semantic tokens:

@media (prefers-color-scheme: dark) {
  :root {
    --lch-bg: 0.15 0.02 250;
    --lch-ink: 0.93 0.005 250;
  }
}

Spacing and Sizing

The scale (defined in app/assets/stylesheets/_global.css) is axis-based, not numbered – inline spacing in ch (tracks the type size horizontally), block spacing in rem:

:root {
  --space-inline: 1ch;
  --space-inline-half: 0.5ch;
  --space-inline-double: 2ch;
  --space-block: 1rem;
  --space-block-half: 0.5rem;
  --space-block-double: 2rem;
}

Use gap for spacing between children whenever possible. A value the scale lacks is a conversation about the scale, not a literal (see design_review_process.md).

Typography

Keep defaults simple; let components opt-in to variations.


Component Authoring Patterns

Components should be themeable via variables

.button {
  --button-bg: var(--color-accent);
  --button-fg: var(--color-bg);

  background: var(--button-bg);
  color: var(--button-fg);
}
.button--secondary {
  --button-bg: transparent;
  --button-fg: var(--color-fg);
  border: 1px solid var(--color-border);
}

Prefer semantic state hooks

Prefer [aria-expanded="true"], [aria-current="page"], [data-state] over inventing many ad-hoc classes.

Keep component boundaries explicit

A component file should not style random descendants outside its responsibility. If you must style a slotted child, require a class:

.modal__actions > .button { /* ok */ }

Layout Helpers

Create a few predictable layout utilities:

  • .stack – vertical flow with consistent gap
  • .cluster – horizontal wrap + gap
  • .sidebar – sidebar/content split
  • .grid – reusable grid patterns

These are structural, not utility hacks.


Modern CSS Features

  • Low specificity + load order to manage cascade (no @layer)
  • Native nesting only for tight component scoping (avoid deep nesting)
  • Logical properties: margin-inline, padding-block, inset-inline
  • clamp() for fluid sizing
  • color-mix() for derived colors
  • :focus-visible for accessible focus styling
  • Container queries when a component needs to respond to its container, not the viewport

Breakpoints

The scale is five round pixel values. Do not invent new breakpoints; if a component seems to need one, it usually needs a different layout instead.

Name Round value
x-small 400px
small 640px
medium 992px
large 1200px
x-large 1440px

Mobile-first is the rule

Write base styles for the smallest screen, then add larger-screen overrides in @media (min-width: N) using the round value. min-width never needs a fractional offset, so it always takes the round number:

.thing {
  padding: 2rem 0;               /* mobile (base) */

  @media (min-width: 640px)  { padding: 2.5rem 0; }
  @media (min-width: 992px)  { padding: 3rem 0; }
  @media (min-width: 1200px) { padding: 4rem 0; }
}

Custom properties are not resolved inside @media conditions — the browser evaluates media queries before custom properties exist, and the @custom-media proposal never shipped without a build step. So the literals are the variable. The --breakpoint-* tokens in _global.css hold the fractional max-width exception values (e.g. --breakpoint-small: 639.98px, whose min-width sibling is 640px) — they back container.css’s max-width: var(--breakpoint-large) and document the exception form; they are not the round min-width scale.

/* Bad -- silently ignored by browsers */
@media (min-width: var(--breakpoint-medium)) { ... }

Documented exception: top-down max-width

A @media (max-width: N − 0.02) block — i.e. 399.98 / 639.98 / 991.98 / 1199.98 / 1439.98 — may stay only where inverting a block to mobile-first would be a large rewrite with real regression risk (top-level shared mobile blocks that retarget a grid, display toggles or single-property tweaks whose desktop value is implicit/inherited, and similar). The 0.02 offset is deliberate: it stops the max-width block overlapping its paired round min-width at an integer viewport width (a plain max-width: 640px would match a 640px viewport from both sides). Never use a plain round max-width; if you are not inverting, keep the − 0.02 form.

(The 320px/1680px pair in base.css is not a breakpoint — it is the min/max clamp of the fluid typography scale.)


Rails Integration

  • In ERB, keep class names stable and searchable.
  • For Turbo/Stimulus components, use data-* for behavior and classes for styling; don’t couple them unless intentional:
    • Behavior: data-controller="modal"
    • Styling: .modal, .modal__backdrop

Review Checklist

Before merging CSS changes:

  • New styles are in the right file, at the lowest specificity that works
  • Selectors are low-specificity (single class where possible)
  • No deep descendant selectors were introduced
  • Colors and spacing use tokens (no raw hex values unless defining tokens)
  • States are expressed via aria-* or data-state when appropriate
  • Works in light and dark mode (if applicable)
  • Keyboard focus is visible (:focus-visible)
  • Responsive behavior is intentional (no magic breakpoints without a reason)

This site uses Just the Docs, a documentation theme for Jekyll.