HERBIE The Love Bug, 1968 to forever

Paint job

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.

Open the Herb playground →

Herb template language

  • HTML+ERB parsing with compile errors

    mismatched tags, unclosed ERB → Herb::Engine::ParseError

    Every 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 && whitewalls

    Garage "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 = true

    Every .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+json

    Values 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 meta

    Honk 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: true

    Strict 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 = :fallback

    Rails' 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#src

    Herb 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.call

    The playground shows any Herbie template compiled with slot markers · see it

    app/models/template_inspector.rb
  • Linter (.herb.yml, CI)

    npx @herb-tools/linter

    bin/herb-lint and the CI workflow · see it

    .herb.yml, bin/herb-lint, .github/workflows/ci.yml
  • Formatter

    npx @herb-tools/formatter --check

    bin/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 --stdio

    Diagnostics, Pit Crew warnings and render costs inline in your editor · see it

    .herb.yml
  • `herb:check` (Rails edge)

    bin/rails herb:check

    CI 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, utilities

    The whole design system; four themes including Drive-in · see it

    app/assets/stylesheets
  • Scroll-driven animations + view timelines

    animation-timeline: scroll(root) / view() / --timeline

    Timeline 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; 15cqi

    Timeline 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-discrete

    Popover 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(), AudioContext

    The 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.

Herb::Engine docs (opens a new tab)

0 characters typed · ↑↓ to choose · ⏎ to go