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” indocs/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
:allsafely 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.cssfirst 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.csswraps 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.
labelinside.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,.cardis 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__titleis fine, but usually.modal__titlealone 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 consistentgap.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 sizingcolor-mix()for derived colors:focus-visiblefor 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
- Behavior:
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-*ordata-statewhen 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)