Tailmix
Tailmix is a declarative, state-driven engine for managing HTML attributes (CSS classes, data, ARIA) in Ruby UI components. Write your component logic once in Ruby — it runs on the server for SSR and hydrates in the browser for interactive behaviour.
Why Tailmix?
Building interactive Ruby UI components (Arbre, ViewComponent, Phlex) usually means one of two things: a tangle of conditionals in the template, or a separate Stimulus controller per component. Tailmix offers a third path — a single declarative definition that handles both.
tailmix do
state :open, default: false
element :panel do
style condition: state.open do
classes "block"
aria expanded: true
otherwise do
classes "hidden"
aria expanded: false
end
end
end
element :toggle_btn do
on :click do
toggle state.open
end
end
endThat's it. No Stimulus controller. No JS file. The same definition drives server-rendered HTML and browser interactivity.
Features
- Declarative DSL — co-locate all presentational logic with your component class
- State & Variants — mutable runtime state (JS-driven) + static compile-time variants (SSR-only)
-
style/otherwise— conditional attribute blocks with an explicit else branch -
match/on— pattern-match a state value to a set of attribute effects -
on :event— event handlers compiled to an instruction set (no hand-written JS) -
toggle/set/dispatch— built-in instructions;dispatchfires CustomEvents for cross-component communication -
boot {}— runs instructions once on JS component init (e.g. fetch initial data) -
watch state.x {}— reactive side-effects triggered when state changes - Persistence — opt-in LocalStorage / SessionStorage per state key
- Isomorphic — Ruby SSR interpreter mirrors the JS runtime; test everything in RSpec
- Zero runtime Ruby dependencies
Tailmix UI
This repository also includes tailmix-ui, a companion gem with ready-made Arbre + Tailwind components powered by Tailmix definitions. These components follow the same twin-layer pattern: a *State class defines variants, state, elements, and rules; the Arbre component renders DOM and adds hydration attributes.
Current components:
| Builder | Component |
|---|---|
btn / button
|
Button |
badge |
Badge |
card |
Card |
tabs |
Tabs |
modal |
Modal |
dropdown |
Dropdown |
toast |
Toast |
tooltip |
Tooltip |
sidebar |
Sidebar layout |
accordion_panel |
Accordion panel |
drawer |
Slide-over drawer |
stepper |
Stepper workflow |
The Rails sandbox under sandbox/ includes Lookbook previews for these components.
Installation
bundle add tailmixFor JavaScript hydration, install the asset:
bin/rails g tailmix:installQuick start
1. Define the component
# app/components/disclosure_component.rb
class DisclosureComponent
include Tailmix
attr_reader :ui
tailmix do
state :open, default: false
element :root do
on :keydown do
# Escape key closes
end
end
element :trigger, "flex w-full items-center justify-between py-4 font-medium" do
on :click do
toggle state.open
end
aria expanded: state.open
end
element :panel do
style condition: state.open do
classes "block pb-4"
otherwise do
classes "hidden"
end
end
end
element :icon, "transition-transform duration-200" do
style condition: state.open do
classes "rotate-180"
end
end
end
def initialize(open: false)
@ui = tailmix(open: open)
end
end2. Use in Arbre
disclosure = DisclosureComponent.new
ui = disclosure.ui
div ui.root do
button ui.trigger do
span "Is Tailmix production-ready?"
span ui.icon do
# chevron SVG
end
end
div ui.panel do
para "Yes! The Ruby SSR path is stable and the JS runtime is actively developed."
end
end3. Use in ERB / ViewComponent
<% ui = DisclosureComponent.new.ui %>
<div <%= tag.attributes(**ui.root) %>>
<button <%= tag.attributes(**ui.trigger) %>>
Is Tailmix production-ready?
</button>
<div <%= tag.attributes(**ui.panel) %>>
Yes! Absolutely.
</div>
</div>DSL Reference
State
Mutable values managed by the JS runtime. Types are inferred from the default value.
state :open, default: false # boolean
state :count, default: 0 # integer
state :query, default: "" # string
state :data, default: nil, type: :json # JSON blob
# Persist across page loads
state :theme, default: "light", persist: :local # localStorage
state :sidebar, default: true, persist: :session # sessionStorageVariants
Static compile-time props. Resolved at SSR, invisible to JavaScript.
variant :size, default: :md # :sm | :md | :lg
variant :color, default: :bluePass variants when building the facade:
@ui = tailmix(size: :lg, color: :green)Elements
element :name, "static-classes" do
# rules ...
endPass per-element params at render time:
# Definition
element :tab do
on :click do
set state.active, param.id
end
end
# Usage
ui.tab(id: "profile")style
style condition: state.open do
classes "block"
aria expanded: true
data foo: "bar"
otherwise do
classes "hidden"
aria expanded: false
end
endmatch
Pattern-match a state value to attribute sets:
match state.size do
on "sm", "text-sm px-2 py-1"
on "lg", "text-lg px-6 py-3"
default "text-base px-4 py-2"
endon (event handlers)
on :click do
set state.active, param.id # assign value
toggle state.open # flip boolean
dispatch "tab:changed", detail: { id: param.id } # CustomEvent
log "clicked" # console.log in browser
endfetch
on :click do
fetch "/api/items" do |response|
set state.items, response
end
end
# With query params and method
fetch "/api/search", method: :post, query: { q: state.query } do |response|
set state.results, response
endboot
Runs once after JS component initialisation:
boot do
fetch "/api/initial-data" do |response|
set state.data, response
end
endwatch
Reactive side-effects. Fires when the watched expression changes:
watch state.query do
fetch "/api/search", query: { q: state.query } do |response|
set state.results, response
end
endExamples
See the examples/ directory for complete Tailmix engine examples, and tailmix-ui/lib/tailmix_ui/components/ for production-style Arbre component implementations:
| Example | Concepts |
|---|---|
tabs.rb |
state, set, style, param
|
modal.rb |
toggle, dispatch, style/otherwise
|
accordion.rb |
match, toggle, multiple elements |
live_search.rb |
watch, fetch, debounce pattern |
How it works
Ruby DSL
└─ ComponentParser / ElementParser / ActionParser
└─ AST (nodes.rb)
└─ JSONGenerator (compiler)
└─ Definition Hash (JSON-serializable)
├─ FacadeBuilder → Facade class (Ruby SSR)
└─ JS runtime (component.js) ← hydrates from JSON definition
The compiled definition is a plain Ruby Hash (also valid JSON). Both the Ruby interpreter and the JS runtime consume the same format, so server-rendered and client-rendered components behave identically.
Contributing
Useful local commands:
bundle exec rspec
cd tailmix-ui
bundle exec rspec
cd ../sandbox
bin/devBug reports and pull requests are welcome on GitHub.
License
MIT — see LICENSE.