Ruby builder API for klods — same components, same call shapes, same HTML output.
klods-ruby lets you use klods in any Ruby project (Rails, Sinatra, plain Ruby) without touching JavaScript. Every builder maps 1-to-1 with its TypeScript counterpart: camelCase becomes snake_case, everything else stays the same.
gem install klods-rubyOr add to your Gemfile:
gem "klods-ruby"You still need the CSS. Grab it from a CDN:
<link rel="stylesheet" href="https://unpkg.com/klods-css/dist/klods.min.css" />Or install via npm and serve it yourself:
npm install klods-cssrequire "klods"
include Klods::Builders
puts card([
card_title("Hello"),
card_body("Snap blocks together like lego.")
])Add gem "haml-rails" to your Gemfile. The Railtie wires all builders into every view automatically.
In HAML, builders accept a do block whose indented content becomes the children — the component hierarchy mirrors the indentation:
- content_for :title, "Home"
- content_for :sidebar, toc([toc_item(toc_link({ href: "#intro" }, "Introduction")), toc_item(toc_link({ href: "#usage" }, "Usage"))])
= stack({ gap: 6 }) do
= prose do
= h1({ id: "intro" }, "Welcome")
= lead("Build pages with Ruby.")
= card do
= card_title("Getting started")
= card_body do
= p do
= "Install the gem, then "
= inline_code("bundle exec rails server")
= "."One rule: = output lines must always be a single Ruby expression — no multi-line arrays. Use do blocks for nesting, and - var = for any intermediate values you want to name:
- edit_path = inline_code("app/views/welcome/index.html.haml")
= prose do
= h1("Welcome")
= p do
= "Edit "
= edit_path
= " to change this page."Blocks and the array API are interchangeable — use whichever fits the situation:
- actions = cluster({ gap: 2 }, [button({ variant: "primary" }, "Save"), button("Cancel")])
= stack({ gap: 4 }) do
= prose do
= h1("Title")
= actionsAdd to an initializer or application.rb — the Railtie wires everything up automatically:
<%= page({sidebar: true}, [
header([
span("My App"),
push,
nav_link({href: "/", active: true}, "Home"),
nav_link({href: "/about"}, "About")
]),
sidebar([
toc([
toc_item(toc_link({href: "#intro"}, "Introduction")),
toc_item(toc_link({href: "#usage"}, "Usage"))
])
]),
content(
card([
card_title("Welcome"),
card_body("Build pages with Ruby.")
])
),
footer("© 2026")
]) %>All builders return Klods::Node, which Rails treats as HTML-safe — no html_safe calls needed.
Every builder accepts three forms — use the shortest that fits:
badge # no args
badge("Done") # children only
badge({variant: "success"}, "Done") # props + childrenpage({sidebar: true, sticky_header: true}, [...])
header("My App")
sidebar([...])
content({narrow: true}, [...])
footer("© 2026")
section([...])
stack({gap: 4}, [...])
cluster({gap: 2}, [...])
row([...])
grid({cols: 3, gap: 4}, [...])
center([...])
spread([...])
push # flex spacer — pushes siblings to end of row
fill([...]) # grows to fill available space
sidebar_toggle # hamburger button for mobile sidebarcard([card_title("Title"), card_body("Body"), card_footer("Footer")])
card({elevated: true}, [...])
badge # span.klods-badge
badge({variant: "success"}, "Done")
badge({variant: "danger"}, "!")
badge({variant: "accent"}, "New")
alert("Something went wrong.")
alert({variant: "info"}, "FYI")
alert({variant: "success"}, "Saved.")
alert({variant: "warning"}, "Check this.")
alert({variant: "danger"}, "Error!")
button("Click me")
button({variant: "primary"}, "Save")
button({variant: "danger"}, "Delete")
button({variant: "ghost"}, "Cancel")
button({type: "submit"}, "Submit")
box("Content in a box")
prose([h1("Heading"), p("Paragraph text.")])
muted("Secondary text")
lead("A lead paragraph.")
text_center([...])nav([nav_list([
nav_link({href: "/", active: true}, "Home"),
nav_link({href: "/about"}, "About")
])])
nav({collapse: true}, [...]) # collapsible with hamburger toggle
nav_toggle # renders the hamburger button
toc([
toc_item(toc_link({href: "#section"}, "Section")),
toc_item(toc_link({href: "#sub"}, "Sub"), toc({sub: true}, [...]))
])
button_group([button("A"), button("B")])field takes a block that receives the auto-generated id:
form([
field({label: "Email", required: true}) { |id|
input(id: id, type: "email", placeholder: "you@example.com")
},
field({label: "Message", help: "Max 500 characters"}) { |id|
textarea(id: id)
},
field({label: "Plan", error: "Please choose a plan"}) { |id|
select(id: id, [
option({value: ""}, "Choose…"),
option({value: "free"}, "Free"),
option({value: "pro"}, "Pro")
])
},
checkbox({label: "I agree to the terms", name: "terms", value: "1"}),
radio_group({legend: "Colour"}, [
radio({label: "Red", name: "colour", value: "red"}),
radio({label: "Blue", name: "colour", value: "blue"})
]),
switch_input({label: "Enable notifications", name: "notify"}),
button({type: "submit", variant: "primary"}, "Submit")
])field automatically:
- Generates an
idfrom the label when none is provided - Wires
foron the label andidon the input - Adds
aria-describedbypointing to the help or error text - Adds
aria-invalid="true"on the input when an error is present
In a Rails app, use Klods::FormBuilder to get fully wired klods form fields from a single call — label, input, error message, help text, and all aria attributes generated automatically.
Set it as the default once in config/application.rb:
config.action_view.default_form_builder = "Klods::FormBuilder"Or specify it per form:
= form_with model: @user, builder: Klods::FormBuilder do |f|Then use f.klods_field, f.klods_textarea, f.klods_select, and f.klods_submit in your views:
= form_with model: resource, as: resource_name, url: registration_path(resource_name) do |f|
= stack({ gap: 4 }) do
= f.klods_field :email, label: "Email", type: :email, autocomplete: "email"
= f.klods_field :password, label: "Password", type: :password, required: true
= f.klods_select :role, [["Admin", "admin"], ["User", "user"]], label: "Role"
= f.klods_textarea :bio, label: "Bio", help: "Tell us about yourself"
= f.klods_submit "Sign up"f.klods_field — for <input> elements. type: accepts :text (default), :email, :password, :tel, :url, :number, :date, :time, :search.
f.klods_textarea — renders a <textarea> with klods-textarea class.
f.klods_select — renders a <select> with klods-select class, wrapped in a klods-select-wrapper div. Pass choices as the second positional argument (array of ["Label", value] pairs or plain values).
All three share the same keyword options:
| Option | Default | Description |
|---|---|---|
label: |
humanized attribute name | Label text |
help: |
— | Help text shown below the input |
required: |
false |
Adds klods-label--required to the label |
Validation errors are read from object.errors automatically — no need to pass them manually. When errors are present, the field gets klods-field--invalid, the error message is shown with klods-error, and aria-invalid="true" is set on the input.
# Modal — open/close is wired by client JS
modal([
modal_panel([
modal_header([modal_title("Confirm"), modal_close]),
modal_body("Are you sure?"),
modal_actions([button("Cancel"), button({variant: "danger"}, "Delete")])
])
])
# Tabs
tabs([
tab_panel({label: "Account"}, [...]),
tab_panel({label: "Security"}, [...])
])
# Disclosure
details([summary("Show more"), p("Hidden content.")])
# Tooltip
tooltip({tip: "Click to copy", position: "above"}, button("Copy"))
# Toast region — render once in your layout; toast() for SSR pre-filled notifications
toast_region
toast({variant: "success"}, "Changes saved.")# Breadcrumbs
breadcrumbs([
crumb({href: "/"}, "Home"),
crumb({href: "/products"}, "Products"),
crumb("Widget")
])
# Description list
dl([
dt("Name"), dd("Drue Wilding"),
dt("Role"), dd("Developer")
])
dl({inline: true}, [...])
# Table
table_wrap(
table({striped: true}, [
thead(tr([th("Name"), th("Role")])),
tbody([
tr([td("Drue"), td("Developer")])
])
])
)
# List
list([
list_item("Plain item"),
list_item({href: "/page"}, "Link item"),
list_item({lead: badge("!"), trail: chev_right_icon}, "With slots")
])
# Avatar
avatar({src: "/photo.jpg", name: "Drue Wilding"})
avatar({name: "Drue Wilding"}) # falls back to initials DW
avatar # falls back to user iconcode_block("npm install klods-ruby")
inline_code("Klods::Node")
kbd("Ctrl+S")
samp("output text")All 19 icons from klods-js, in snake_case:
check_circle_icon
chev_down_icon chev_left_icon chev_right_icon chev_up_icon
close_icon copy_icon danger_circle_icon edit_icon
external_link_icon eye_icon eye_off_icon info_circle_icon
menu_icon plus_icon search_icon trash_icon
user_icon warning_iconEach accepts optional props:
search_icon({size: "large", label: "Search"})
# size: "small" | "medium" | "large" (default: "medium")
# label: accessible text — omit for decorative useFor raw HTML elements with no BEM class:
p("Paragraph")
span("Inline")
div({class: "wrapper"}, [...])
ul([li("a"), li("b")])
a({href: "/path"}, "Link")
h1("Heading") # h1 through h6
strong("Bold") em("Italic")
code("snippet") pre(code("block"))
img({src: "/logo.png", alt: "Logo"})
br hrTags that share a name with a klods component (nav, button, form, table, etc.) are intentionally omitted — use the klods component or el("tag", ...) for the unstyled version.
el("time", {datetime: "2026-01-01"}, "1 January 2026")
raw("<strong>Pre-escaped HTML</strong>")Apply a theme with a data-theme attribute on any ancestor element:
<html data-theme="dark">
<div data-theme="playful">...</div>
<div data-theme="brutalist">...</div>bundle install
bundle exec rspec # run tests
bundle exec standardrb # check style
bundle exec standardrb --fix # auto-fix styleReleases are automated with Release Please:
- Commit with Conventional Commits (
feat:,fix:,docs:,chore:) - Release Please opens a Release PR bumping
version.rband updatingCHANGELOG.md - Merging that PR publishes the gem to RubyGems automatically
Two secrets are needed in the repo settings:
| Secret | Where to get it |
|---|---|
RELEASE_PLEASE_TOKEN |
GitHub → Settings → Developer settings → Personal access tokens (needs contents and pull-requests write) |
RUBYGEMS_API_KEY |
rubygems.org → Account → API keys |
MIT