How Herbie is built
Under the hood
Herbie is a showcase for Herb and ReActionView on edge Rails. Here is every feature it uses, where to see it, and where to read it.
Herb template language
-
HTML+ERB parsing with compile errors
mismatched tags, unclosed ERB → Herb::Engine::ParseErrorEvery template; the dev error page shows file, line and fix · see it
app/views -
Slots (`herb:slots client` / `server`)
<%# herb:slots client %>Every reactive template; client mode parks unrendered branches in a `<template>` · see it
app/views/timeline/index.html.erb, app/views/properties/show.html.erb -
State (Boolean, Integer, String kinds)
<%# herb:state (open: false, dollars_in: 2026, tab: "story") %>Masthead compact/menu/theme, timeline filters, property tabs, garage, arena HUD · see it
app/views/layouts/_masthead.html.erb, app/views/properties/show.html.erb -
Row state in keyed collections
<%# herb:key event.id %> <%# herb:state (open: false, starred: false) %>Timeline "More" and ★ per moment, property card flips, scene checkboxes · see it
app/views/timeline/index.html.erb -
Derived state
deluxe: stripes && sunroof && whitewallsGarage "Deluxe package" badge; Honk board over_limit (draft.length > 280) · see it
app/views/garages/_workbench.html.erb -
Seeding state from strict locals
<%# herb:state (body: body_seed) %>Garage starts from your saved car; the SVG Herbie partial takes its paint from locals · see it
app/views/garages/_workbench.html.erb, app/views/shared/_herbie.html.erb -
Binding a partial's state (`render ..., state:`)
render "shared/herbie", state: { body: body, mood: mood }The garage's car is a separate partial bound to seven of the workbench's states · see it
app/views/garages/_workbench.html.erb -
Counting folds
<% if spotted %><% spotted_count += 1 %><% end %>Property "Scenes spotted" checklist and progress bar · see it
app/views/properties/show.html.erb -
Client-evaluable reads (comparisons, predicates, `length`, `blank?`, `positive?`)
<%= draft.length %> disabled="<%= draft.blank? %>" <% if pads.positive? %>Honk character counter, send button, controller-detected banner · see it
app/views/properties/show.html.erb, app/views/games/_arena.html.erb -
Boolean attributes read as presence
hidden="<%= tab != "cast" %>" checked="<%= spotted %>"Property tab panels, checklists, synopses toggle · see it
app/views/properties/show.html.erb -
Two-way bound form controls
<input value="<%= driver %>"> <input type=checkbox checked="<%= sunroof %>"> <textarea><%= motto %></textarea>Garage (text, number, color, checkbox, textarea), palette search, Honk composer, box office slider · see it
app/views/garages/_workbench.html.erb -
Server-answered state reads
<%= kind_label(kind) %> (Ruby the client can't run)Timeline, properties and box office calculator re-render through the same action · see it
app/views/properties/index.html.erb -
Actions (set / toggle / increment / decrement / reset)
data-herb-set="tab=cast" data-herb-toggle="open" data-herb-increment="racing_number" data-herb-reset="driver body"All the buttons; garage ±1 / "Make it 53" / "Back to saved" · see it
app/views/garages/_workbench.html.erb -
`$value` and event clauses
data-herb-set="sort=$value" data-herb-reset="submit->draft"Sort selects, mood select, Honk composer clears its draft on submit · see it
app/views/properties/index.html.erb -
Key filters and `@window` / `@outside` targets
keydown.meta+k@window->open=true click@outside->menu=false keydown.m@window->muted⌘K palette, mobile menu closes on outside click / Esc, M mutes the race · see it
app/views/layouts/_palette.html.erb, app/views/layouts/_masthead.html.erb -
Debounce
data-herb-debounce="120"Box office slider, garage name field · see it
app/views/properties/show.html.erb -
`$refresh` action
data-herb-action="$refresh"Honk board "Check for new honks", Hall of fame refresh · see it
app/views/properties/show.html.erb -
Keyed collections (`herb:key`) with in-place moves
<%# herb:key property.slug %>Properties re-sort without rebuilding cards; timeline filter adds/removes rows · see it
app/views/properties/index.html.erb -
Optimistic rows (`data-herb-into`, `data-herb-name`)
<form data-herb-into="honks"> <strong data-herb-name="author">Honk board guestbook: the row appears before the server answers · see it
app/views/properties/show.html.erb, app/controllers/honks_controller.rb -
`<Fragment>` with `<Fallback>`, `delay`, `hold`, `on`
<Fragment delay="80" hold="350" on="dollars_in">Box office time machine (server converts dollars as you drag) · see it
app/views/properties/show.html.erb -
`<Async>` (+ `poll`)
<Async delay="150" hold="400"> <Async poll="30000">Timeline "by the numbers", Herbie 500 hall of fame · see it
app/views/timeline/index.html.erb, app/views/games/show.html.erb -
`<Lazy>` (+ `poll`)
<Lazy poll="20000">"Fresh off the track" ticker at the end of the timeline · see it
app/views/timeline/index.html.erb -
Scoped styles (`<style scoped>`)
<style scoped> .card { … } </style>Every view and partial styles itself without class collisions · see it
app/views/properties/show.html.erb, config/initializers/reactionview.rb -
Strict locals (checked by Herb)
<%# locals: (property:, size: "md") %>Every partial; the linter checks render calls against them · see it
app/views/shared/_herbie.html.erb
ReActionView (Rails integration)
-
`intercept_erb` on Rails 8.2
config.intercept_erb = trueEvery .html.erb goes through ReActionView's Herb handler · see it
config/initializers/reactionview.rb -
`config.slots` + the `slots` format
config.slots = :server → application/vnd.herb.slots+jsonValues requests to the same URL with a Herb-State header · see it
config/initializers/reactionview.rb -
`herb_state` in controllers
herb_state("kind", "all")Timeline and properties filter/sort in the database · see it
app/controllers/timeline_controller.rb, app/controllers/properties_controller.rb -
`format.slots` responses
respond_to { |f| f.slots { render "properties/show" } }The Honk board's create action answers with the one new row · see it
app/controllers/honks_controller.rb -
Stimulus `useState` (`this.state`, `<name>Changed`)
useState(this); themeChanged(theme) { … }; this.state.set({ compact: true })Masthead shrinks on scroll, theme persists, palette syncs with the native popover, garage turntable, arena HUD · see it
app/javascript/controllers/masthead_controller.js, app/javascript/controllers/arena_controller.js -
Client behaviors (`connect`, `enter`, `updated`, `leave`)
Runtime.start().behaviors.define("data-honk", { enter, leave })Honks slide in/out, timeline rows animate when filtered, stars/scenes restored on connect · see it
app/javascript/controllers/honks_controller.js, app/javascript/controllers/persist_controller.js -
Outbox item states, `retry`, `discard`
<%# herb:state (pending: false, failed: false) %> outbox.retry(el)Honk board "Sending…" and the Flaky network switch with Retry / Discard · see it
app/views/properties/show.html.erb, app/javascript/controllers/honks_controller.js -
View transitions (`data-herb-transition`)
<li data-herb-transition="card-<%= slug %>">Property cards glide to their new places when sorted · see it
app/views/properties/index.html.erb -
`herb:state-change` events
document.addEventListener("herb:state-change", …)Timeline stars and scene checklists persist to localStorage · see it
app/javascript/controllers/timeline_controller.js -
Turbo integration
Turbo Drive, Turbo Streams, view-transition metaHonk toasts and the live leaderboard arrive as Turbo Streams into reactive pages · see it
app/models/honk.rb, app/models/lap_run.rb -
Debug mode + dev tools
config.debug_mode = Rails.env.development?The Herb badge (top right in development) outlines templates and opens files in your editor · see it
config/initializers/reactionview.rb -
Validation overlays
config.validation_mode = :overlay / :raise (tests)Nesting and security findings in the badge; tests fail on them · see it
config/initializers/reactionview.rb, test -
Runtime instrumentation (+ a custom measurement)
Herb::Engine::Runtime::Session.measurement(:redis, …)Per-tag SQL / render time / translations, plus "N Redis commands" · see it
config/initializers/herb_instrumentation.rb -
Embedded dev server (hot patching)
* Herb Dev Server: ws://localhost:… (embedded)Edit a template while the race runs; text changes patch in place · see it
config/initializers/reactionview.rb -
Custom compile visitors (`engine.visitors`)
config.engine.visitors.use(Herbie::PitCrewVisitor.new)Scoped styles (hoisted) and Herbie's own Pit Crew check reporting through Herb diagnostics · see it
lib/herbie/pit_crew_visitor.rb, config/initializers/reactionview.rb -
Parser options (`engine.parser_options`, `.herb.yml`)
strict_locals: trueStrict locals as real nodes, which lets state seed from them · see it
config/initializers/reactionview.rb, .herb.yml -
Templates from gems
config.external_template_mode = :fallbackRails' own error pages still render · see it
config/initializers/reactionview.rb
Herb tooling
-
Herb Ruby API at runtime
Herb.parse, Herb.lex, Herb.extract_ruby, Herb::Engine#srcHerb playground (AST, tokens, Ruby, compiled output of your snippet) · see it
app/controllers/hood_controller.rb, app/views/hood/playground.html.erb -
Slots compile output of real templates
ReActionView::Template::Handlers::ERB.callThe playground shows any Herbie template compiled with slot markers · see it
app/models/template_inspector.rb -
Linter (.herb.yml, CI)
npx @herb-tools/linterbin/herb-lint and the CI workflow · see it
.herb.yml, bin/herb-lint, .github/workflows/ci.yml -
Formatter
npx @herb-tools/formatter --checkbin/herb-format; configured in .herb.yml but switched off until it stops moving herb:disable comments · see it
.herb.yml, bin/herb-format -
Language server
npx @herb-tools/language-server --stdioDiagnostics, Pit Crew warnings and render costs inline in your editor · see it
.herb.yml -
`herb:check` (Rails edge)
bin/rails herb:checkCI compiles every template through Herb · see it
.github/workflows/ci.yml
Modern web platform
-
Popover API + anchor positioning
popover, popovertarget, position-anchor, position-area⌘K palette, theme menu, timeline peeks, cast bios · see it
app/views/layouts/_palette.html.erb -
Cascade layers, nesting, oklch, color-mix
@layer reset, tokens, base, layout, components, utilitiesThe whole design system; four themes including Drive-in · see it
app/assets/stylesheets -
Scroll-driven animations + view timelines
animation-timeline: scroll(root) / view() / --timelineTimeline progress rail, cards sliding in, Herbie swerving down the road, era minimap · see it
app/views/timeline/index.html.erb -
Grid, subgrid, container queries
grid-template-columns: subgrid; container-type: inline-size; 15cqiTimeline road layout, poster typography · see it
app/views/timeline/index.html.erb, app/assets/stylesheets/components.css -
@starting-style, allow-discrete, interpolate-size, field-sizing
transition: display 0.2s allow-discretePopover entry animations, auto-growing fields · see it
app/assets/stylesheets/components.css -
Cross-document view transitions
@view-transition { navigation: auto }Page-to-page navigation morphs the masthead · see it
app/assets/stylesheets/base.css -
Canvas, Gamepad API, Web Audio
navigator.getGamepads(), AudioContextThe Herbie 500 (Xbox pads, synthesized engines) · see it
app/javascript/game
This app's ReActionView configuration
- intercept_erb
- true
- slots
- :server
- debug_mode
- false
- validation_mode
- :overlay
- external_template_mode
- :fallback
- instrumentation.enabled
- false
- engine.visitors
- Herb::Engine::ScopedStyle::Visitor, Herbie::PitCrewVisitor
- engine.parser_options
- {strict_locals: true}
- action_view.erb_implementation
- :herb
Pit Crew demo
Herbie registers its own compile-time check, Herbie::PitCrewVisitor, through config.engine.visitors. It flags links that open a new tab without rel="noopener". This one is left unfixed on purpose: in development, open the Herb badge in the corner to see the warning, attributed to this file and line.