css-architecture
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
More from thinkoodle/rails-skills
minitest
Expert guidance for writing fast, maintainable Minitest tests in Rails applications. Use when writing tests, converting from RSpec, debugging test failures, improving test performance, or following testing best practices. Covers model tests, policy tests, request tests, system tests, fixtures, and TDD workflows.
32caching
Expert guidance for Rails caching — fragment caching, Russian doll caching, cache keys/versioning, low-level caching (Rails.cache), conditional GET (stale?/fresh_when), and cache stores (Solid Cache, Redis, Memcached). Use when implementing cache, caching, fragment cache, Russian doll, Rails.cache, Solid Cache, cache key, HTTP caching, stale?, fresh_when, cache store, or optimizing performance.
4uuid-primary-keys
Expert guidance for implementing UUID primary keys in Rails applications. Use when setting up UUIDs as primary keys, choosing between UUIDv4 and UUIDv7, configuring generators for UUID defaults, writing migrations with id colon uuid, adding UUID foreign keys, implementing base36 encoding for URL-friendly IDs, configuring PostgreSQL pgcrypto or gen_random_uuid, implementing SQLite binary UUID storage, choosing a primary key type, using non-sequential IDs, secure IDs, random IDs, or any ID generation strategy beyond auto-increment integers.
4security
Expert guidance for writing secure Rails applications. Use when dealing with security, CSRF protection, XSS prevention, SQL injection, authentication, authorization, sanitize, html_safe, credentials, secrets, content security policy, session security, mass assignment, strong parameters, secure headers, file uploads, open redirects, or vulnerability remediation. Covers every major attack vector and the Rails-idiomatic defenses.
4stimulus
Expert guidance for building Stimulus controllers in Rails applications. Use when creating JavaScript behaviors, writing data-controller/data-action/data-target attributes, building interactive UI components, or working with Hotwire Stimulus. Covers controller creation, targets, values, actions, classes, outlets, lifecycle callbacks, progressive enhancement, and common patterns like clipboard, flash, modal, toggle, and form validation.
4testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4