Unmagic::Components
Browse the components and documentation
Declarative table and detail-list builders for server-rendered Rails views, in
the spirit of form_for: describe the columns, get the chrome.
<%= table_for @users do |table| %>
<% table.empty "No team members yet." %>
<% table.column "Name", width: "38%" do |user| %>
<%= link_to user.name, user %>
<% end %>
<% table.column "Created", sort: :created_at, direction: :desc do |user| %>
<%= user.created_at.to_fs(:short) %>
<% end %>
<% table.column "Logins", :sign_in_count, numeric: true %>
<% end %>Features
- Tables that stack on a phone, each row a card of its cells with the headings written in front.
-
Sortable headers that toggle
?sort/?direction, carryaria-sort, and preserve the rest of the query string. -
Deferred tables —
defer: truerenders a skeleton inside a Turbo Frame without touching the collection, then loads the real rows into it. The skeleton keeps the loaded table's headers and column widths, so nothing shifts when the data lands. - Two flavours of empty state: a blank slate for an empty dataset, and a separate one for a search that matched nothing, chosen automatically.
- Companion detail rows under any record, skipped per row when empty.
-
Detail lists in inline and stacked layouts, with blanks rendered as an em
dash so call sites don't each need
.presence || "—". -
Dialogs as native
<dialog>s: a shared modal that loads its content from the server with a skeleton, an error panel with retry, and a close that lands in the same render as the page's refresh; same-page dialogs; and a confirm dialog in place ofwindow.confirm. - AI chat components for rendering an agent's work: a transcript, streamed replies revealed at a steady pace, tool calls strung into a timeline, reasoning, plans, workspaces, permission gates, questions, inline proposals, citations and a composer with Send and Stop. See AI chat.
- Drag-and-drop ordering by pointer or keyboard: sortable lists and a Trello-style board, posting one-row moves that unmagic-sortable saves. See Sortable lists and boards.
- No hard dependency on a pagination library or on any helper of yours.
Installation
gem "unmagic-components"The components are styled with Tailwind CSS v4, which your app needs. The
gem's styles are a Tailwind source file,
app/assets/tailwind/unmagic_components/engine.css, that your own Tailwind
build compiles. Markup inside an installed gem is never scanned, so the gem
hands Tailwind its CSS instead of relying on scanning.
With tailwindcss-rails, import the engine's entry file after Tailwind itself in
app/assets/tailwind/application.css:
@import "tailwindcss";
@import "../builds/tailwind/unmagic_components";tailwindcss-rails generates that entry file on every build and watch, or on
demand with bin/rails tailwindcss:engines.
With the Tailwind CLI or an npm build, import the gem's file by path. bundle show unmagic-components prints where the gem is installed:
@import "tailwindcss";
@import "/path/to/unmagic-components/app/assets/tailwind/unmagic_components/engine.css";The gem brings two of its siblings with it: unmagic-icon, which renders the
glyphs, and unmagic-color, which picks avatar tints. The glyphs are a subset of
Lucide shipped inside this gem, so there is no icon set to download; under Rails
they are also available to your views as unmagic_icon "unmagic_components:lucide/<name>".
The engine mixes the helpers into ActionView automatically — no initializer
needed to get started. The interactive components need their JavaScript too; with
importmap-rails the engine pins it for you, so add import "unmagic/components"
to your application.js (see Dialogs).
Browsing components
Unmagic::Components::Browser is an engine for looking through every component,
its examples and their source inside your own app. Mount it in your routes:
mount Unmagic::Components::Browser::Engine => "/unmagic/components" if Rails.env.development?It brings its own stylesheet, prebuilt with Tailwind's default theme, and loads
the components' JavaScript and Turbo itself, so nothing in your CSS or JS
changes. It needs turbo-rails in your bundle. It shows the components as the
gem draws them: your configuration doesn't apply inside it, so an empty_state
that renders your own partial runs only in your app.
Its controllers inherit ActionController::Base, so your authentication doesn't
cover it. Its demo endpoints only write to the visitor's session, but mount it
outside development only behind a constraint of your own:
authenticate :user, ->(user) { user.admin? } do
mount Unmagic::Components::Browser::Engine => "/unmagic/components"
endA static copy of the browser
rake browser:export[site,/unmagic-components] writes every page out as
static files under site/, with the stylesheet, the components' JavaScript and
Turbo beside them, for hosting where nothing runs. The second argument is the
path the site will be served under (a project site on GitHub Pages lives under
/<repo>/), which is what every link and asset URL in the pages is written
for. Examples that talk to a server (a form that saves, a search) say so on the
static page; the rest work as they do live. The theme toggle works too, kept in
the visitor's browser.
.github/workflows/browser.yml does this on every push to main and on every
pull request: main goes to the root of the repository's GitHub Pages site and
each pull request to pr-<number>/, with a comment on the pull request saying
where. Pages has to be set to deploy from the gh-pages branch.
Theming
The components use Tailwind's own palette (neutral for surfaces, borders and
text, and red, green and amber for tones), with a dark: variant for every
colour. They look right with Tailwind's defaults and follow your theme from
there.
Dark mode follows your app's dark variant. Tailwind's default is the
visitor's system setting. To switch on a class or an attribute instead, redefine
the variant in your Tailwind input file and the components switch with it:
@custom-variant dark (&:where(.dark, .dark *));Colours, radii and fonts come from your theme, so change them there and the components follow:
@theme {
--color-neutral-900: oklch(0.21 0.03 265);
--radius-md: 0.25rem;
}Your own greys. Every grey the components draw, text, borders and surfaces
alike, is a neutral shade, and Tailwind compiles each one to
var(--color-neutral-…). So your brand's greys replace them in one of two ways,
and the gem has no grey setting of its own.
Everywhere, by redefining the ramp in your @theme. Every neutral in your app
changes with it:
@theme {
--color-neutral-50: oklch(98.5% 0.004 70);
--color-neutral-100: oklch(97% 0.007 70);
--color-neutral-200: oklch(92.2% 0.012 70);
--color-neutral-300: oklch(87% 0.016 70);
--color-neutral-400: oklch(70.8% 0.024 70);
--color-neutral-500: oklch(55.6% 0.03 70);
--color-neutral-600: oklch(43.9% 0.028 70);
--color-neutral-700: oklch(37.1% 0.025 70);
--color-neutral-800: oklch(26.9% 0.02 70);
--color-neutral-900: oklch(20.5% 0.016 70);
--color-neutral-950: oklch(14.5% 0.012 70);
}Or in one place, by redefining the same variables on a wrapper, in plain CSS
in your Tailwind input file. Everything inside it, the wrapper included, draws from your
greys, and neutral stays Tailwind's everywhere else. This keeps a panel on
one palette when its surroundings use your greys but the rest of the app
doesn't:
.brand-greys {
--color-neutral-50: oklch(98.5% 0.004 70);
/* …the rest of the ramp, as above */
--color-neutral-950: oklch(14.5% 0.012 70);
}<aside class="brand-greys rounded-xl border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-900">
<%= ai_chat_plan do |plan| %>…<% end %>
<%= ai_chat_workspace do |workspace| %>…<% end %>
</aside>Either way:
-
Redefine the whole ramp, 50 to 950. Light and dark use different
shades of the same colour (
text-neutral-500,dark:text-neutral-400), so a partial ramp mixes palettes in one mode or the other. -
Keep each shade's lightness. Contrast is mostly lightness, and the
components' text pairs are chosen against Tailwind's ramp:
neutral-500on white orneutral-50is only just over 4.5:1. Hold theLof each shade (the firstoklchvalue) and change the chroma and hue, as above, and every pair keeps its ratio. If you move a shade's lightness, check its pairs again. -
White and black aren't greys. Surfaces drawn
bg-whitestay white. Set--color-whitetoo, or give the wrapper aneutral-50surface, if you want them tinted. -
Translucent greys follow in current browsers. A shade with an opacity
(
bg-neutral-800/50) compiles tocolor-mix()over the variable, with a fixed fallback for browsers withoutcolor-mix()inoklab. Only those browsers keep Tailwind's grey there, and only with the scoped override. - The scoped override is inherited, not global. A popover or dialog you render outside the wrapper keeps Tailwind's greys; put the class on it too.
The browser shows the scoped override on the AI chat workspace.
One component takes utilities through class:, like any other option.
The gem's rules sit in @layer components, which Tailwind orders before
@layer utilities, so your utilities win:
<%= card title: "Members", class: "rounded-none shadow-none" do %>…<% end %>
<%= form.field :email, "Email", class: "font-mono" %>Configuration
Seams, each with a working default. Point them at your own versions if you already own these concerns:
# config/initializers/unmagic_components.rb
Unmagic::Components.configure do |config|
# The table's blank slate. Called with (view, content, **options), where the
# options are whatever `table.empty` was given beyond its text.
config.empty_state = ->(view, content, **options) { view.empty_state(content) }
# The table's pager. Called with (view, pagy:, turbo_frame:).
config.pagination = ->(view, pagy:, turbo_frame:) {
view.render "shared/pagination", pagy: pagy, turbo_frame: turbo_frame
}
# Resolves the pager object a table pages with. Called with (view, collection);
# return nil to suppress the pager. Nothing here is Pagy-specific — the
# renderer only needs something answering previous/next/page_url, so
# geared_pagination or your own object works just as well.
config.pagy_for = ->(view, collection) { view.table_pagy(collection) }
# Colours a block of source. Called with (source, language); return one
# html_safe string per line. The default is Rouge, whose token classes the
# gem's stylesheet colours.
config.highlight = ->(source, language) { MyHighlighter.lines(source, language) }
# Frames a block of code: a tool call's payload, a code block in prose. Called
# with (view, source, language); return markup. The default is a code_view.
config.code_block = ->(view, source, language) { view.render("code", source: source, language: language) }
# What a sortable item carries for a record, and where drops post. The
# unmagic-sortable gem sets both to its signed keys and endpoint.
config.sortable_item = ->(view, record) { { key: record.to_param, rank: record.sortable_rank } }
config.sortable_url = ->(view) { view.reorder_path }
endUsage
table_for(collection, **options, &block)
| Option | Default | Meaning |
|---|---|---|
defer: |
false |
Render a skeleton in a Turbo Frame first, then load the rows |
id: |
"#{controller_name}_table" |
The frame id, when deferring |
paginate: |
true |
false suppresses the pager; a pagy object is used directly |
headers: |
true |
false omits the <thead>
|
sorted_by: |
params[:sort] |
The currently applied sort key |
sort_direction: |
params[:direction] |
:asc or :desc
|
sort_url: |
— |
->(key, direction) { url }, when sorting rides on other params |
row_class: |
— |
->(record) { "…" } for extra <tr> classes |
Any other option rides on the <table> itself — class:, data:, aria-* — so a
view can space or annotate the table without wrapping it in a div. id: is the
exception: it names the deferred turbo frame, not the table.
On the yielded builder:
-
column(title = nil, attribute = nil, **options, &block)— content comes from the block, elserecord.public_send(attribute). Options:sort:,direction:,align:(:right/:center),numeric:(right-aligns and uses tabular figures),width:,class:,skeleton:(below). -
details(&block)— a full-width companion row per record; capturing nothing skips it. -
empty(text = nil, **options, &block)— the blank slate for an empty dataset. -
no_results(text = nil, **options, &block)— shown instead when the collection responds tofiltered?with true.
Extra options on empty/no_results are handed to the configured empty_state
seam, so an app whose blank slate takes more than a message can ask for it per
table:
<% table.empty "No labels yet.", icon: "tag" %>config.empty_state = ->(view, content, **options) do
view.render "shared/empty", icon: options.fetch(:icon, "table"), message: content
endwidth: takes a CSS length ("40%", "170px"), which rides on a <col> as a
style, or any other string, which is used as a class name so a Tailwind app can
pass "w-[40%]". Any width switches the table to a fixed layout. Give every
column of a deferred table a width, so the skeleton and the rows that replace it
lay out identically.
A deferred table's skeleton draws one bar per cell. A column whose cells look
like something else declares it with skeleton:, so the skeleton rows are the
height and shape of the rows that replace them. It takes a skeleton builder shape
by name, or a lambda given the builder (see Skeletons):
<% table.column "Name", width: "30%", skeleton: ->(s) { s.item(avatar: :medium) } do |person| %>…<% end %>
<% table.column "Role", width: "25%", skeleton: :item do |person| %>…<% end %>
<% table.column "Status", width: "15%", skeleton: :badge do |person| %>…<% end %>
<% table.column "Skills", width: "22%", skeleton: ->(s) { s.badge(count: 3) } do |person| %>…<% end %>
<% table.column width: "8%", align: :right, skeleton: :icon do |person| %>…<% end %>:text and :item take a different width on each row, as the bars do. An
unknown shape raises ArgumentError when the column is declared. details rows
aren't part of the skeleton: they're per record, so any placeholder would be wrong
for most rows.
detail_list(variant: :inline, **options, &block)
<%= detail_list variant: :stacked do |list| %>
<% list.item "Client ID", @application.uid, class: "font-mono" %>
<% list.item "Redirect URIs", span: :full do %>
<% @application.redirect_uris.each do |uri| %>
<div><%= uri %></div>
<% end %>
<% end %>
<% list.item "Registered", @application.created_at %>
<% end %>:inline lays labels beside values in a two-column grid; :stacked puts small
caps labels above values, in two columns once there is room. An item's class:
lands on its <dd>, and span: :full stretches a stacked item across both
columns.
table_tag(headers, rows, **options)
The primitive the table is built on, for a static table that wants the same look without the record/sort/pagination machinery:
<%= table_tag [ "Name", "Score" ], [ [ "Ann", 42 ], [ "Bob", 7 ] ], aligns: [ nil, :right ] %>A cell is a value, or a { content:, **attrs } hash setting attributes on its
th/td. A row is an array of cells, or a { cells:, **attrs } hash setting
attributes on its <tr>. aligns: and widths: are per-column, and caption:
adds a screen-reader-only caption.
Forms
FormBuilder is the chrome around a control — the wrapper, the label and its
required marker, the hint, and the error line — the part every app writes the same
way and then repeats in every view.
<%= form_with model: @label, builder: Unmagic::Components::FormBuilder do |form| %>
<%= form.errors_summary %>
<%= form.field :name, "Name", required: true, hint: "A key is derived from it." %>
<%= form.field :colour, "Colour" do %>
<%= form.select :colour, Label::COLOURS %>
<% end %>
<%= form.submit "Add label" %>
<% end %>Set it as the default with config.action_view.default_form_builder, or pass
builder: per form.
-
field(method, label, required:, hint:, as:, &block)— the control comes fromas:(any builder method), or from the block when you pass one. An invalid field getsaria-invalid="true"and its errors underneath, read as a sentence. -
group(inline: false)— lay the contained fields out in a row. -
errors_summary— the record's whole-object (:base) errors. -
check_box_field,check_box_collection— a checkbox with its label beside it. -
submit— conjugates its label for the length of the submit ("Save" → "Saving…", "Add label" → "Adding label…") viadata-turbo-submits-with. Passsubmitting: falseto leave it alone, or a string to choose it. A block supplies your own button content, e.g. an icon. -
form_value_for— the value to show, whether the object is a model or something hash-ish, preferring what the user actually typed. -
autogrow_text_area— a textarea that grows as you type, from the height itsrowsgive it up to its CSSmax-height, and then scrolls. Use it as a field's control withform.field :body, "Message", as: :autogrow_text_area. Outside a form builder, useautogrow_text_area_tag. Needsimport "unmagic/components/autogrow". -
uuid_field— a hidden field holding a fresh UUIDv7. Use it when the form should submit an id the client already knows, such as the id of an element rendered before the server replies. A new id is generated when the page loads and every time the form resets; without JavaScript the server's own id is sent. Outside a form builder, useuuid_input_tag. Needsimport "unmagic/components/uuid_input".
The controls are styled too. Each control the builder makes wears a class for its kind, and the gem's CSS styles those classes, never bare elements, so an input the gem didn't render keeps whatever your app gives it:
| Builder methods | Kind | Class |
|---|---|---|
text_field, email_field, number_field, url_field, search_field, telephone_field
|
:input |
UnmagicInput |
password_field |
:password |
UnmagicInput |
text_area, autogrow_text_area
|
:text_area |
UnmagicInput |
date_field, time_field, datetime_field, month_field, week_field
|
:date |
UnmagicInput |
select, collection_select, grouped_collection_select, time_zone_select
|
:select |
UnmagicSelect |
check_box_field, check_box_collection
|
:check |
UnmagicCheck |
A class you pass (class: "font-mono") is added after the gem's. Rails' own
check_box and radio_button are left alone, as are the *_tag helpers. Give
one of those the same look with control_classes:
<%= select_tag "status", options_for_select(%w[Open Closed]), class: control_classes(:select) %>
<%= search_field_tag "q", params[:q], class: control_classes(:input, size: :small) %>
<%= radio_button_tag "notify", "daily", class: control_classes(:radio) %>control_classes(kind, size:) takes the kinds above plus :radio. size: :small or :large sits a box level with a button_classes button of the same
size.
The classes come from a seam. If your app already styles its inputs, point it at
your own classes, or return nil to leave the controls unstyled:
config.control_class = ->(_view, kind) { kind == :select ? "form-select" : "form-input" }
config.control_class = ->(_view, _kind) { nil }The controls use the same palette as the rest of the gem. A checked box or radio is neutral-900 (neutral-100 in dark mode), and an invalid control has a red border.
The submit button's classes come from a seam, so it wears your own button:
config.submit_class = ->(_view, variant) { variant == :primary ? "btn btn-primary" : "btn" }Switches, radios, sliders, passwords and codes
<%= form.switch_field :notify, "Email me about new replies", hint: "…" %>
<%= form.radio_button_collection :plan, Plan.all, :id, :name, legend: "Plan", hint_method: :summary, variant: :cards, inline: true %>
<%= form.field :volume, "Volume", as: :range_field, min: 0, max: 100 %>
<%= form.field :password, "Password", as: :password_field, reveal: true %>
<%= form.field :code, "Code", as: :one_time_code_field, length: 6, submit: true %>-
switch_fieldis a checkbox withrole="switch", drawn as one;switchis the bare control andswitch_tagthe tag form. -
radio_button_collectionrenders a fieldset named bylegend:, a hint under each option fromhint_method:,inline:in a row, andvariant: :cardswhere the whole card is the target.radio_button_fieldis one labelled radio. -
range_fieldis the native slider styled to match. -
password_field reveal: trueadds a button that shows what was typed (import "unmagic/components/password");password_field_tagtakes it too. -
one_time_code_fieldis one real input for a code, drawn as a row of boxes byimport "unmagic/components/one_time_code", so paste and autofill work;length:4 to 10,charset: :numericor:alphanumeric,submit: trueto submit on the last character.
input_group(prefix:, suffix:, **options, &block)
A control with something joined to either end: input_group prefix: "https://", suffix: ".example.com" do … end. Text becomes a tinted addon; markup (a
button, an icon) is set in as it is.
toggle(label, pressed:, icon:, name:, …) and toggle_group(name:, value:, multiple:, label:, …, &block)
A button that is on or off, and a run of them joined into a segmented control
a form submits. With a name: a toggle is a checkbox drawn as a button and
needs no script; without one it is a button whose aria-pressed the script
flips (import "unmagic/components/toggle"). A group's options are native
radios, or checkboxes with multiple: true, so the arrow keys move the choice.
Live tables
A table whose rows a Turbo Stream keeps up to date needs the column definitions in
a place both the page render and a single broadcast row can reach, so they move
into a partial that takes a table local and does nothing but declare them:
<%# tasks/_columns.html.erb %>
<% table.column "Task", width: "30%" do |task| %>
<%= link_to task.kind, task %>
<% end %>
<% table.column "Status" do |task| %>
<span class="badge"><%= task.status %></span>
<% end %>The page renders the table with them, names the <tbody> so a stream can target
it, and gives the rows one id prefix:
<%= turbo_stream_from "tasks" %>
<%= table_for @tasks, columns: "tasks/columns", rows_id: "task_rows",
row_id: ->(task) { "task_#{task.id}" } do |table| %>
<% table.empty "Nothing has run yet." %>
<% end %>A broadcast renders one row from the same partial:
<%# tasks/_row.html.erb %>
<%= row_for task, columns: "tasks/columns", row_id: ->(task) { "task_#{task.id}" } %>class Task < ApplicationRecord
after_create_commit :broadcast_row
after_update_commit :broadcast_row
private
def broadcast_row
broadcast_action_to "tasks", action: :upsert, target: "task_rows",
attributes: { order: "desc" }, partial: "tasks/row", locals: { task: self }
end
endupsert is a Turbo Stream action this gem ships. Import it once:
// app/javascript/application.js
import "unmagic/components/upsert"It merges append and replace. If an element with the incoming id is already in
the document it is replaced in place, so a record that is both server-rendered and
broadcast never duplicates. Otherwise it is inserted at the position its id sorts
to, so out-of-order delivery still lands in order — which assumes time-ordered ids
(UUIDv7, ULID). order="desc" flips the comparison for a newest-first list.
row_id: matters over an STI collection. dom_id names the record's own
class, so subclasses get different prefixes and the rows sort by type rather than
by id. Give every row one prefix and the time-ordered id decides.
The companion details row is not broadcast: a stream action carries one element,
and the pair is a page-render concern.
Dialogs
There are three kinds, all built on the same panel: a modal whose content loads
from the server, a dialog that's already on the page, and a confirm that replaces
window.confirm. Each is a native <dialog>, so the browser handles trapping
focus, closing on Escape, and returning focus when it closes.
Import the behaviour once:
// app/javascript/application.js
import "unmagic/components" // every component, including the confirm dialog
// ...or only the ones you want
import "unmagic/components/modal"
import "unmagic/components/confirm"The shared modal
Mount it once, in the layout:
<%= modal_frame %>Point a link at any action with modal_link_to. It takes link_to's arguments:
<%= modal_link_to "Edit", edit_label_path(@label), class: button_classes %>The action renders a dialog. It's the same template whether the page opens in
the modal or is visited directly:
<%# labels/edit.html.erb %>
<%= dialog title: "Edit label", form: { model: @label } do |dialog, form| %>
<%= form.field :name, "Name", required: true %>
<% dialog.footer do %>
<button type="button" class="<%= button_classes %>" data-unmagic-dialog-close>Cancel</button>
<%= form.submit "Save" %>
<% end %>
<% end %>class LabelsController < ApplicationController
include Unmagic::Components::DialogResponder
def update
if @label.update(label_params)
refresh_or_redirect labels_path, notice: "Label saved."
else
render :edit, status: :unprocessable_content
end
end
endWhat that gets you:
- Opening: the dialog opens as soon as the request starts and shows a skeleton until the response arrives. Turbo's hover prefetch doesn't open it.
-
Failed loads: a network error, an error status or an empty
head :forbiddenall show an error panel with a Try again button, rather than Turbo's "Content missing". -
Failed saves: a
:unprocessable_contentrender replaces the form inside the dialog, errors and all. -
Successful saves:
-
refresh_or_redirect: the dialog stays up, with its submit button still saying "Saving…". It closes in the same render as the morph refresh, so the page repaints once, straight to its new state. - A stream response that doesn't refresh: the dialog closes as soon as it's read.
- A plain
redirect_to: the modal visits that page and closes.
-
- Multi-step forms: a response that renders back into the frame, such as the next step of a wizard, keeps the dialog open.
-
Closing: Escape, a click on the backdrop, or any
[data-unmagic-dialog-close]. A drag that starts in an input and ends over the backdrop doesn't count, so selecting text never throws the form away.
Build the form with form: rather than wrapping the dialog in form_with. On a
request aimed at the modal the dialog wraps itself in the modal's turbo frame, and
Turbo keeps only what is inside that frame. A form outside the dialog would be
dropped.
The dialog leaves overflow visible, so a dropdown inside a form isn't clipped. The
catch is that a dialog's content has to be short enough to fit the screen.
config.modal_frame_id (default "modal") renames the frame.
A dialog already on the page
<%= dialog_button "What's a scope?", dialog: "scopes_help", class: button_classes(:ghost) %>
<%= dialog_tag "scopes_help", title: "Scopes" do %>
<p>A scope limits what a token can do.</p>
<% end %>The block is yielded the panel, for a footer. Extra options go on the
<dialog>.
Confirm
Importing unmagic/components/confirm replaces the browser's confirm box for
data-turbo-confirm with a dialog in the same chrome:
<%= button_to "Delete", label_path(@label), method: :delete, class: button_classes(:danger),
form: { data: { turbo_confirm: "Delete this label?",
turbo_confirm_accept: "Delete",
turbo_confirm_variant: "danger" } } %>-
data-turbo-confirm-titlesets the heading. -
data-turbo-confirm-acceptsets the confirming button's label. -
data-turbo-confirm-variant="danger"makes that button red and focuses Cancel instead, so a destructive action is never one Enter away.
These attributes are read from the submitter, then the form. A link with
data-turbo-method doesn't pass them on: Turbo builds a form for it and copies
only data-turbo-confirm onto it. Use button_to when you need them.
Buttons
button(label = nil, variant = :default, **options, &block) is a button, a link
that looks like one, or a button_to form, from one call:
<%= button "Save", :primary, type: "submit" %>
<%= button "New label", href: new_label_path, icon: :plus %>
<%= button "Delete", :danger, href: label_path(@label), method: :delete, form: { data: { turbo_confirm: "Sure?" } } %>
<%= button "Close", :icon, icon: :x %>
<%= button "Saving", :primary, loading: true %>The variants are :default, :primary, :ghost, :danger and :icon; the
sizes size: :small and :large. href: renders a link, and with a method:
other than GET a button_to whose form: and params: pass through. icon: is
a symbol from the gem's Lucide set or your own markup, and leads the label; the
:icon variant shows only the icon and keeps the label for a screen reader and
a hover. loading: true disables the button and turns a spinner in the icon's
place. disabled: true disables a button, and marks a link aria-disabled and
takes it out of the tab order. block: true fills the width. Every button is at
least 44px tall where the pointer is coarse.
button_group(label:, orientation:, **options) { |group| … } joins buttons edge
to edge into one control; group.button takes the arguments above and
group.item anything else that belongs in the run. orientation: :vertical
stacks them.
button_classes(variant = :default, size: nil) returns the class string alone,
for link_to, button_to and form.submit.
Translations
The words in these components go through I18n, with English defaults. The confirm
dialog is built in the browser, so render <%= confirm_dialog_template %> once in
your layout to translate it.
| Key | Default |
|---|---|
unmagic.components.dialog.close |
Close |
unmagic.components.modal.loading |
Loading… |
unmagic.components.modal.error_title |
Couldn’t load |
unmagic.components.modal.error_message |
Something went wrong loading this. Check your connection and try again. |
unmagic.components.modal.retry |
Try again |
unmagic.components.confirm.title |
Are you sure? |
unmagic.components.confirm.accept |
Confirm |
unmagic.components.confirm.cancel |
Cancel |
Page building blocks
Plain Ruby and CSS, with no JavaScript.
page_header(title:, description:, back:, **options, &block)
<%= page_header title: @label.name, description: "Applied to 12 issues.",
back: { text: "Labels", path: labels_path } do |header| %>
<% header.badge "Archived", tone: :warn if @label.archived? %>
<%= modal_link_to "Edit", edit_label_path(@label), class: button_classes %>
<% end %>The block's output becomes the actions on the right. Its builder also takes
title { } and description { } for markup, and leading { } for something
before the title, such as an avatar. The actions drop below the title when there
isn't room beside it.
section(title, spacing: :normal, heading: :h2, **options, &block)
<%= section "Files" do |section| %>
<% section.aside { badge "12" } %>
<% section.actions { button "Upload", href: new_upload_path, size: :small } %>
<%= table_for @files do |table| %>...<% end %>
<% end %>A titled run of a page. aside is what qualifies the title, actions the
button that acts on the whole run, hard right; on a narrow screen it drops
under the heading. spacing: :tight closes up the first run on a page, :none
leaves it to you.
item(title:, description:, href:, mono:, **options, &block)
<%= item title: file.name, description: "#{file.content_type} · #{size}", href: file_path(file), mono: true do |item| %>
<% item.media { image_tag file.thumbnail } %>
<% item.meta { badge "Hidden" } %>
<% item.actions { menu … } %>
<% end %>A row about one thing, wherever a list shows it: media on the left, the title
over its description, meta flags beside the title, actions on the right, and
the block under the description. href: makes the title a link whose hit area
is the whole row, leaving the actions clickable on their own.
chart(series, labels:, type: :column, format: :count, title:, width:, legend:, table:, max:, label_format:, **options)
<%= chart [ { label: "Spent", values: spend_by_day } ], labels: days, format: :money, title: "Spend" %>
<%= chart [ { label: "CPU", values: readings } ], labels: times, type: :line, format: :percent, max: 100 %>A chart drawn as inline SVG with no script. labels: are the columns or the
points along a line; each series is { label:, values: } with values keyed by
label or an array in the same order, and an optional total: for the legend.
Columns stack where there is more than one series; on a line a nil value is a
gap. format: is :count, :money or :percent. Every column carries a
tooltip, and the numbers are laid out as a table under "As a table". Colours
come from the stylesheet by slot (--unmagic-chart-series-1 to -6), so a
series is the same colour on every chart. width: is the drawing's own width
(720), which scales to its box.
card(title:, href:, flush:, **options, &block)
<%= card title: "Members" do |card| %>
<% card.actions { link_to "Invite", new_invitation_path, class: button_classes(size: :small) } %>
Ada, Grace and Katherine can see everything.
<% card.footer { "3 of 5 seats used" } %>
<% end %>-
flush: trueremoves the body's padding. Atable_tagortable_forinside a flush card uses the card's border instead of drawing its own. -
card.header { … }is a bar of your own across the top, ruled off from the body, in place of a title: a row of tabs, a search field, a run of badges. Its options go on the<header>.border: falseandbackground: falsetake those away, for a card nested in another surface. -
href:makes the whole card one link, for a row that opens a record. Don't put other links or buttons inside it. - A card doesn't clip its content, so a dropdown inside one isn't cut off.
badge(content, tone: :neutral, **options)
<%= badge "Draft" %>
<%= badge "Overdue", tone: :bad %>Tones are :neutral, :good, :warn, :bad, :info (outlined) and :accent.
callout(title = nil, tone: :neutral, badge: nil, icon: true, **options, &block)
<%= callout "DNS isn't verified", tone: :warn, badge: "Pending" do %>
Add the TXT record below, then check again.
<% end %>This is for the state of something in place, such as a health check or a warning
above a form. Each tone except :neutral has an icon, and icon: false removes
it. A badge takes the callout's tone.
pagination(pager, window: 2, turbo_frame: nil, label: nil, **options)
Links to the pages around this one, for anything that pages like Pagy. The
pager answers previous, next and page_url; one that also answers page
and last gets numbered links, window: pages either side of this one with the
first and last always shown. On a phone the numbers give way to "6 of 12". One
page renders nothing. This is what table_for draws under itself through
config.pagination.
breadcrumbs(label: nil, **options, &block)
<%= breadcrumbs do |crumbs| %>
<% crumbs.link "Settings", settings_path %>
<% crumbs.current "GitHub" %>
<% end %>The trail of pages above this one. crumbs.link takes link_to's arguments;
crumbs.current is optional and last. On a phone only the last two crumbs
show. page_header takes the same block as its breadcrumbs part, where
back: goes; mono: true on a page header sets its title in monospace.
tree_view(label:, guides: true, **options, &block)
<%= tree_view label: "Files" do |tree| %>
<% tree.branch "app", icon: :folder do |app| %>
<% app.leaf "user.rb", href: blob_path("app/models/user.rb"), icon: :file_code, current: true %>
<% end %>
<% tree.leaf "Gemfile", href: blob_path("Gemfile"), icon: :file %>
<% end %>A nested, collapsible list of things inside other things: nested lists and
<details>, so Tab moves through the open rows and Enter or Space folds a
branch, with no script. label: names the root list and is required;
guides: false drops the line beside each level.
-
tree.branch(label, icon:, open:, meta:)yields a builder with the samebranchandleaf, to any depth. It starts open when a leaf inside it is current, unlessopen:says otherwise. A branch with nothing in it says "Empty". -
tree.leaf(label, href:, icon:, current:, meta:)is a link withhref:and plain text without; a block gives it markup in place oflabel.current: truemarks itaria-current="page". -
meta:is a short reading (a size, a count) kept whole at the end of the row while the label truncates.icon:is one of the gem's icons; none by default. - Other options on a branch or a leaf go on its row, and a string label is the
row's
title. Other options ontree_viewgo on the root<ul>. An empty tree renders nothing.
I18n: unmagic.components.tree.empty.
timeline(orientation: :vertical, label: nil, marker: :dot, skeleton: false, **options, &block)
<%= timeline label: "Order history" do |timeline| %>
<% timeline.event "Order placed", time: @order.created_at, icon: :shopping_cart %>
<% timeline.event "Payment failed", time: @payment.failed_at, icon: :credit_card, tone: :bad,
description: "Card declined" %>
<% timeline.event "Ada commented", time: @comment.created_at, time_format: :relative,
avatar: @comment.author.name do %>
<%= simple_format @comment.body %>
<% end %>
<% timeline.event "Delivered", time: "Friday", pending: true %>
<% end %>Things that happened, in order, joined by a line: an audit log, an order's
history, a deploy log, an activity feed, a roadmap. An <ol> with no script.
Events render in the order given; the gem doesn't sort. label: names the
list. orientation: :horizontal lays events side by side from 40rem and
falls back to vertical on a phone. marker: is what an event with no icon or
avatar shows, named as CSS's list-style-type: :dot by default, or the
event's position as :decimal, :lower_alpha, :upper_alpha, :lower_roman
or :upper_roman, for a process laid out in steps (an interview loop, an
onboarding).
-
time:is a Time or Date, drawn throughlocal_time_tagin the viewer's zone (time_format:is its format,:mediumby default and:datefor a Date), or a String such as"Q4", printed as it is. Markup prints too, sotime: badge("30 mins")works. -
The marker is a dot by default,
icon:in a circle, oravatar:(a name, or a Hash ofavataroptions such as{ name:, src: }) drawn small.tone:(:neutral,:good,:warn,:bad,:info) colours it. Markers are decoration, so the title has to say what happened. -
pending: trueis for what hasn't happened yet: a hollow marker, a dashed line into it, and "(upcoming)" for a screen reader. -
href:links the title anddescription:is a line under it. A block is the event's body: text, a code view, buttons. - Titles are not headings, so a long feed doesn't fill the page outline.
- Other options on an event go on its
<li>, and other options ontimelinego on the<ol>. A timeline with no events renders nothing, sotimeline(...).presence || empty_state(...)works.skeleton: truerenders three placeholder events.
I18n: unmagic.components.timeline.pending.
kbd(*keys, hotkey: nil, sequence: false, **options)
A key or a combination drawn as key caps: kbd "Esc", kbd :mod, "K",
kbd hotkey: "mod+shift+p", kbd "G", "I", sequence: true. Named keys draw
glyphs and carry their spoken names; :mod is ⌘ on Apple platforms and Ctrl
elsewhere.
empty_state(content = nil, title: nil, icon: nil, **options, &block)
<%= empty_state "Import a folder or drop files here.", title: "No files yet", icon: :folder do %>
<%= button "Import", :primary, href: new_import_path %>
<% end %>content says what's missing; title: heads it, icon: is a glyph above that,
and the block is the actions that fix it. All of it goes through
config.empty_state, so a table's blank slate and this one match.
<%= empty_state "No invitations yet." %>This is the same blank slate tables use. It renders through the empty_state
setting, so if your app replaces it, both change.
Times and tooltips
local_time_tag(time, format: :medium, compact: false, **options)
<%= local_time_tag comment.created_at, format: :relative %> <%# "3 hours ago" %>
<%= local_time_tag invoice.due_at, format: :date %> <%# "16 Sept 2026" %>
<%= local_time_tag event.starts_at %> <%# "16 Sept 2026, 4:33 pm" %>The browser formats the time with Intl, in the viewer's own locale and time
zone, so there's nothing to translate and no time zone to look up for the user.
Until the script runs, the server's own rendering (in Time.zone) shows instead.
format: |
Shows |
|---|---|
:short, :medium, :long, :full
|
A date and time, in increasing detail |
:date, :time
|
Just one of them |
:relative |
"now", "5 minutes ago", "yesterday", "in 3 days" |
- Relative times stay current. Every relative time on the page updates on one shared timer, once a minute, and the full time shows on hover.
- Days follow the calendar. A relative time counts days by midnight, so something from 11pm reads "yesterday" at 2am.
- Old dates settle. Past a week a relative time becomes a plain date and stops updating.
-
Compact form:
compact: trueshortens a relative time to "5m" or "3h". -
Blank values: a
niltime renders an em dash.
tooltip(content = nil, text:, placement: :top, term: nil, **options, &block)
Every page declares a <%= tooltip "canonical URL", text: "The address search engines treat as the original." %>.
<%= tooltip text: "Copy the key" do %>
<%= copy_button @key.secret %>
<% end %>- Hover and focus: the hint appears on hover after a short delay, or straight away on focus. Escape dismisses it.
- Never clipped: it's drawn in the browser's top layer, so a container's overflow can't cut it off. It flips to the other side when there isn't room and stays inside the screen.
-
Terms: plain text content gets a dashed underline and a help cursor, and
becomes focusable. Block content, such as a button or an icon, is left alone
and the hint describes its focusable element.
term:overrides that choice. -
Placement:
:topor:bottom.
Needs import "unmagic/components/time" and "unmagic/components/tooltip", or
import "unmagic/components".
Menus, tabs, copy buttons and code
menu(label = nil, align: :end, **options, &block)
On the Popover API since 0.6.0: the panel is in the top layer and the trigger
opens it without script. menu.section heads the items after it, menu.item
is a plain button for wiring, menu.disclosure folds a small form out in
place, and icon: leads a label. On a narrow screen the panel is a sheet along
the bottom.
<%= menu do |menu| %>
<% menu.link "Edit", edit_job_path(@job) %>
<% menu.divider %>
<% menu.button "Delete", job_path(@job), method: :delete, tone: :danger,
form: { data: { turbo_confirm: "Delete this job?" } } %>
<% end %>-
Built on
<details>: the menu opens even before its script loads. - Closing: an outside click, Escape, choosing an item, or navigating away with Turbo all close it.
- Keyboard: the arrow keys, Home and End move between items. Opening the menu with the keyboard focuses the first item.
-
Trigger: with no label it's a ⋮ icon button labelled "More actions"
(
unmagic.components.menu.label). Pass a label for a text button with a chevron. -
Items:
linkandbuttontakelink_to's andbutton_to's arguments, plustone: :danger. -
Alignment:
align: :startlines the panel up with the trigger's left edge instead of its right.
sidebar(id:, label: nil, collapse_below: :lg, **options, &block) and sidebar_toggle(id)
<%= sidebar id: "app_nav" do |nav| %>
<% nav.header { link_to image_tag("logo.svg", alt: "Acme"), root_path } %>
<% nav.section do |s| %>
<% s.link "Inbox", inbox_path, icon: :messages_square, badge: @unread %>
<% end %>
<% nav.section "Settings", collapsible: true do |s| %>
<% s.link "Members", members_path %>
<% end %>
<% end %>
<%= sidebar_toggle "app_nav" %>The navigation down the side of an app. One <nav> for both widths: a popover
sheet from the edge below collapse_below: that the toggle opens with no
script, inline above it. Needs import "unmagic/components/sidebar".
navbar(label: nil, sticky: false, collapse: :md, **options, &block)
The bar across the top: nav.brand, nav.link … current:, nav.actions. On a
narrow screen the links fold behind a menu button on a <details>. Needs
import "unmagic/components/navbar".
combobox_tag(name, collection:, value:, text:, multiple:, selected:, src:, …), form.combobox and combobox_results
<%= form.field :owner_id, "Owner", as: :combobox, collection: @members, text: :name %>
<%= form.combobox :label_ids, collection: @labels, text: :name, multiple: true %>
<%= combobox_tag "owner_id", collection: [ @owner ].compact, text: :name, src: search_members_path %>A text input that filters a list of options, choosing one or several. The value
is a hidden input (name[] with a chip per choice when multiple), so a form
submitted before script loads keeps it. With src: the rest of the list is
fetched as you type from an action answering ?q= with combobox_results. A
block records rich options: combobox.option value, label:, keywords: { markup }.
Needs import "unmagic/components/combobox".
command_palette(id:, hotkey: "mod+k", src:, …, &block), command_palette_button and command_palette_results
Render once in the layout. palette.group "Go to" { |g| g.link …; g.button … };
each command holds a real link or button_to form, so frames and confirms
work. ⌘K opens it; src: fetches more commands as you type. Needs
import "unmagic/components/command_palette".
context_menu(for:, label: nil, **options, &block)
The same panel as menu, opened at the pointer on a right-click or a long press
on the element with the id for:, or at its corner on Shift+F10. The element
can sit anywhere, so a table row can have one. Keep the actions reachable
elsewhere too: without script the browser's own menu shows.
popover(label = nil, title: nil, placement: :bottom, align: :start, size: :default, **options, &block)
<%= popover "Rename", title: "Rename" do |popover| %>
<%= form_with model: @job, builder: Unmagic::Components::FormBuilder do |form| %>
<%= form.field :name, "Name" %>
<% popover.footer { form.submit "Rename" } %>
<% end %>
<% end %>A small panel of content behind a trigger. The label is a text trigger with a
chevron; popover.trigger { … } is a trigger of your own. The panel is a
popover="auto" dialog in the top layer, placed against the trigger by
unmagic/components/position and kept there while open; on a narrow screen it
is a sheet along the bottom. Needs import "unmagic/components/popover".
disclosure(summary = nil, open: false, **options, &block) and accordion(id: nil, exclusive: false, **options, &block)
A summary that folds a panel open, on <details>, and a run of them with
exclusive: true opening one at a time through the platform's own
<details name>. No script.
scroll_area(axis: :y, max_height: nil, label: nil, shadows: true, **options, &block)
A box that scrolls, with shadows where there is more to see. label: makes it a
named region a keyboard can reach and scroll. Knob: --unmagic-scroll-area-max-height.
Drawers
dialog_tag … side: :end (or :start) and dialog … side: open the panel as a
drawer along that edge of the screen; pass the same side: to modal_link_to
so the shared modal's skeleton opens there too. On a phone every dialog is a
sheet from the bottom. A dialog whose panel says aria-busy="true" refuses
Escape and the backdrop until it isn't.
tabs(id: nil, **options, &block)
<%= tabs id: "response" do |tabs| %>
<% tabs.tab "Body" %>
<% tabs.tab "Headers" %>
<% tabs.tab "Preview", disabled: "HTML only" %>
<% tabs.panel do %>...<% end %>
<% tabs.panel do %>...<% end %>
<% end %>- Accessible markup: the server renders the full tab pattern with its ARIA roles, so only the selected tab is in the tab order. The arrow keys, Home and End switch tabs.
-
Panels: each panel pairs with an enabled tab, in order. A tab with
disabled:shows its reason and takes no panel, andactive: truepicks the tab shown first. -
Icons:
icon:leads a label with a symbol from the gem's Lucide set (:folder) or rendered markup from your own icons. -
Styles:
style: :segmented(the default) is an inset track with the chosen tab raised out of it.style: :baris a row of pill tabs with no track, for a bar across a card or a page; on a narrow screen it scrolls sideways rather than wrapping, and the chosen tab is kept in view. -
Remembering the choice: give the tabs an
id:and the chosen tab is kept for that page until the browser tab closes, even across a morph refresh. Each change firesunmagic-tabs:change.
Give each tab an href: instead of panels and you get a bar of links to separate
pages. It's rendered on the server with no script, and active: true marks the
current page:
<%= tabs do |tabs| %>
<% tabs.tab "All", href: invitations_path, active: @status.nil? %>
<% tabs.tab "Replied", href: invitations_path(status: "replied"), active: @status == "replied" %>
<% end %>panel(id: nil, flush: false, **options, &block)
A card with switcher buttons across its top bar and the open one's content below: a README beside the brief, a file's source beside its preview.
<%= panel id: "notes" do |panel| %>
<% panel.tab "README", icon: :book_open %>
<% panel.tab "Agents", icon: :bot %>
<% panel.panel { markdown @readme } %>
<% panel.panel { markdown @agents } %>
<% end %>The tabs are tabs' own, in the bar style: the same labels, icons, disabled:
reasons and active: choice, switched in the page. Give each an href: instead
and they are pages of their own: the server draws the open one, the block is its
body, and the address names the tab.
<%= panel flush: true do |panel| %>
<% panel.tab "Source", href: file_path(@file, view: :source), active: @view == :source, icon: :code %>
<% panel.tab "Preview", href: file_path(@file, view: :preview), active: @view == :preview, icon: :eye %>
<%= render "files/#{@view}", file: @file %>
<% end %>flush: true drops the body's padding, for a code view or a table that runs to
the edges. Other options go on the outer element. Needs
import "unmagic/components/tabs" for in-page tabs.
copy_button(text = nil, from: nil, label: nil, **options, &block)
<%= copy_button @key.secret %>
<code id="install_command">bundle add unmagic-components</code>
<%= copy_button from: "install_command" do %>Copy command<% end %>- Feedback: the copy icon briefly turns into a check, and screen readers hear "Copied".
-
Copying from the page:
from:copies the value of an input, or the text of any element, with that id at the moment of the click. The text doesn't need to be repeated in an attribute. -
Labels and options: without a block it's an icon button labelled
label:("Copy"). Other options go on the<button>. -
Events: it fires
unmagic-clipboard:copy, orunmagic-clipboard:errorwhen the browser refuses the write, so you can show a toast.
Needs import "unmagic/components/menu", "unmagic/components/tabs" and
"unmagic/components/clipboard", or import "unmagic/components".
code_view(source, language: nil, lines: false, wrap: true, max_height: nil, copy: true, label: nil, id: nil, **options)
A block of source to read or copy: a file, a payload, a command. Coloured with
Rouge, wrapping long lines, with a copy button in the corner that appears on
hover and stays put on a touch screen. Just the code; put it in a panel for a
switcher or a card for a title.
<%= code_view @file.source, language: @file.language %>
<%= code_view backtrace, language: :plaintext, lines: true, max_height: "20rem" %>
<%= code_view command, language: :shell, wrap: false, copy: false %>language: is a name Rouge knows (:json, "ruby", :erb) or a lexer;
unknown or nil is plain text. lines: true numbers the lines, and the numbers
are drawn rather than written, so a copy leaves them behind. wrap: false
scrolls sideways instead of wrapping. max_height: is a CSS length past which
the block scrolls, and a block that can scroll is a focusable region named
label: ("Code"). id: names the wrapper; the <code> is "#{id}_code".
Colouring goes through config.highlight; config.code_block, which tool
payloads and prose code blocks render through, is a code_view by default.
Knob: --unmagic-code-view-max-height. I18n: unmagic.components.code_view.label.
Needs import "unmagic/components/clipboard" for the button.
Separators, progress and spinners
separator(label = nil, orientation: :horizontal, **options)
A rule between two things: a plain <hr>, one with a word on it (separator "or"), or orientation: :vertical upright between items in a row.
progress(value = nil, max: 100, tone: :neutral, size: :medium, label: nil, indeterminate: false, **options)
A bar filled to value out of max, in a tone (:neutral, :good, :warn,
:bad, :info) and a size (:small, :medium, :large), named label:
("Progress"). With indeterminate: true, or no value, it sweeps; under reduced
motion it pulses. I18n: unmagic.components.progress.label.
spinner(text = nil, label: nil, size: :medium, **options)
A ring that turns while something loads. Visible text is the label and sits
beside the ring; otherwise label: ("Loading…") is read but not seen, and
label: false makes the ring decorative for a control that already says what is
happening. Sizes :small, :medium, :large. It pulses rather than turning
under reduced motion. I18n: unmagic.components.spinner.label.
Skeletons
Skeletons block out an interface while it loads. skeleton yields a builder,
like form_for does. You arrange its shapes with your own markup:
<%= skeleton label: "Loading candidate" do |s| %>
<div class="flex items-center gap-3">
<%= s.circle size: "3rem" %>
<div class="flex-1">
<%= s.text width: "60%" %>
<%= s.text width: "40%" %>
</div>
</div>
<%= s.text lines: 2 %>
<% end %>| Shape | Stands in for |
|---|---|
s.text(width:, lines:) |
A line of text in the surrounding font. lines: makes a paragraph with a shorter last line. |
s.circle(size:) |
An avatar or round icon (default 2.5rem). |
s.block(height:, width:) |
An image, chart or map (default 8rem tall, full width). |
s.button(size:, width:) |
A button_classes button, size: :small or :large. |
s.badge(width:, count:) |
A badge's pill. count: makes a row of chips that doesn't wrap. |
s.icon |
An icon-only button (button_classes(:icon)), square. |
s.item(avatar:, description:, width:) |
A title over a smaller, shorter description, like item. avatar: :small, :medium or :large puts an avatar-sized circle first; description: false leaves the title alone. |
-
Nothing moves when content arrives. Each shape is sized from what it
replaces: a text line fills exactly one line of the font it sits in, so a line
inside an
<h1>is heading-sized, and a button shape matches a real button's height. -
Styling: every shape also takes
class:andstyle:. -
Screen readers: the shapes are hidden from them. They hear
label:("Loading…",unmagic.components.skeleton.loading) once for the whole group.
Outside a block, the same shapes are skeleton_text, skeleton_circle,
skeleton_block, skeleton_button, skeleton_badge, skeleton_icon and
skeleton_item.
Some components render a skeleton version of themselves, so it matches the real one:
<%= detail_list skeleton: true do |list| %>
<% list.item "Created" %>
<% list.item "Salary" %>
<% end %>
<%= page_header skeleton: true %>
<%= card title: "Members", skeleton: true %>Anything you pass still renders for real, and the rest becomes shapes:
-
detail_listkeeps its labels and shows a bar for each value. -
page_headershows a title bar, a description line and a button.description: falseleaves out the line. -
cardkeeps its title. An empty body becomes three lines, or you can pass a block to block out the body yourself.
A skeleton pairs well with a lazy Turbo Frame:
<%= turbo_frame_tag "stats", src: stats_path, loading: :lazy do %>
<%= detail_list skeleton: true do |list| %>...<% end %>
<% end %>Toasts
Mount them once, in the layout:
<%= flash_toasts %>Then set a flash as usual:
redirect_to labels_path, notice: "Label saved."The toast shows in the top-end corner (top-right in LTR) and dismisses itself after five seconds
(duration:, in milliseconds). It also has a dismiss button.
- Holding it open: hovering or focusing a toast pauses its countdown. Letting go resumes it, with at least a second left.
-
Staying put: the stack is
data-turbo-permanent, so a toast on screen survives a Drive visit or a morph refresh. A flash set beforerefresh_or_redirectarrives with the refresh. -
Tone: set by
config.flash_tones.noticeandsuccessare good,alertanderrorare bad,warningis warn, and anything else is info. A bad toast interrupts screen readers withrole="alert"; the rest are announced politely. -
Filtering: pass the flashes to show when some aren't meant for the user:
flash_toasts flash.to_hash.except("copy_link").
To show a toast from a stream response, where there's no redirect to carry a flash:
render turbo_stream: turbo_stream.toast("Invitation sent.")
render turbo_stream: turbo_stream.toast("Couldn't reach Slack.", tone: :bad)Each streamed toast can override its duration, including staying up until dismissed:
render turbo_stream: turbo_stream.toast("Review your changes.", duration: 0)
render turbo_stream: turbo_stream.toast("Saved.", duration: 2000, position: :bottom_end)Set flash_toasts duration: 0 to make manual dismissal the default for the entire
mount. Individual streamed durations still override it.
| Option | Default | Values |
|---|---|---|
tone: |
:good |
:good, :warn, :bad, :info, :neutral, :accent, :inverted
|
title: |
none | Text above the message |
close_button: |
true |
false hides the ×; timers and custom dismiss actions still work |
icon: |
tone icon | Bundled icon name; false hides it |
duration: |
mount duration (5000ms) | Nonnegative milliseconds; 0 is sticky |
position: |
mount position (:top_end) |
:top_start, :top, :top_end, :bottom_start, :bottom, :bottom_end
|
width: |
:short (384px) |
:short, :long (560px), or a positive pixel integer |
layout: |
:horizontal |
:horizontal, :vertical (actions below content) |
target: |
"unmagic_toasts" |
The id of a flash_toasts mount |
Horizontal actions move below the text when space is tight. Use width: :long
for a message with two actions beside it. Vertical actions align below the text.
Start and end follow the reading direction. Widths clamp to the screen or scoped
panel. The per-instance --unmagic-toast-width CSS property carries the width.
Other HTML options (id:, class:, data:, etc.) go on the toast root.
In a .turbo_stream.erb response, capture actions or custom content:
<%= turbo_stream.toast("Your workspace is ready.", title: "All set", duration: 0,
layout: :vertical) do |toast| %>
<% toast.leading do %>
<%= avatar "Alex Morgan", size: :small %>
<% end %>
<% toast.actions do %>
<%= button_tag "Got it", type: "button", class: button_classes,
data: { unmagic_toast_dismiss: "" } %>
<% end %>
<% end %>leading replaces the default icon; an explicit icon: takes precedence.
body replaces the standard title and message with captured markup. actions
accepts ordinary Rails links, buttons, and forms. Default button_classes actions
inherit the toast tone; explicit button variants keep their own styling. Add data-unmagic-toast-dismiss
to an action to close its toast. Dismissing a toast does not undo a server action;
wire application actions to their own routes. Toasts include a labelled close
button by default, including custom bodies. Set close_button: false to hide it.
For sticky toasts (duration: 0), provide a custom dismiss action when hiding the ×.
To contain notifications within a panel, give it a positioned ancestor and a named mount, then stream to that target:
<div class="relative min-h-80">
<%= flash_toasts({}, id: "project_toasts", scoped: true, position: :bottom_end) %>
</div>render turbo_stream: turbo_stream.toast("Project saved.", target: "project_toasts")flash_toasts accepts id:, position:, duration:, scoped: and extra HTML
options on the mount. A scoped mount stays inside its panel and does not use the
top layer. Render empty flashes ({}) for secondary mounts to avoid duplication.
JavaScript can use the same mount directly, without Turbo or a server request:
import { toast } from "unmagic/components/toasts";
const id = toast.show("Preparing your export…", { duration: 0, tone: "info" });
toast.update(id, { message: "Export ready.", tone: "good", duration: 5000 });
toast.dismiss(id);
toast.dismissAll(); // Only the default mount.show returns a logical string id; optionally supply id yourself. Active ids
must be unique across mounts. update and dismiss return false for an unknown,
expired or leaving toast. dismissAll({ target: "project_toasts" }) returns the
number dismissed from that mount. The <unmagic-toasts> element itself exposes
show, update, dismiss, and dismissAll, scoped to that element.
JavaScript supports title, tone, duration, position, width, layout,
closeButton, target, and actions. Values match the Ruby API; keys use
camelCase. icon: false hides the default icon and icon: null restores it.
Custom icon names, HTML strings and body/leading slots remain Rails features.
Message/title/action labels are plain text. At least a message or title is
required. Invalid options, missing mounts and duplicate ids throw an error.
toast.show("Archive this item?", {
duration: 0,
closeButton: false,
actions: [
{ label: "Archive", onClick: async ({ id }) => { await archiveItem(); } },
{ label: "Dismiss" },
],
});An action dismisses after its callback succeeds, or immediately if there is no
callback. Set dismiss: false on the action to keep the toast. Promise-returning
callbacks disable their button and pause the timer until completion. Rejection
keeps the toast, re-enables the action and emits unmagic-toast:action-error;
the app can use that event to display an error. Other close controls still work
while an action runs. Completion after dismissal never brings a toast back.
Updates change only supplied fields and retain the same root node. An omitted
duration preserves the remaining countdown; supplying duration restarts it,
including 0 to make it sticky. Hover, focus and pending actions pause timers.
Clear the title with title: null, or actions with actions: []. Changing the
position moves the toast within its mount; target and id cannot be updated.
Updating title/message on a Rails custom body switches it to the standard text
layout. Other updates preserve captured server content and actions.
To address a Rails-created toast from JavaScript, give it toast_id::
render turbo_stream: turbo_stream.toast("Export queued.", toast_id: "export-result", duration: 0)toast.update("export-result", { message: "Ready.", duration: 5000 });toast_id: is separate from the existing root HTML id:. Incoming templates
with duplicate logical ids are discarded and emit unmagic-toast:error.
Events bubble from the mount: unmagic-toast:show, unmagic-toast:update, and
unmagic-toast:dismiss include { id }; dismiss also includes
reason: "timeout" | "close" | "action" | "api". Both error events include
{ id, error }. Dismiss fires once at the start of the exit animation.
document.addEventListener("unmagic-toast:action-error", ({ detail: { id, error } }) => {
toast.update(id, { message: error.message, tone: "bad", duration: 0 });
});Each mount includes inert Ruby-rendered prototypes, so client-created toasts use the same markup, translated close label, and icons. Active callbacks survive Turbo visits and morphs; stale cache snapshots do not resurrect dismissed toasts. Re-resolve element references after navigation, or use the imported facade.
The stack sits in the browser's top layer, so a toast shows above an open dialog. Existing toasts are raised again when a dialog opens, even if no new toast arrives. It can't be hovered or dismissed until that dialog closes, because a modal dialog makes the rest of the page inert, but it still times out on its own.
Needs import "unmagic/components/toasts", which import "unmagic/components"
includes. The dismiss button's label is unmagic.components.toast.dismiss
("Dismiss").
Sortable lists and boards
Drag-and-drop ordering, by pointer or keyboard. The server half — ranks, signed keys and the endpoint — is the unmagic-sortable gem, which configures these components when it's installed:
class Card < ApplicationRecord
include Unmagic::Sortable
sortable_within :board_id
sortable_column :column_id
endsortable_list(namespace: nil, params: {}, url: nil, orientation: :vertical, label: nil, **options, &block)
<%= sortable_list label: "Interview steps", class: "flex flex-col gap-2" do |list| %>
<% @steps.ordered.each do |step| %>
<%= list.item(step) do %>
<%= sortable_handle label: "Move #{step.name}" %>
<%= step.name %>
<% end %>
<% end %>
<% end %>-
Items:
list.item(record)takes its key and rank fromconfig.sortable_item;key:andrank:set them directly, andlabel:is what a screen reader calls it. -
Handles: with a
sortable_handlein an item, only the handle drags, so the rest stays clickable, and the handle is the keyboard stop. Without one, the whole item drags once the pointer moves, and the item takes focus. -
Touch: a finger lifts an item with a long press, so a swipe still scrolls
and a tap still taps. A
sortable_handlegrip drags at once. -
Between lists: lists sharing a
namespace:exchange items. A drop posts the destination list'sparams:. -
Keyboard: Space or Enter picks an item up. The arrows along the list move
it (
orientation:decides which) and the arrows across move it to the next list. Space drops it; Escape, or tabbing away, puts it back. Each step is announced, and Escape also cancels a pointer drag. - Scrolling: a drag near the edge of anything scrollable scrolls it.
-
The drop fires a cancelable
unmagic-sortable:movewith{ key, original, prev, next, params }, then PATCHesurl:(config.sortable_url) withmoved,original,prev,nextand the params. Without a url, the event is all there is. -
Styling:
sortable-dragging:,sortable-placeholder:,sortable-lifted:,sortable-active:andsortable-over:variants decorate the drag states.
Needs import "unmagic/components/sortable". I18n under
unmagic.components.sortable: handle, instructions, picked, moved,
dropped and cancelled, with {item}, {list}, {position} and {count}.
board(id:, url: nil, columns_url: nil, sortable_columns: true, label: nil, column_height: nil, **options, &block)
<%= board id: "roadmap" do |board| %>
<% @columns.each do |column| %>
<% board.column column, title: column.name, params: { column_id: column.id } do |col| %>
<% col.actions { menu { |m| m.link "Rename", edit_column_path(column) } } %>
<% column.cards.ordered.each { |card| col.card(card) { render card } } %>
<% col.add url: cards_path, field: "card[title]", params: { "card[column_id]" => column.id } %>
<% end %>
<% end %>
<% board.add_column url: columns_path, field: "column[name]" %>
<% end %>-
Moving: cards move within and between the board's columns, posting the
column's
params:. Columns move along the board by their header, and the grip in it is their keyboard stop. On a phone, press and hold a card or a header.sortable_columns: falsefixes them in place. -
Forms:
col.addandboard.add_columnopen into a one-field form. Enter submits it, Escape closes it, and it stays open after adding, so several can be added in a row. -
Size:
column_height:(default75dvh) is how tall a column grows before its cards scroll. -
Where drops go:
url:is where they post;columns_url:sends column drops elsewhere.
Needs import "unmagic/components/sortable" and "unmagic/components/board".
I18n under unmagic.components.board: label, add_card, add_card_submit,
add_column, add_column_submit, cancel, move_column and cards.
Avatars
avatar(name, src: nil, size: :medium, shape: :circle, tint: true, skeleton: false, **options)
<%= avatar "Ada Lovelace" %>
<%= avatar @user.name, src: @user.avatar_url, size: :large %>
<%= avatar "Acme Ltd", shape: :square %>
<%= avatar_group max: 3, size: :small do |group| %>
<% @members.each { |member| group.avatar member.name, src: member.avatar_url } %>
<% end %>- Initials come from the first and last words ("Ada Lovelace" is "AL") and always render. The image sits on top of them, so a broken image shows the initials without any script.
-
Tint: the initials sit on one of six palette tints picked from the name
with
unmagic-color's stable string hash, so a person keeps their colour on every page and every server.tint: falseis neutral. OverrideUnmagicAvatar--tint-1…-6to recolour them. -
Sizes:
:small,:mediumand:large(1.5, 2 and 2.5rem).skeleton: truerenders a skeleton circle of the same size. -
Accessibility: an avatar is
role="img"named for the person. Pass"aria-hidden": truewhen the name is written beside it. -
Groups:
avatar_groupsets the size and shape for every avatar in it, and collapses pastmax:into a "+N" whose title names the rest.
I18n: unmagic.components.avatar.group ("%{count} people") and
unmagic.components.avatar.more ("+%{count}").
Elapsed times
elapsed_tag(time, direction: :up, **options)
<%= elapsed_tag tool_call.started_at %> <%# 3m 5s, and counting %>
<%= elapsed_tag session.expires_at, direction: :down %> <%# 42s, and falling %>A clock that counts up from a moment the server named, a second at a time, or
down to one, stopping at zero and firing unmagic-elapsed:end. Work that takes a
minute and work that has hung look the same behind a spinner; a number that keeps
moving is the difference.
The server renders the current reading, so it is right before the script loads.
Readings are whole seconds — 2s, 3m 5s, 1h 4m 2s — and a settled duration
under a second is milliseconds (640ms). The words are the
unmagic.components.elapsed.* keys (milliseconds, seconds, minutes,
hours); the element writes the English forms as it ticks. A blank time renders
an em dash. Needs import "unmagic/components/elapsed".
Messaging
Components for showing people talking to each other, or to a machine: a
thread of messages with who said it, when, what came with it, how others
reacted and what can be done to it. One family renders an iMessage-style chat,
a Slack-style channel, an email back-and-forth and an AI chat
(ai_chat_message is built on message).
<%= message_thread id: "messages", live: true, label: "Chat with Ana" do %>
<%= message_separator "Today" %>
<%= message "Are we still on for Friday?", author: "Ana Silva", avatar: true, time: message.sent_at %>
<%= message own: true, time: reply.sent_at do |m| %>
<% m.status :read, at: reply.read_at %>
Yes, 2pm.
<% end %>
<%= message_typing "Ana Silva", avatar: true %>
<% end %>message_thread(id: nil, live: false, label: nil, **options, &block)
The container: the block is the content, one message (or separator, or typing
indicator) after another, and the thread spaces them, tightening the gap before
a continued: message and widening it before a separator. live: true makes
it a polite role="log" named by label:, for a conversation broadcasts land
in; without it there is no role and no label. Always renders, empty or not.
message(content = nil, variant: :bubble, own: false, author: nil, avatar: nil, time: nil, time_format: nil, continued: false, edited: false, collapsible: false, open: true, id: nil, **options, &block)
<%= message variant: :row, id: dom_id(message), author: message.author.name, avatar: true,
time: message.sent_at, edited: message.edited? do |m| %>
<% m.quote parent.body, author: parent.author.name, href: message_path(parent) %>
<% m.attachments { |files| files.file "notes.md", size: 1_240, url: "…" } %>
<% m.reactions { |r| r.reaction "👍", count: 2, url: react_path(message) } %>
<% m.footer { link_to "3 replies", thread_path(message) } %>
<% m.actions { |bar| bar.action "Reply", reply_path(message), icon: :reply } %>
<%= Markdown.render(message.body) %>
<% end %>
<%= message variant: :email, author: "Ana Silva", avatar: true, time: mail.sent_at, collapsible: true, open: false do |m| %>
<% m.meta "to Ben Reyes, Chloe Park" %>
<%= mail.html_body %>
<% end %>-
Variants:
:bubble(a chat),:row(a channel or thread) and:email(a letter in a card), from one markup.own: trueputs a bubble on the right on the dark surface; a row or an email only gains the class. -
Header:
author:names the sender;avatar: truedraws their initials (it needsauthor:), or takes a name or a Hash ofavataroptions;time:goes throughlocal_time_tagwithtime_format:(:time, or:mediumfor an email), and a string prints as it is. -
Continued:
continued: truefor a message from the same sender as the one above, moments later: no name or avatar (the avatar's column stays), a tighter gap, joined corners. Still passauthor:; it stays in the DOM for screen readers. A continued row shows its time in the gutter on hover. -
Body: the content or the block. A bubble keeps the line breaks typed into
it; a row or an email is prose (
UnmagicProse), your rendered HTML. -
Parts:
m.meta(a line under the author),m.quote(text, author:, href:)(what this replies to),m.attachments,m.reactions,m.status(state, at:)(:sending,:sent,:delivered,:reador:failed, a word and a glyph),m.footer(your own footer content) andm.actions(**options).attachments,reactionsandactionsbuild the matching component when their block takes an argument ({ |bar| … }gets amessage_actionsfor thisid:) and take markup when it doesn't. The actions sit beside a bubble, float over a row on hover, and sit under an email. -
Collapsible:
collapsible: truefolds the message into a<details>whose summary is the header and a line of the body,open: falseto start shut. Meant for:email. -
State is on the root:
data-status, anddata-optimisticfor a message drawn before the server has it.
I18n: unmagic.components.message.you ("You"), .edited ("Edited") and
.status.sending, .sent, .delivered, .read and .failed.
message_actions(for:, reveal: :hover, label: nil, **options, &block)
The controls on a message. bar.copy(text) is a copy_button;
bar.action(label, url, icon:, method:, confirm:) is an icon-only link for a
GET and a button_to otherwise; bar.control { } is anything else (a menu,
an email's "Reply" text button). A toolbar with one Tab stop and arrow keys
between the controls (<unmagic-toolbar>). reveal: :hover shows it when the
message is hovered or the bar focused, and always on a touch screen and under
an email; it is never hidden from the keyboard. ai_chat_action_bar is the
same helper under its old name. Needs import "unmagic/components/toolbar".
I18n: unmagic.components.message.actions.label ("Message actions"), falling
back to the old unmagic.components.ai_chat.action_bar.label.
message_attachments(align: :start, **options, &block)
The files that came with a message, as tiles: files.file(name, size:, url:, thumbnail:). align: :end gathers them on the right, under an own bubble; on a
bubble they show above it. ai_chat_attachments is the same helper under its
old name, and ai_chat_dropzone draws the same tile for a file on its way in.
message_reactions(label: nil, **options, &block)
r.reaction(emoji, count:, reacted:, names:, url:, method:) is a pill: with
url: a button_to that toggles it, pressed when reacted:, whose response
re-renders the list; without, a pill that only shows. names: lists who in its
title. r.add(**options) is an icon button labelled "Add reaction" that takes
the options (popovertarget:, data:) to wire it to your picker, or
r.add { } is a control of your own. I18n:
unmagic.components.message.reactions.label, .add and .reacted.
message_separator(label = nil, time: nil, unread: false, **options)
A line across the thread. A label prints as it is; time: is a date through
local_time_tag; unread: true marks where the new messages start, in blue,
with "New messages" unless there's a label. I18n:
unmagic.components.message.separator.unread.
message_typing(who = nil, avatar: nil, **options)
Three dots in a bubble. The name shows above them and reads as "Ana Silva is
typing", or "Typing". It claims no live region: a live thread announces its
arrival; outside one, pass role: "status". Under reduced motion the dots pulse
instead of rising. I18n: unmagic.components.message.typing.named and
.anonymous.
AI chat
Components for rendering an agent's work, in the spirit of assistant-ui but server-rendered: the host renders each turn, Turbo Streams keep it live, and small custom elements pace the reveal.
<%= turbo_stream_from @chat %>
<div id="transcript" class="h-[70dvh] overflow-y-auto">
<%= ai_chat id: "entries", scroller: "#transcript" do |chat| %>
<% chat.welcome do %>
<%= ai_chat_welcome heading: "What can I help with?" do |welcome| %>
<% welcome.suggestion "Who hasn't replied yet?" %>
<% end %>
<% end %>
<%= render @chat.entries %>
<% end %>
</div>
<%= form_with model: Message.new, url: chat_messages_path(@chat), id: "composer" do |form| %>
<%= ai_chat_composer form: form, field: :content, state: @chat.run_state do |composer| %>
<% composer.optimistic id: "message[client_id]", container: "#entries" %>
<% end %>
<% end %>
<%= ai_chat_stop_form chat_stop_path(@chat) %><%# app/views/messages/_message.html.erb %>
<%= ai_chat_message role: message.role.to_sym, id: dom_id(message), streaming: message.pending? do %>
<%= message.user? ? message.content : Markdown.render(message.content) %>
<% end %>They need Turbo and import "unmagic/components", or the modules named below.
How the pieces fit
-
Entries are upserted by id. Broadcast each entry with the gem's
upsertaction into the transcript's id. A record already on the page is replaced in place; a new one is inserted in id order.broadcast_action_to chat, action: :upsert, target: "entries", partial: "messages/message", locals: { message: self }
-
The question is drawn before the server has it.
composer.optimisticrenders an<unmagic-uuid-input>and a template of the question. On submit,<unmagic-optimistic>draws the question, dimmed, under the minted id. Create the record under that id (message[client_id]) and its broadcast replaces the placeholder. -
Replies stream as whole renders. While a reply is being written, send the whole of it so far, rendered, to the body's id.
<unmagic-streaming-markdown>works out what's new and reveals it at the model's own average pace, so bursts read as one stream. When the turn settles, upsert the whole message. The new element picks up the reveal where the old one left off.broadcast_action_to chat, action: :stream_markdown, target: "#{dom_id(self)}_content", html: Markdown.render(content)
-
The gem never parses Markdown. Hand it your own rendered, sanitised HTML.
UnmagicProsestyles it, and code blocks, like tool payloads, go throughconfig.code_block. -
Only the composer's button changes with the turn's state. Redraw it with
ai_chat_composer_actionso a half-typed draft survives. -
Amber means waiting on a person. A question, a permission request or a plan step parked on someone is the one state that won't move on its own, so it is the one in colour.
ai_chat(id:, scroller: nil, follow: true, scroll_to_latest: true, **options, &block)
The scrolling region turns are rendered into: a polite role="log". It renders
no entries of its own; use a partial per entry type.
-
Following: it keeps the scroller (the page, or
scroller:) at the bottom while the reader is there, and stops when they scroll up. A jump-to-latest button shows meanwhile (scroll_to_latest: falsefor none).follow: falserenders a bare log. -
Spacing: turns are separated by a gap that hidden entries don't earn. Mark
hidden entries between two tool calls (a tool result's anchor) with
data-ai-chat-timeline="gap"so the run stays joined. -
Welcome:
chat.welcomeshows while there are no entries, and hides as the first one arrives.
Needs import "unmagic/components/autoscroll". I18n:
unmagic.components.ai_chat.transcript.label ("Conversation") and .latest
("Jump to latest"). The button's dock is sticky at bottom-4; lift it with
.UnmagicAIChat__latest { bottom: … } if a sticky composer covers it.
ai_chat_message(content = nil, role:, id: nil, streaming: false, final: false, optimistic: nil, **options, &block)
One turn, built on message: a user's turn is an own bubble of plain text with
its line breaks kept; an assistant's is an unbubbled row of prose.
-
The body of an assistant turn with an id is an
<unmagic-streaming-markdown id="#{id}_content">.streaming: trueshows a thinking spinner until the first flush and marks the body busy.final: truerefuses any later flush, for a stopped or failed reply. -
Empty: a settled assistant turn with nothing in it is
hidden, keeping its id for broadcasts. -
Parts:
turn.reasoning(**options) { }(seeai_chat_reasoning),turn.actions { },turn.branches { }andturn.attachments { }(shown above a user's bubble;{ |files| … }buildsmessage_attachments). -
Optimistic:
optimistic: { id:, text: }renders the template a composer fills from those fields, dimmed. -
Classes: the root carries
UnmagicMessageandUnmagicAIChatMessage; the body isUnmagicMessage__body.
I18n: unmagic.components.ai_chat.message.user ("You said"), .assistant
("Assistant said") and .thinking ("Thinking"), for screen readers.
streaming_markdown_tag(content = nil, id:, final: false, streaming: false, **options, &block)
The streaming body on its own. Send flushes with
turbo_stream.stream_markdown(target, html), each carrying the whole render so
far. Under reduced motion each flush paints at once. It fires
unmagic-streaming-markdown:settle when it has caught up. Needs
import "unmagic/components/streaming_markdown".
ai_chat_reasoning(content = nil, title: nil, streaming: false, duration: nil, open: false, **options, &block)
The model's thinking, collapsed above its reply. duration: titles it "Thought
for 12s"; streaming: true shows "Thinking…". reasoning.block { } adds a
block. Blank content renders nothing. I18n:
unmagic.components.ai_chat.reasoning.title, .thinking and .duration.
ai_chat_tool_call(name:, state:, id: nil, icon: nil, open: false, timeline: true, **options, &block)
<%= ai_chat_tool_call name: call.name, state: call.state, id: dom_id(call), icon: call.category_icon do |tool| %>
<% tool.summary call.summary %>
<% tool.timing started_at: call.started_at, duration: call.duration %>
<% tool.asked call.arguments %>
<% tool.answered call.response %>
<% end %>-
State:
:queued,:running,:waiting,:doneor:failed. A running call is busy, spins, and counts up withelapsed_tag. A done call showsicon:, what it was about, in place of a column of identical ticks. -
Timeline: consecutive calls are joined by a line, which runs down an open
call's body.
timeline: falseleaves a call out of the run. -
Parts:
summary,timing,askedandanswered(payloads, in a<details>, so no script and no state to restore after a redraw),failures(a count for a batch that mostly worked),progress(a line with id"#{id}_progress"to replace as the tool reports), andmade { }(output kept outside the fold).
I18n under unmagic.components.ai_chat.tool_call: queued, running,
waiting, done, failed, asked, answered, failures, partly_failed,
running_for and took.
ai_chat_payload(payload, label: nil, language: nil, duration: nil, copy: false, **options)
What went into a tool call or came back. A Hash, an Array, or a string holding
JSON is laid out a key to a line as :json; anything else is :plaintext
unless language: says otherwise. It renders through config.code_block,
scrolls past a height, wraps long lines, and is reachable by keyboard. copy: true adds a copy button. nil renders nothing. I18n:
unmagic.components.ai_chat.payload.took and .label ("Payload").
ai_chat_composer(form:, field:, state: :idle, label: nil, stop_form: "stop_turn", placeholder: nil, rows: 2, **options, &block)
-
The form needs an
id:. Send names it withform=, and Stop namesstop_form:. Renderai_chat_stop_form(url)outside your form, since forms can't nest. -
State —
:idle,:running,:stoppingor:waiting— changes only the button region (id"#{form_id}_action", a polite live region). The field is never disabled. Redraw the region alone withai_chat_composer_action(form:, state:). - Keys: Enter sends and Shift+Enter makes a new line. Enter does nothing while there's no Send button. A successful submit resets the form and keeps focus in the field.
-
Parts:
composer.optimistic(id:, container:),composer.attach { },composer.actions { }andcomposer.menu { }(anai_chat_slash_menu). -
The field is the composer's own chrome, not a
control_classcontrol: the box around it is the control and takes the focus ring.
Needs import "unmagic/components/ai_chat". I18n under
unmagic.components.ai_chat.composer: send, stop, stopping, waiting
and label.
ai_chat_slash_menu(for: nil, id: nil, trigger: "/", above: false, insert: nil, **options, &block)
Commands offered on a slash. menu.item(name, description:, arguments:) for
each; every item is rendered and typing filters them by prefix. Up and Down move,
Enter or Tab takes one, Escape closes, and focus stays in the field (the combobox
pattern). Picking writes "/name " at the cursor. for: defaults to the
composer's field. Needs import "unmagic/components/slash_menu". I18n:
unmagic.components.ai_chat.slash_menu.label ("Commands").
ai_chat_dropzone(input:, url: nil, field: nil, chips: nil, label: nil, **options, &block)
ai_chat_dropzone wraps a region that takes dropped and pasted files, with an
overlay while dragging. input: is the file input the files join, which is also
the way in for anyone who can't drag. Without url:, files wait on that input
and post with the form. With url:, each file is POSTed on the spot as file,
and the JSON response's value is posted with the message under field:.
chips: is where attached files are listed: the composer's
[data-ai-chat-chips]. It fires unmagic-dropzone:attach and :error. Needs
import "unmagic/components/dropzone". I18n under
unmagic.components.ai_chat.attachments: drop, attached, remove and
failed.
ai_chat_welcome(heading:, heading_tag: :h2, composer: "composer", field: nil, **options, &block)
What an empty conversation says. welcome.body(text) and
welcome.suggestion(label, icon:, fill:, value:). Pressing a suggestion puts it
in the composer form with id composer: and sends it, unless fill: true or a
turn is running. Needs import "unmagic/components/ai_chat".
ai_chat_request(state:, url: nil, method: :patch, prompt: nil, label: nil, scope: :response, live: false, **options, &block)
<%= ai_chat_request state: :waiting, url: answer_path(@chat), live: true do |request| %>
<% request.question "How should it sound?", header: "Tone" do |q| %>
<% q.option "Friendly", description: "A short, warm check-in" %>
<% q.option "Direct" %>
<% end %>
<% end %>-
Ask with questions (
multiple: truefor checkboxes; answers post asanswers[i][]) or with a form (request.form { |form| … }), not both.request.declineadds a Decline that skips validation. -
Answered states —
:accepted,:declined,:cancelled,:timed_out— keep the questions and show what was picked (question picked:) or answered (request.answer label, value). Declined and dropped cards dim. -
live: trueaddsrole="alert"and, if nothing else has focus, focuses the first option.
I18n under unmagic.components.ai_chat.request: waiting, asked, answer,
decline, accepted, declined, cancelled, timed_out and unanswered.
ai_chat_permission(tool:, state:, url: nil, live: false, outcome: nil, **options, &block)
<%= ai_chat_permission tool: "delete_files", state: :waiting, url: permission_path(@call) do |ask| %>
<% ask.argument "paths", "drafts/*" %>
<% ask.summary { markdown(@call.summary) } %>
<% ask.allow confirm: "Delete these files? This can't be undone." %>
<% ask.allow "Allow for any chat", params: { widened: true } %>
<% ask.refuse %>
<% end %>- The ask — the tool's own name and its arguments — comes before the reasoning, because it's what the decision is about.
-
Actions: the first
allowis the prominent, red, consequential choice; a later one is the wider grant. Offer that only when there is something to widen.allowPOSTs andrefuseDELETEs tourl:, each withparams:andconfirm:. - Focus goes to the card when it arrives, never to Allow.
-
Answered:
:granted,:refused, or:lapsed(answered in the chat, nothing granted), withoutcome:to reword the sentence.
I18n under unmagic.components.ai_chat.permission: waiting, asked, allow,
refuse, granted, refused and lapsed.
ai_chat_proposal(state: :pending, icon: :lightbulb, id: nil, **options, &block)
An offer the agent made in passing, which doesn't stop the work: offer.claim,
offer.meta, offer.accept { } and offer.reject { } while :pending; the
outcome ("Saved", "Dismissed", or offer.outcome) once :accepted or
:rejected. It is an <aside> named by its claim, and not-prose so it can sit
inside a reply. I18n: unmagic.components.ai_chat.proposal.accepted and
.rejected.
ai_chat_citation(compact: false, **options, &block)
A quotation rendered from the record, not the model's paraphrase:
cite.avatar (with no block, the gem's avatar for who), cite.who,
cite.when(time, url:) and cite.quote(text). The quote is escaped text with
its line breaks kept. compact: true is one line, for a list of sources.
ai_chat_failure(message = nil, live: false, **options, &block)
A turn that fell over. The sentence is for whoever asked. failure.cause sits
above the fold; failure.detail(label, payload, language:) rows fold away for
whoever works on the assistant; failure.retry { } holds your control. Render
it below a turn's body, never in its place. live: true adds role="alert".
I18n: unmagic.components.ai_chat.failure.details.
ai_chat_plan(**options, &block) and ai_chat_workspace(**options, &block)
Two sections of one side panel. Both are collapsible, open by default, and always render, empty or not, so a broadcast has something to replace.
-
plan.step(title, state:)::pending,:in_progress,:waitingor:completed, with an optional block for detail. The count reads completed/total, orcompleted:/total:when a panel shows only some steps. -
workspace.file(path, size:, url:, icon:): the workspace folds the paths into atree_viewof folders, open, with folders ahead of the files beside them. A folder that holds only one folder joins it in a single row, which shortens from the front so the last folder's name stays. A file with a url is a link, and its glyph comes from its extension (:file_code,:file_text,:file_image,:file_json,:file_spreadsheet,:file_archive,:file_audio,:file_video, else:file) unlessicon:says otherwise. Each row's title is its full path; the count is the number of files. -
Both take
title:,open:,empty:,collapsible:andtitle_tag:(:h2). The workspace also takescount:. -
Collapsed stays collapsed. Given an
id:, a section keeps the person's open or shut when a broadcast replaces or morphs it, soopen:decides only until they choose. A tool call or reasoning given anid:does the same. Needsimport "unmagic/components/ai_chat"; without it the server'sopen:applies on every render.
I18n under unmagic.components.ai_chat: plan.title, plan.empty,
plan.pending, plan.in_progress, plan.waiting, plan.completed,
workspace.title and workspace.empty.
ai_chat_branch_picker(index:, count:, previous: nil, next: nil, method: :get, **options)
Walks between versions of a turn: "Version 2 of 3", with a disabled end where
there's nowhere to go. What a branch is stays your application's business, and
one version renders nothing. It goes in turn.branches, beside the turn's
message_actions (ai_chat_action_bar is that helper under its old name).
Needs import "unmagic/components/ai_chat". I18n:
unmagic.components.ai_chat.branch_picker.label, .previous and .next.
Image tools
<%= image_zoom "/photo.jpg", alt: "Mountain lake", zoom_src: "/original.jpg" %>
<%= image_crop "/photo.jpg", alt: "Mountain lake", aspect: 1, name: "avatar" %>
<%= image_crop "/photo.jpg", alt: "Mountain lake", circular: true %>
<%= color_field_tag "color", "#ffffff", id: "chosen_color" %>
<%= image_color_picker "/photo.jpg", alt: "Mountain lake", input: "chosen_color" %>Import unmagic/components or the individual image_zoom, image_crop, and
image_color_picker modules under unmagic/components/.
All require alt:; HTML options apply to the root, and blank sources raise.
Zoom opens a native dialog with Escape, backdrop dismissal and focus restoration.
zoom_src: defaults to the thumbnail source. The element exposes open() and
close() and emits unmagic-image-zoom:change with { open }.
Crop supports freeform selection (aspect: nil), a finite positive aspect ratio,
and circular PNGs (circular: true, forcing a square). Drag the selection to move
it or its lower-right handle to resize; labeled numeric fields offer the same
controls in source pixels. name: nil optionally renders a hidden field containing
the exported PNG data URL. crop() returns that URL and emits
unmagic-image-crop:crop with { dataURL, x, y, width, height }. Changing the
selection clears the previous result. Exports preserve the selected source
resolution; no upload or automatic compression is performed.
The color picker extracts Toybox's image-to-field sampling. input: is the id of
an existing editable field; a click samples that pixel, arrow keys move the cursor
(one source pixel, or ten with Shift), and Enter/Space samples it. It writes
#rrggbb, fires ordinary input and change events, and emits
unmagic-image-color-picker:change with { value, x, y }. It samples RGB;
transparent pixels do not include an alpha value.
Crop and sampling need same-origin images, data URLs, or remote images served
with CORS permission. Failures announce a message and emit the component's
:error event with { error }. Images remain visible without JavaScript;
interactive crop controls enable after the image loads.
I18n keys under unmagic.components: image_zoom.open, .close;
image_crop.x, .y, .width, .height, .crop, .reset, .error;
image_color_picker.pick, .error.
Run bin/demo-images with bin/dev running to replay the visible browser demo.
It checks all three components at desktop and phone widths, in both themes,
and saves screenshots under tmp/image-demo/. Set HEADLESS=1 for automation.
Interaction references: Kibo Image Crop and Kibo Image Zoom.
Development
bundle install
bundle exec rspec
bundle exec rubocopContributing
Bug reports and pull requests are welcome at https://github.com/unreasonable-magic/unmagic-components.
License
Available as open source under the terms of the MIT License.