Rails CSS Architecture Expert
Write maintainable, scalable CSS for Rails applications using modern CSS features — custom properties, @layer, light-dark(), and semantic design tokens. No framework dependency.
Philosophy
Core Principles:
- Components own their styles — One CSS file per component, never style components from page stylesheets
- Design tokens over magic numbers — All values come from
_global.csscustom properties - Layers control the cascade — Use
@layerinstead of specificity hacks or!important - Dark mode is free —
color-scheme+light-dark()gives you dark mode with zero extra work - Utilities are seasoning, not the meal — Use sparingly for one-off adjustments, not as primary styling
Architecture Pyramid:
/\ Utilities (few — spacing/text tweaks)
/ \
/____\ Components (many — reusable UI pieces)
/ \
/________\ Base (element defaults, resets)
/ \
/____________\ Tokens (_global.css — design system foundation)
When To Use This Skill
- Creating or reorganizing CSS file structure in a Rails app
- Writing new component stylesheets
- Implementing dark mode support
- Defining or extending design tokens
- Debugging cascade/specificity issues
- Converting from Tailwind or other frameworks to vanilla CSS
- Setting up CSS layers
- Creating utility classes
- Integrating third-party CSS libraries
Instructions
Step 1: Understand the File Structure
Every Rails app using this architecture has this structure:
app/assets/stylesheets/
├── _global.css # Design tokens: colors, spacing, typography, radii
├── application.css # Entry point — may just import everything
├── reset.css # CSS reset / normalization
├── base.css # Element defaults (no classes)
├── utilities.css # Single-purpose utility classes
└── components/
├── app-layout.css # Main layout shell
├── forms.css # Form elements + buttons
├── cards.css # Card component
├── badges.css # Badge variants
├── alerts.css # Alerts/flash messages
├── tables.css # Table styles
└── [feature].css # Feature-specific components
ALWAYS check the existing structure first:
# See what exists
find app/assets/stylesheets -name "*.css" | sort
# Check for existing tokens
cat app/assets/stylesheets/_global.css
# Check layer declarations
rg "@layer" app/assets/stylesheets/
# Check for existing components
ls app/assets/stylesheets/components/
Match existing project conventions — consistency beats "ideal" patterns.
Step 2: Set Up CSS Layers
Declare layers once at the top of _global.css (or application.css):
/* _global.css — FIRST LINE */
@layer reset, base, components, utilities;
Layer priority (lowest → highest):
- reset — Browser normalization
- base — Element defaults (
h1,a,input) - components — UI components (
.card,.btn,.badge) - utilities — Override helpers (
.mt-4,.hidden) — always wins
Every CSS rule goes inside its layer:
/* base.css */
@layer base {
body { font-family: var(--font-sans); color: var(--color-ink); }
a { color: var(--color-link); }
}
/* components/cards.css */
@layer components {
.card { background: var(--color-surface); border: 1px solid var(--color-border); }
}
/* utilities.css */
@layer utilities {
.mt-4 { margin-top: var(--space-4); }
}
Why layers matter: Without @layer, you fight specificity with nesting, !important, or load order hacks. Layers eliminate all of that. A utility in the utilities layer beats any component rule regardless of specificity.
Step 3: Define Design Tokens
All design values live in _global.css as custom properties on :root:
:root {
/* Enable dark mode detection */
color-scheme: light dark;
/* === Raw Palette (OKLCH for perceptual uniformity) === */
--color-zinc-50: oklch(0.985 0 0);
--color-zinc-100: oklch(0.967 0.001 286.375);
--color-zinc-200: oklch(0.92 0.004 286.32);
--color-zinc-400: oklch(0.707 0.022 261);
--color-zinc-500: oklch(0.552 0.016 285.938);
--color-zinc-700: oklch(0.37 0.013 285.805);
--color-zinc-800: oklch(0.274 0.006 286.033);
--color-zinc-900: oklch(0.21 0.006 285.885);
--color-zinc-950: oklch(0.141 0.005 285.823);
/* === Semantic Color Tokens === */
/* Surfaces */
--color-canvas: light-dark(var(--color-zinc-50), var(--color-zinc-900));
--color-surface: light-dark(white, var(--color-zinc-800));
--color-surface-raised: light-dark(white, var(--color-zinc-700));
--color-surface-muted: light-dark(var(--color-zinc-100), var(--color-zinc-800));
/* Text */
--color-ink: light-dark(var(--color-zinc-900), var(--color-zinc-50));
--color-ink-muted: light-dark(var(--color-zinc-500), var(--color-zinc-400));
--color-ink-subtle: light-dark(var(--color-zinc-400), var(--color-zinc-500));
/* Borders */
--color-border: light-dark(var(--color-zinc-200), var(--color-zinc-700));
--color-border-muted: light-dark(var(--color-zinc-100), var(--color-zinc-800));
--color-border-strong: light-dark(var(--color-zinc-400), var(--color-zinc-600));
/* Interactive */
--color-primary: light-dark(var(--color-blue-600), var(--color-blue-500));
--color-primary-hover: light-dark(var(--color-blue-700), var(--color-blue-400));
--color-link: light-dark(var(--color-blue-600), var(--color-blue-400));
/* State */
--color-positive: light-dark(var(--color-green-600), var(--color-green-500));
--color-positive-canvas: light-dark(var(--color-green-50), var(--color-green-950));
--color-negative: light-dark(var(--color-red-600), var(--color-red-500));
--color-negative-canvas: light-dark(var(--color-red-50), var(--color-red-950));
/* === Spacing Scale === */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
/* === Typography === */
--font-sans: system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, "SF Mono", monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* === Radii === */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-full: 9999px;
/* === Transitions === */
--duration-fast: 150ms;
--duration-normal: 250ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
Rule: Components NEVER use raw values. Always reference tokens.
/* GOOD */
.card { padding: var(--space-6); background: var(--color-surface); }
/* BAD — hardcoded values */
.card { padding: 1.5rem; background: white; }
Step 4: Write Component Styles
Each component gets its own file in components/. Components use tokens only.
Component structure:
/* components/cards.css */
@layer components {
/* Base */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
}
/* Structural subcomponents */
.card-header { padding-bottom: var(--space-4); border-bottom: 1px solid var(--color-border-muted); }
.card-body { padding-top: var(--space-4); }
.card-footer { padding-top: var(--space-4); border-top: 1px solid var(--color-border-muted); }
/* Variants */
.card-sm { padding: var(--space-4); }
.card-lg { padding: var(--space-8); }
/* States */
.card-interactive { transition: border-color var(--duration-fast) var(--ease-out); }
.card-interactive:hover { border-color: var(--color-border-strong); }
}
Naming conventions:
- Base:
.card,.btn,.badge - Subcomponents:
.card-header,.card-body - Variants:
.card-sm,.btn-primary - States:
.is-loading,.is-active
When to create a new component file:
- Pattern used 3+ times
- Has related sub-classes (
.foo,.foo-header,.foo-item) - Has variants (
.foo-primary,.foo-small)
Keep it in a page stylesheet when:
- Truly page-specific (used once)
- Simple layout (grid, flex container)
Step 5: Dark Mode with light-dark()
Dark mode is automatic when you follow the token system. Here's how:
- Set
color-scheme: light darkon:root— browsers respect OS preference - Define semantic tokens with
light-dark()— first value = light, second = dark - Components reference semantic tokens — they adapt automatically
:root {
color-scheme: light dark;
--color-surface: light-dark(white, var(--color-zinc-800));
--color-ink: light-dark(var(--color-zinc-900), var(--color-zinc-50));
}
/* This component supports dark mode with ZERO extra work */
.card {
background: var(--color-surface);
color: var(--color-ink);
}
Optional manual toggle:
html[data-theme="light"] { color-scheme: light; }
html[data-theme="dark"] { color-scheme: dark; }
function toggleTheme() {
const current = document.documentElement.dataset.theme;
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
}
Flash prevention — put this in <head>:
<script>
const theme = localStorage.getItem('theme');
if (theme) document.documentElement.dataset.theme = theme;
</script>
Dark mode rules:
- Shadows don't work in dark mode — prefer borders
- Use
color-mix()for subtle tinted backgrounds - State colors need both a foreground and canvas variant
- Never use raw white/black — always use semantic tokens
Step 6: Write Utility Classes
Utilities go in utilities.css inside @layer utilities. They are single-purpose and immutable:
@layer utilities {
/* Spacing */
.mt-2 { margin-top: var(--space-2); }
.mt-4 { margin-top: var(--space-4); }
.mt-6 { margin-top: var(--space-6); }
.mb-4 { margin-bottom: var(--space-4); }
.p-4 { padding: var(--space-4); }
.gap-4 { gap: var(--space-4); }
/* Text */
.text-sm { font-size: var(--text-sm); }
.text-muted { color: var(--color-ink-muted); }
.font-medium { font-weight: var(--font-medium); }
/* Layout */
.flex { display: flex; }
.grid { display: grid; }
.hidden { display: none; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
}
Use utilities for one-off adjustments:
<%# Good — utility for spacing adjustment %>
<div class="card mt-6">
<%# Bad — utilities replacing component styles %>
<div class="p-6 bg-white border rounded-lg shadow">
Step 7: Page Stylesheets Are Thin
Page-specific styles should be minimal — just layout:
/* pages/dashboard.css */
@layer components {
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-6);
}
}
Pages compose existing pieces:
<div class="dashboard-grid">
<div class="card">...</div>
<div class="card">...</div>
<div class="card mt-4">...</div> <%# Component + utility %>
</div>
Never style components from page stylesheets:
/* BAD — page overriding component */
.dashboard .card { padding: 2rem; }
/* GOOD — create a variant in the component file */
.card-lg { padding: var(--space-8); }
Step 8: Integrate Third-Party CSS
When integrating external libraries, map their variables to your tokens:
:root {
--lexxy-color-ink: var(--color-ink);
--lexxy-color-canvas: var(--color-surface);
--lexxy-color-selected: light-dark(oklch(0.92 0.026 254), oklch(0.3 0.05 254));
}
Load order matters — library CSS BEFORE your overrides:
<%= stylesheet_link_tag "lexxy" %>
<%= stylesheet_link_tag :app %>
Anti-Patterns
| Don't | Do Instead |
|-------|-----------|
| Hardcode colors (white, #333) | Use semantic tokens (var(--color-surface)) |
| !important | Use @layer for cascade control |
| Deep nesting (.page .section .card .title) | Flat, specific classes (.card-title) |
| Generic names (.container, .title) | Prefixed names (.page-container, .card-title) |
| Style components from page CSS | Create component variants |
| Inline styles in ERB | Use utility classes or component classes |
| box-shadow for dark mode elevation | Use borders |
| Magic numbers (padding: 23px) | Use spacing tokens (var(--space-6)) |
| Media queries for dark mode | Use light-dark() with color-scheme |
| Framework-specific classes in views | Use your own semantic classes |
Quick Reference
Naming Conventions
.component → .card, .btn, .badge
.component-sub → .card-header, .card-body
.component-variant → .card-sm, .btn-primary
.component-state → .card-interactive (or .is-loading for global states)
.page-element → .dashboard-grid, .settings-sidebar
Responsive Patterns
/* Mobile-first with container queries where possible */
.dashboard-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
}
@media (min-width: 768px) {
.dashboard-grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-6);
}
}
Browser Support
light-dark() requires Chrome 123+, Firefox 120+, Safari 17.5+.
Fallback for older browsers:
@media (prefers-color-scheme: dark) {
:root {
--color-surface: var(--color-zinc-800);
--color-ink: var(--color-zinc-50);
}
}
New Component Checklist
- [ ] Created
components/[name].css - [ ] Wrapped all rules in
@layer components { } - [ ] All values reference design tokens (no magic numbers)
- [ ] Uses semantic color tokens (not raw palette)
- [ ] Class names are specific and won't collide
- [ ] Subcomponents follow
.component-subnaming - [ ] Dark mode works automatically (verified)
- [ ] Imported in
application.cssif needed
Design Token Checklist
- [ ] New tokens added to
_global.cssunder:root - [ ] Color tokens use
light-dark()for both modes - [ ] Raw palette values use OKLCH
- [ ] Spacing follows the existing scale
- [ ] Token names are semantic (
--color-surface, not--color-white)
For detailed patterns and examples, see the references/ directory:
references/design-tokens.md— Full color palette (OKLCH), spacing, typography, naming conventionsreferences/components.md— Button, card, badge, alert, form, table patterns + Rails integrationreferences/dark-mode.md—light-dark()deep dive, manual toggle, shadows,color-mix()references/responsive.md— Mobile-first breakpoints, container queries, fluid typography, layout patternsreferences/layers.md—@layercascade control, anti-patterns, third-party CSSreferences/utilities.md— Complete recommended utility class set