Form builder
The chrome around a form control — the wrapper, the label and its required marker, the hint and the error line — and the look of the controls it builds, through config.control_class.
Fields
field builds its control from as:, or takes one from a block; group lays fields out in a row.
<%= form_with url: request.path, method: :get, scope: :person, builder: Unmagic::Components::FormBuilder do |form| %>
<%= form.group do %>
<%= form.field :first_name, "First name", required: true, placeholder: "Ada" %>
<%= form.field :last_name, "Last name", placeholder: "Lovelace" %>
<% end %>
<%= form.field :email, "Email", as: :email_field, placeholder: "ada@example.com",
hint: "Only used to sign you in." %>
<%= form.group do %>
<%= form.field :password, "Password", as: :password_field %>
<%= form.field :starts_on, "Starts on", as: :date_field %>
<% end %>
<%= form.field :role, "Role" do %>
<%= form.select :role, [ "Engineer", "Designer", "Manager" ], include_blank: "Choose a role" %>
<% end %>
<%= form.field :bio, "Bio", as: :text_area, rows: 3, placeholder: "A line or two about yourself." %>
<div class="mb-4 grid gap-3">
<%= form.check_box_field :releases, "Email me about new releases", hint: "Roughly once a month." %>
<%= form.check_box_collection :teams, [ [ 1, "Platform" ], [ 2, "Billing" ], [ 3, "Growth" ] ],
:first, :last, inline: true %>
</div>
<%= form.submit "Save" %>
<% end %>Invalid, disabled and read-only
An attribute with errors marks its control aria-invalid and reads the errors underneath.
<%= form_with model: @invalid_profile, url: request.path, method: :get,
builder: Unmagic::Components::FormBuilder do |form| %>
<%= form.field :name, "Name", required: true %>
<%= form.field :role, "Role", disabled: true, hint: "Set by your administrator." %>
<% end %>
<%= form_with model: @profile, url: request.path, method: :get, namespace: :locked,
builder: Unmagic::Components::FormBuilder do |form| %>
<%= form.field :role, "Role (read-only)", readonly: true %>
<%= form.check_box_field :name, "Locked option", disabled: true, checked: true %>
<% end %>control_classes, outside a builder
A select_tag or a hand-written input wears the same classes, sized to sit level with buttons.
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-2">
<%= search_field_tag "people[q]", nil, placeholder: "Search people", "aria-label": "Search people",
class: control_classes(:input, size: :small) %>
<%= select_tag "people[status]", options_for_select(%w[Active Invited Removed]), "aria-label": "Status",
class: control_classes(:select, size: :small) %>
<%= tag.button "Filter", type: "button", class: button_classes(:default, size: :small) %>
</div>
<div class="flex flex-wrap items-center gap-2">
<%= text_field_tag "key[slug]", "deploy-key", "aria-label": "Slug", class: control_classes(:input) %>
<%= select_tag "key[scope]", options_for_select([ "Read", "Read and write" ]), "aria-label": "Scope",
class: control_classes(:select) %>
<%= tag.button "Save", type: "button", class: button_classes(:primary) %>
</div>
<div class="flex flex-wrap items-center gap-2">
<%= text_field_tag "report[title]", "Quarterly review", "aria-label": "Title",
class: control_classes(:input, size: :large) %>
<%= tag.button "Publish", type: "button", class: button_classes(:primary, size: :large) %>
</div>
<fieldset class="m-0 border-0 p-0">
<legend class="UnmagicLabel mb-2">Notify me</legend>
<div class="UnmagicCheckList--inline">
<% [ "Immediately", "Daily", "Never" ].each_with_index do |choice, index| %>
<label class="UnmagicCheckField">
<%= radio_button_tag "notify", choice.downcase, index.zero?, class: control_classes(:radio) %>
<span class="UnmagicCheckField__label"><%= choice %></span>
</label>
<% end %>
<label class="UnmagicCheckField">
<%= radio_button_tag "notify", "paused", false, disabled: true, class: control_classes(:radio) %>
<span class="UnmagicCheckField__label">Paused (disabled)</span>
</label>
</div>
</fieldset>
<div class="UnmagicCheckList--inline">
<label class="UnmagicCheckField">
<%= check_box_tag "all_teams", "1", false, id: "control_classes_all_teams", class: control_classes(:check) %>
<span class="UnmagicCheckField__label">All teams (indeterminate)</span>
</label>
<label class="UnmagicCheckField">
<%= check_box_tag "archived", "1", true, id: "control_classes_archived", class: control_classes(:check),
"aria-invalid": "true" %>
<span class="UnmagicCheckField__label">Invalid</span>
</label>
</div>
<%= javascript_tag nonce: true do %>
document.getElementById("control_classes_all_teams").indeterminate = true
<% end %>
</div>Switches and sliders
switch_field is a checkbox with role switch, drawn as one. range_field is the native slider styled to match.
<%= form_with model: @profile, url: "#", builder: Unmagic::Components::FormBuilder, class: "max-w-md" do |form| %>
<%= form.switch_field :notify, "Email me about new replies" %>
<%= form.switch_field :two_factor, "Require two-factor", hint: "Members without it are signed out." %>
<%= form.field :volume, "Volume", as: :range_field, min: 0, max: 100, hint: "A slider, styled to match." %>
<% end %>Radio groups
radio_button_collection in a fieldset named by legend:, with a hint under each option; variant: :cards makes the whole card the target.
<% plans = [ [ "starter", "Starter", "For one person trying things out." ], [ "team", "Team", "Everybody in the shop, one bill." ], [ "studio", "Studio", "Priority renders and a kiln of your own." ] ].map { |id, name, summary| Struct.new(:id, :name, :summary).new(id, name, summary) } %>
<%= form_with model: @profile, url: "#", builder: Unmagic::Components::FormBuilder, class: "max-w-2xl" do |form| %>
<%= form.radio_button_collection :role, plans, :id, :name, legend: "Plan", hint_method: :summary, variant: :cards, inline: true, required: true %>
<%= form.radio_button_collection :name, plans, :id, :name, legend: "The same, as a list", inline: true %>
<% end %>Passwords and one-time codes
password_field reveal: true adds a button that shows what was typed. one_time_code_field is one real input drawn as a row of boxes, so paste and autofill just work. Needs import "unmagic/components/password" and "unmagic/components/one_time_code".
<%= form_with model: @profile, url: "#", builder: Unmagic::Components::FormBuilder, class: "max-w-md" do |form| %>
<%= form.field :password, "Password", as: :password_field, reveal: true, autocomplete: "new-password", hint: "The button shows what you typed; the caret stays put." %>
<%= form.field :code, "Verification code", as: :one_time_code_field, length: 6, hint: "Paste it, or let the phone fill it in." %>
<% end %>Input groups
input_group joins a unit, a scheme, an icon or a button to either end of a control.
<div class="grid max-w-md gap-4">
<%= input_group prefix: "https://", suffix: ".example.com" do %>
<%= text_field_tag :subdomain, "kidbook", class: control_classes(:input), "aria-label": "Subdomain" %>
<% end %>
<%= input_group prefix: unmagic_icon("unmagic_components:lucide/messages-square", class: "size-4 mx-3 text-neutral-500") do %>
<%= text_field_tag :handle, nil, placeholder: "Search messages", class: control_classes(:input), "aria-label": "Search" %>
<% end %>
<%= input_group suffix: button("Send", :primary, type: "button") do %>
<%= email_field_tag :email, nil, placeholder: "you@example.com", class: control_classes(:input), "aria-label": "Email" %>
<% end %>
</div>Autogrowing textarea
Grows from its rows up to its max-height, then scrolls. Needs import "unmagic/components/autogrow".
<%= form_with url: request.path, method: :get, scope: :note, builder: Unmagic::Components::FormBuilder do |form| %>
<%= form.field :body, "Message", as: :autogrow_text_area, rows: 2, class: "max-h-40",
placeholder: "Type a few lines, then keep going past ten rems…" %>
<% end %>UUID field
A hidden field holding a fresh UUIDv7, minted again when the element upgrades and on every form reset. Needs import "unmagic/components/uuid_input".
<%= form_with url: request.path, method: :get, scope: :message, builder: Unmagic::Components::FormBuilder do |form| %>
<%= form.uuid_field :id %>
<%= form.field :body, "Message", hint: "Submits with a client-minted id; resetting the form mints another." %>
<div class="flex gap-2">
<%= form.submit "Send" %>
<button type="reset" class="<%= button_classes %>">Reset the form</button>
</div>
<% end %>