Rails + Hotwire CSS conventions adapted from pal-e-playground. Plain CSS, design tokens, no build step.
Find a file
Lucas Draney d4b5e460fc Rails + Hotwire CSS guide adapted from pal-e-playground philosophy
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>
2026-05-09 12:43:06 -06:00
README.md Rails + Hotwire CSS guide adapted from pal-e-playground philosophy 2026-05-09 12:43:06 -06:00

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

  1. Tokens, not values. Every color, font, and spacing value lives in :root as a custom property. No hardcoded hex anywhere else. Brand changes are one-line edits.
  2. Mobile-first, one breakpoint. Design for the phone. Add desktop at 600px. Two layouts, not five.
  3. ERB partials are the spec. The partial defines the component. CSS targets its root class. One partial, one CSS section.
  4. 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 .child or .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 notice and alert
  • Forms use the .form > .field pattern, 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 #id selectors 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).