Skip to content

Repository files navigation

klods-ruby

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.

📖 klods docs →

Installation

gem install klods-ruby

Or 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-css

Quick start

Plain Ruby

require "klods"
include Klods::Builders

puts card([
  card_title("Hello"),
  card_body("Snap blocks together like lego.")
])

Rails (HAML)

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")
  = actions

Rails (ERB)

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

Call shapes

Every builder accepts three forms — use the shortest that fits:

badge                                    # no args
badge("Done")                            # children only
badge({variant: "success"}, "Done")      # props + children

Components

Layout

page({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 sidebar

Components

card([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([...])

Navigation

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")])

Forms

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 id from the label when none is provided
  • Wires for on the label and id on the input
  • Adds aria-describedby pointing to the help or error text
  • Adds aria-invalid="true" on the input when an error is present

Rails forms with Klods::FormBuilder

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.

Interactive

# 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.")

Data display

# 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 icon

Code

code_block("npm install klods-ruby")
inline_code("Klods::Node")
kbd("Ctrl+S")
samp("output text")

Icons

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_icon

Each accepts optional props:

search_icon({size: "large", label: "Search"})
# size: "small" | "medium" | "large" (default: "medium")
# label: accessible text — omit for decorative use

HTML tag shortcuts

For 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    hr

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

Escape hatch

el("time", {datetime: "2026-01-01"}, "1 January 2026")
raw("<strong>Pre-escaped HTML</strong>")

Themes

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>

Development

bundle install
bundle exec rspec          # run tests
bundle exec standardrb     # check style
bundle exec standardrb --fix  # auto-fix style

Releasing

Releases are automated with Release Please:

  1. Commit with Conventional Commits (feat:, fix:, docs:, chore:)
  2. Release Please opens a Release PR bumping version.rb and updating CHANGELOG.md
  3. Merging that PR publishes the gem to RubyGems automatically

First-time setup

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

License

MIT

About

Ruby builder API for klods

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages