Build reactive web components using Ruby and WebAssembly
Zephyr WASM is a lightweight framework for creating interactive web components using Ruby, compiled to WebAssembly and running entirely in the browser. Write your UI logic in Ruby with a declarative template syntax, and let the browser handle the rest.
- Pure Ruby - Write components in idiomatic Ruby
- Reactive State - Automatic re-rendering on state changes
- Web Components - Standard custom elements that work anywhere
- Zero Build Step - Load Ruby files directly in the browser
- Lifecycle Hooks -
on_connect
andon_disconnect
callbacks - Event Handling - First-class support for DOM events
- Template DSL - Clean, declarative component templates
run the following script:
ruby build.rb
this will generate the zephyrRB.js file
You need an HTTP server (ruby.wasm can't load files via file://
):
# Python
python3 -m http.server 8000
# Ruby
ruby -run -ehttpd . -p8000
# Node
npx http-server -p 8000
<!DOCTYPE html>
<html>
<head>
<title>My Zephyr App</title>
</head>
<body>
<!-- Use your components -->
<x-counter initial="5"></x-counter>
<!-- Load required items from ZephyrRB-->
<script src="/dist/zephyrRB.js"></script>
</body>
</html>
Create components.rb
:
# Counter component
ZephyrWasm.component('x-counter') do
observed_attributes :initial
on_connect do
count = (self['initial'] || '0').to_i
set_state(:count, count)
end
template do |b|
comp = self
b.div(class: 'counter') do
b.button(on_click: ->(_) { comp.set_state(:count, comp.state[:count] - 1) }) do
b.text('-')
end
b.span { b.text(comp.state[:count]) }
b.button(on_click: ->(_) { comp.set_state(:count, comp.state[:count] + 1) }) do
b.text('+')
end
end
end
end
Visit http://localhost:8000
and see your component in action! π
ZephyrRB/
βββ src/
β βββ browser.script.iife.js
β βββ component.rb
β βββ components.rb
β βββ dom_builder.rb
β βββ registry.rb
β βββ zephyr-bridge.js
β βββ zephyr_wasm.rb
βββ dist/
β βββ zephyrRB.js
βββ lib/
β βββ cli.rb
β βββ version.rb
βββ README.md
βββ zephyr_rb.gemspec
βββ build.rb
ZephyrWasm.component('x-my-component') do
# Declare observed HTML attributes
observed_attributes :foo, :bar
# Lifecycle: called when component is added to DOM
on_connect do
# Initialize state
set_state(:count, 0)
end
# Lifecycle: called when component is removed from DOM
on_disconnect do
# Cleanup if needed
end
# Define your component's UI
template do |b|
comp = self # Capture component reference
b.div(class: 'my-component') do
b.h1 { b.text("Hello from Ruby!") }
end
end
end
# Set state (triggers re-render)
set_state(:key, value)
# Read state
state[:key]
# Multiple state updates
set_state(:count, 0)
set_state(:loading, false)
# Read HTML attributes
value = self['data-id']
# Write HTML attributes
self['data-id'] = 'new-value'
# Observed attributes automatically update state
observed_attributes :user_id
on_connect do
# state[:user_id] is automatically set from the attribute
puts state[:user_id]
end
template do |b|
comp = self
# Click handler
b.button(on_click: ->(_e) { comp.set_state(:clicked, true) }) do
b.text('Click me')
end
# Input handler
b.tag(:input,
type: 'text',
on_input: ->(e) { comp.set_state(:value, e[:target][:value].to_s) }
)
# Any DOM event works: on_change, on_submit, on_keydown, etc.
end
template do |b|
comp = self
# HTML elements (method name = tag name)
b.div(class: 'container') do
b.h1 { b.text('Title') }
b.p { b.text('Paragraph') }
end
# Attributes
b.div(id: 'main', class: 'active', data_value: '123')
# Generic tag method
b.tag(:input, type: 'text', placeholder: 'Enter text...')
# Text nodes
b.span { b.text('Hello') }
# Conditional rendering
b.render_if(comp.state[:show]) do
b.p { b.text('Visible!') }
end
# List rendering
b.render_each(comp.state[:items] || []) do |item|
b.li { b.text(item[:name]) }
end
# Boolean properties (checked, disabled, selected)
b.tag(:input, type: 'checkbox', checked: true)
# Inline styles
b.div(style: { color: 'red', font_size: '16px' })
end
ZephyrWasm.component('x-counter') do
observed_attributes :initial
on_connect do
initial = (self['initial'] || '0').to_i
set_state(:count, initial)
set_state(:initial, initial)
end
template do |b|
comp = self
count = comp.state[:count] || 0
b.div(class: 'counter') do
b.button(on_click: ->(_) { comp.set_state(:count, count - 1) }) do
b.text('-')
end
b.span(class: 'count') { b.text(count) }
b.button(on_click: ->(_) { comp.set_state(:count, count + 1) }) do
b.text('+')
end
b.button(on_click: ->(_) { comp.set_state(:count, comp.state[:initial]) }) do
b.text('Reset')
end
end
end
end
ZephyrWasm.component('x-toggle') do
observed_attributes :label, :checked
on_connect do
set_state(:checked, self['checked'] == 'true')
end
template do |b|
comp = self
is_checked = comp.state[:checked]
label = comp['label'] || 'Toggle'
b.button(
class: is_checked ? 'toggle active' : 'toggle',
on_click: ->(_) {
new_state = !comp.state[:checked]
comp.set_state(:checked, new_state)
comp['checked'] = new_state.to_s
# Dispatch custom event
event = JS.global[:CustomEvent].new(
'toggle-change',
{ bubbles: true, detail: { checked: new_state }.to_js }.to_js
)
comp.element.call(:dispatchEvent, event)
}
) do
b.text(label)
end
end
end
ZephyrWasm.component('x-todo-list') do
on_connect do
set_state(:todos, [])
set_state(:input_value, '')
end
template do |b|
comp = self
b.div(class: 'todo-list') do
# Input section
b.div(class: 'input-group') do
b.tag(:input,
type: 'text',
placeholder: 'Enter a task...',
value: comp.state[:input_value] || '',
on_input: ->(e) { comp.set_state(:input_value, e[:target][:value].to_s) }
)
b.button(
on_click: ->(_) {
value = comp.state[:input_value]&.strip
if value && !value.empty?
todos = (comp.state[:todos] || []).dup
todos << {
id: JS.global[:Date].new.call(:getTime),
text: value,
done: false
}
comp.set_state(:todos, todos)
comp.set_state(:input_value, '')
end
}
) { b.text('Add') }
end
# Todo items
b.tag(:ul) do
todos = comp.state[:todos] || []
b.render_each(todos) do |todo|
b.tag(:li, class: todo[:done] ? 'done' : '') do
b.tag(:input,
type: 'checkbox',
checked: !!todo[:done],
on_change: ->(e) {
updated_todos = (comp.state[:todos] || []).map { |t|
t[:id] == todo[:id] ? { **t, done: !!e[:target][:checked] } : t
}
comp.set_state(:todos, updated_todos)
}
)
b.span { b.text(todo[:text]) }
b.button(
class: 'delete',
on_click: ->(_) {
filtered = (comp.state[:todos] || []).reject { |t| t[:id] == todo[:id] }
comp.set_state(:todos, filtered)
}
) { b.text('Γ') }
end
end
end
end
end
end
# Dispatch custom events from your component
event = JS.global[:CustomEvent].new(
'my-event',
{
bubbles: true,
detail: { foo: 'bar' }.to_js
}.to_js
)
element.call(:dispatchEvent, event)
on_connect do
# Query inside component
button = query('.my-button')
# Query all
items = query_all('.item')
end
on_connect do
# Direct access to the DOM element
element[:id] = 'my-component'
element.call(:setAttribute, 'data-loaded', 'true')
end
# Call JavaScript functions
JS.global[:console].call(:log, 'Hello from Ruby!')
# Access global objects
date = JS.global[:Date].new
timestamp = date.call(:getTime)
# Call methods on JS objects
element.call(:scrollIntoView)
Add CSS to your HTML:
<style>
.counter {
display: flex;
gap: 1rem;
align-items: center;
}
.counter button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
background: #667eea;
color: white;
cursor: pointer;
}
.counter button:hover {
background: #5568d3;
}
</style>
Ruby WASM cannot load files via file://
protocol. Always serve your files:
python3 -m http.server 8000
Custom element names require a hyphen:
# β
Good
ZephyrWasm.component('x-counter')
ZephyrWasm.component('my-button')
# β Bad
ZephyrWasm.component('counter') # Missing hyphen!
Always capture self
as a local variable in templates:
template do |b|
comp = self # β
Capture this!
b.button(on_click: ->(_) {
comp.set_state(:clicked, true) # Use comp, not self
})
end
Event handlers should be Ruby procs that will be converted to JS:
# β
Good
on_click: ->(_e) { comp.set_state(:count, 1) }
# β Bad - don't call .to_js yourself
on_click: ->(_e) { comp.set_state(:count, 1) }.to_js
- Check browser console for errors
- Ensure you're using an HTTP server (not
file://
) - Verify all
.rb
files are in the same directory asindex.html
- Check Network tab for 404 errors
This happens if you reload without refreshing. It's harmless but you can add:
ZephyrWasm::Registry.clear
Make sure you're using set_state
and not modifying state directly:
# β
Good
set_state(:count, state[:count] + 1)
# β Bad - won't trigger re-render
state[:count] += 1
- Ruby Files Load: Browser fetches your
.rb
files via<script type="text/ruby" src="...">
- Components Register: Ruby code registers component metadata in
window.ZephyrWasmRegistry
- Bridge Watches:
zephyr-bridge.js
uses a Proxy to watch for new components - Custom Elements Defined: Bridge defines custom elements using
setTimeout()
to avoid nested VM calls - Component Lifecycle: When elements connect to DOM, Ruby component instances are created
- Reactive Rendering: State changes trigger re-renders using DocumentFragment for efficiency
MIT License - feel free to use in your projects!
This is an experimental framework. Issues and pull requests welcome!
Built with:
- ruby.wasm - Ruby in the browser
- Web Components - Standard browser APIs
- Love for Ruby π
Happy coding with Ruby and WebAssembly! π