Plain CSS conventions: design tokens, component patterns, Turbo/Stimulus integration, form styling, responsive approach. No build step. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|---|---|---|
| README.md | ||
Rails + Hotwire CSS Guide
Plain CSS conventions for Ruby on Rails apps using Hotwire (Turbo + Stimulus). Adapted from the pal-e-playground design system.
No Tailwind. No Sass. No PostCSS. No build step. Propshaft serves static CSS.
Principles
- Tokens, not values. Every color, font, and spacing value lives in
:rootas a custom property. No hardcoded hex anywhere else. Brand changes are one-line edits. - Mobile-first, one breakpoint. Design for the phone. Add desktop at
600px. Two layouts, not five. - ERB partials are the spec. The partial defines the component. CSS targets its root class. One partial, one CSS section.
- Constraints enable speed. One container. One font stack. One accent. Fewer decisions, faster iteration.
File Organization
Rails 8 with Propshaft serves everything in app/assets/ as static files. No compilation, no bundling.
app/assets/stylesheets/
application.css # tokens + reset + layout + typography
components/
nav.css # one file per component (optional split)
cards.css
forms.css
dashboard.css
For small apps (< 10 views), one application.css is fine. Split when a section exceeds ~150 lines.
Importing in the layout
<%# app/views/layouts/application.html.erb %>
<%= stylesheet_link_tag "application", data: { turbo_track: "reload" } %>
Propshaft auto-serves anything in app/assets/stylesheets/. No manifest needed for additional files — just add more stylesheet_link_tag calls or use CSS @import.
Design Tokens
/* ============================================
Design Tokens
Change a token, change everything.
Projects override tokens, not rules.
============================================ */
:root {
--color-bg: #fafafa;
--color-surface: #ffffff;
--color-text: #1a1a1a;
--color-muted: #57606a;
--color-link: #0366d6;
--color-link-hover: #024ea4;
--color-border: #d0d7de;
--color-accent: #0366d6;
--color-accent-light: #ddf4ff;
--color-danger: #d1242f;
--color-success: #1a7f37;
--font-body: 'Atkinson Hyperlegible', system-ui, -apple-system, sans-serif;
--font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
--max-width: 48rem;
--radius: 6px;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 3rem;
}
Reset
/* ============================================
Reset
============================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.7;
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
Component CSS Pattern
Each Rails partial or view maps to a CSS section. Comments mark the boundary and name the partial.
/* ============================================
Component: NavBar
Partial: layouts/_navbar.html.erb
Stimulus: none
============================================ */
.navbar { ... }
.navbar-brand { ... }
.navbar-links { ... }
/* ============================================
Component: ContactForm
Partial: contacts/_form.html.erb
Stimulus: form_controller.js (validates on submit)
============================================ */
.contact-form { ... }
.contact-form .field { ... }
.contact-form .field label { ... }
Naming convention
- Component root:
.component-name(kebab-case) - Children:
.component-name .childor.component-name-child - States:
.component-name.is-active,.component-name.is-loading - No BEM. No utility classes. Semantic names that match what the thing is.
Hotwire Integration
Turbo Frames
Turbo replaces content inside <turbo-frame> tags. CSS targets the content, not the frame.
<%# Don't style turbo-frame — style its contents %>
<turbo-frame id="dashboard">
<div class="dashboard">
...
</div>
</turbo-frame>
/* Target the component, not the frame */
.dashboard { ... }
Turbo Drive
Turbo Drive replaces the <body> on navigation. CSS loads once and persists across page transitions. Use data-turbo-track="reload" on your stylesheet link to bust cache on deploys.
Stimulus Controllers
When a Stimulus controller manages visibility or state, use .is-* classes toggled by the controller.
// app/javascript/controllers/dropdown_controller.js
toggle() {
this.menuTarget.classList.toggle("is-open")
}
.dropdown-menu { display: none; }
.dropdown-menu.is-open { display: block; }
No hidden attribute toggling. No inline styles from JS. CSS owns presentation, Stimulus owns behavior.
Flash Messages
Rails flash messages are styled by type. Match Rails conventions.
<%# app/views/layouts/_flash.html.erb %>
<% flash.each do |type, message| %>
<div class="flash flash-<%= type %>"><%= message %></div>
<% end %>
.flash {
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius);
margin-bottom: var(--spacing-md);
border: 1px solid;
}
.flash-notice {
background: var(--color-accent-light);
border-color: var(--color-accent);
color: var(--color-text);
}
.flash-alert {
background: #ffeef0;
border-color: var(--color-danger);
color: var(--color-danger);
}
Forms
Style Rails form helpers with semantic selectors. No class on every <input> — target by context.
/* ============================================
Forms — target by context, not by class
============================================ */
.form .field {
margin-bottom: var(--spacing-lg);
}
.form .field label {
display: block;
font-weight: 700;
font-size: 0.9rem;
margin-bottom: var(--spacing-xs);
color: var(--color-text);
}
.form .field input,
.form .field textarea,
.form .field select {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-family: inherit;
font-size: 1rem;
background: var(--color-surface);
color: var(--color-text);
}
.form .field input:focus,
.form .field textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.form .actions {
margin-top: var(--spacing-lg);
}
Buttons
One base, variants by modifier class.
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-sm) var(--spacing-lg);
font-family: inherit;
font-size: 0.95rem;
font-weight: 700;
border: 1px solid transparent;
border-radius: var(--radius);
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease;
text-decoration: none;
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover {
background: var(--color-link-hover);
}
.btn-secondary {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--color-border);
}
.btn-secondary:hover {
border-color: var(--color-accent);
}
Responsive
Mobile-first. One breakpoint at 600px.
/* ============================================
Responsive — mobile-first, one breakpoint
============================================ */
/* Base: phone layout (default) */
.card-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--spacing-md);
}
/* Desktop: 600px+ */
@media (min-width: 600px) {
.card-grid {
grid-template-columns: repeat(3, 1fr);
}
}
Rails Definition of Done
A view is styled when all of the following are true:
- All CSS in
app/assets/stylesheets/— zero inline styles, zero<style>blocks in ERB - All colors use
var(--token)— zero hardcoded hex outside:root - CSS comments mark component boundaries with partial paths and Stimulus controller names
- Mobile-first layout — looks right on a phone at 390px width
- Flash messages styled for
noticeandalert - Forms use the
.form > .fieldpattern, not classes on every input - Turbo Frame content is styled by component class, not by frame ID
- Stimulus toggles
.is-*classes, never inline styles
What Not to Do
- No Tailwind. Utility classes obscure intent and require a build step.
- No Sass/SCSS. CSS custom properties replace variables. Nesting is now native CSS.
- No CSS-in-JS. This is Rails, not React.
- No
!important. If you need it, your specificity is wrong. - No
#idselectors in CSS. IDs are for Turbo Frames and anchors, not styling. - No inline styles in ERB. Exception: dynamic values from the database (e.g., user-set brand color).