Project

klods-ruby

0.0
The project is in a healthy, maintained state
Ruby builder API for klods β€” same components, same call shapes, same HTML output.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 3.0
~> 1.0
 Project Readme

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