Forms Cheatsheet

View Source

Quick reference for form controls and patterns in Sutra UI.

For the broader component list, use components.cheatmd. For design rules, composition, and implementation constraints, see SKILL.md, skill/patterns.md, and usage_rules.md.

Browse live form and component examples at sutraui.gurujada.com.

Basic Form

LiveView Form with Changeset

<.form for={@form} class="form" phx-change="validate" phx-submit="save">
  <.input field={@form[:name]} label="Name" />
  <.input field={@form[:email]} type="email" label="Email" />
  <.button type="submit" phx-disable-with="Saving...">Save</.button>
</.form>

Manual Form

<.form for={@form} phx-submit="save">
  <.input field={@form[:name]} label="Name" />
  <.button type="submit">Save</.button>
</.form>

Text Inputs

Basic Input

<.input type="text" name="name" value={@name} />
<.input type="text" name="name" placeholder="Enter name" />
<.input type="text" name="name" disabled />

With Label and Description

<.input
  field={@form[:name]}
  label="Full Name"
  description="Enter your legal name"
/>

Input with Errors

<.input
  id="email"
  name="email"
  value={@email}
  type="email"
  label="Email"
  errors={@errors}
/>

Input Types Reference

TypeExample
text<.input type="text" />
email<.input type="email" />
password<.input type="password" />
number<.input type="number" min="0" />
tel<.input type="tel" />
url<.input type="url" />
search<.input type="search" />
date<.input type="date" />
time<.input type="time" />
datetime-local<.input type="datetime-local" />

Textarea

Basic Textarea

<.textarea name="bio" />
<.textarea name="bio" rows="6" />
<.textarea name="bio" placeholder="Tell us about yourself..." />

With Character Count

<.input
  field={@form[:bio]}
  type="textarea"
  label="Bio"
  description={"#{String.length(@bio || "")}/500 characters"}
  maxlength="500"
/>

Input Groups

With Prefix

<.input_group>
  <:prefix>https://</:prefix>
  <input type="text" name="domain" class="input pl-21" placeholder="example.com" />
</.input_group>

With Suffix

<.input_group>
  <input type="number" name="weight" class="input pr-12" />
  <:suffix>kg</:suffix>
</.input_group>

With Icons

<.input_group>
  <:prefix type="icon">
    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
  </:prefix>
  <input type="search" name="q" class="input pl-9" placeholder="Search..." />
</.input_group>

<.input_group>
  <:prefix>$</:prefix>
  <input type="number" name="price" class="input pl-7 pr-12" />
  <:suffix>.00</:suffix>
</.input_group>

Selection Controls

Checkbox

<.checkbox id="terms" name="terms" />

<!-- With label -->
<div class="flex items-center gap-2">
  <.checkbox id="terms" name="terms" />
  <.label for="terms">I agree to the terms</.label>
</div>

<!-- Checked by default -->
<.checkbox id="subscribe" name="subscribe" checked />

Checkbox Group Pattern

<fieldset>
  <legend class="text-sm font-medium">Notifications</legend>
  <div class="space-y-2 mt-2">
    <div class="flex items-center gap-2">
      <.checkbox id="email" name="notifications[]" value="email" />
      <.label for="email">Email</.label>
    </div>
    <div class="flex items-center gap-2">
      <.checkbox id="sms" name="notifications[]" value="sms" />
      <.label for="sms">SMS</.label>
    </div>
  </div>
</fieldset>

Switch

<.switch id="dark-mode" name="dark_mode" />

<!-- With label -->
<div class="flex items-center gap-2">
  <.switch id="notifications" name="notifications" />
  <.label for="notifications">Enable notifications</.label>
</div>

Radio Group

<.radio_group name="plan" label="Plan">
  <:radio value="free" label="Free" checked={@selected_plan == "free"} />
  <:radio value="pro" label="Pro - $10/mo" checked={@selected_plan == "pro"} />
  <:radio
    value="enterprise"
    label="Enterprise - Contact us"
    checked={@selected_plan == "enterprise"}
  />
</.radio_group>

Radio with Descriptions

<.radio_group name="shipping">
  <:radio value="standard" label="Standard">
    <span class="text-muted-foreground">3-5 business days</span>
  </:radio>
  <:radio value="express" label="Express">
    <span class="text-muted-foreground">1-2 business days</span>
  </:radio>
</.radio_group>

Select Controls

Basic Select

<.select id="country" name="country" value={@country}>
  <:trigger>{@country_label || "Select a country"}</:trigger>
  <.select_option value="us" label="United States" />
  <.select_option value="ca" label="Canada" />
  <.select_option value="mx" label="Mexico" />
</.select>

Select with Groups

<.select id="timezone" name="timezone" value={@timezone} searchable>
  <:trigger>{@timezone_label || "Select timezone"}</:trigger>
  <.select_group label="Americas">
    <.select_option value="America/New_York" label="New York" />
    <.select_option value="America/Los_Angeles" label="Los Angeles" />
  </.select_group>
  <.select_separator />
  <.select_group label="Europe">
    <.select_option value="Europe/London" label="London" />
    <.select_option value="Europe/Paris" label="Paris" />
  </.select_group>
</.select>

Select with Default Value

<.select id="status" name="status" value={@status} selected_label={@status_label}>
  <.select_option value="active" label="Active" />
  <.select_option value="inactive" label="Inactive" />
  <.select_option value="pending" label="Pending" />
</.select>
<.form for={@form} class="form" phx-change="validate">
  <.live_component
    module={SutraUI.LiveSelect}
    id="user-select"
    field={@form[:user_id]}
    placeholder="Search users..."
  />
</.form>

<%= case SutraUI.LiveSelect.decode(@form.params["user_id"]) do %>
  <% nil -> %>
  <% [] -> %>
  <% user -> %>
    Selected user id: {user}
<% end %>

In your LiveView:

def handle_event("live_select_change", %{"text" => text, "id" => id}, socket) do
  users = Accounts.search_users(text)
  options = Enum.map(users, &{&1.name, &1.id})

  send_update(SutraUI.LiveSelect, id: id, options: options)
  {:noreply, socket}
end

Sliders

Basic Slider

<.slider id="volume" name="volume" min="0" max="100" value="50" />

Slider with Steps

<.slider id="rating" name="rating" min="1" max="5" step="1" value="3" />

Range Slider (Dual Handle)

<.range_slider
  id="price-range"
  name="price"
  min="0"
  max="1000"
  value_min="200"
  value_max="800"
/>

Slider with Labels

<div class="space-y-2">
  <div class="flex items-center justify-between">
    <.label for="volume">Volume</.label>
    <output for="volume">{@volume}%</output>
  </div>
  <.slider id="volume" name="volume" min="0" max="100" value={@volume} phx-change="update_volume" />
</div>

Form Patterns

Inline Validation

# LiveView
def handle_event("validate", %{"user" => params}, socket) do
  changeset =
    %User{}
    |> User.changeset(params)
    |> Map.put(:action, :validate)

  {:noreply, assign(socket, form: to_form(changeset))}
end
<.form for={@form} class="form" phx-change="validate" phx-submit="save">
  <.input field={@form[:email]} type="email" label="Email" />
  <.button type="submit">Save</.button>
</.form>

Form with Loading State

<.form for={@form} class="form" phx-submit="save">
  <.input field={@form[:name]} label="Name" />
  <.button type="submit" loading={@saving} phx-disable-with="Saving...">Save</.button>
</.form>

Multi-Step Form

<.form for={@form} class="form" phx-change="validate" phx-submit="next_step">
  <.stepper_wizard id="profile-wizard" current={@step} errors={@step_errors}>
    <:step id="profile" label="Profile">
      <.input field={@form[:name]} label="Name" />
    </:step>
    <:step id="details" label="Details">
      <.input field={@form[:email]} type="email" label="Email" />
    </:step>
    <:step id="review" label="Review">
      Review your details before submitting.
    </:step>

    <:actions>
      <.button type="button" variant="outline" phx-click="previous_step">Back</.button>
      <.button type="submit">Continue</.button>
    </:actions>
  </.stepper_wizard>
</.form>

Filter Forms

Filter Bar

<.filter_bar on_change="filter" on_clear="reset_filters" show_clear={@has_filters}>
  <:filter>
    <.input type="search" name="q" value={@query} placeholder="Search..." />
  </:filter>
  <:filter>
    <.input
      type="select"
      name="status"
      value={@status}
      options={[{"All", ""}, {"Active", "active"}, {"Inactive", "inactive"}]}
    />
  </:filter>
  <:filter>
    <.input
      type="select"
      name="sort"
      value={@sort}
      options={[{"Newest", "newest"}, {"Oldest", "oldest"}, {"Name", "name"}]}
    />
  </:filter>
</.filter_bar>

Date Range Filter

<.filter_bar on_change="filter_dates" cols={2}>
  <:filter>
    <.input type="date" name="start_date" value={@start_date} />
  </:filter>
  <:filter>
    <.input type="date" name="end_date" value={@end_date} />
  </:filter>
</.filter_bar>

Error Handling

Input with Error

<.input
  field={@form[:email]}
  type="email"
  label="Email"
  phx-debounce="blur"
/>

Form-Level Errors

<.form for={@form} class="form" phx-submit="save">
  <.alert :if={@form.errors[:base]} variant="destructive">
    <:title>{translate_error(@form.errors[:base])}</:title>
  </.alert>

  <.input field={@form[:email]} label="Email" />
  <.button type="submit">Save</.button>
</.form>

Async Validation

def handle_event("validate_email", %{"email" => email}, socket) do
  case Accounts.email_available?(email) do
    true -> {:noreply, assign(socket, email_error: nil)}
    false -> {:noreply, assign(socket, email_error: "Email already taken")}
  end
end
<.input
  id="email"
  name="email"
  value={@email}
  type="email"
  label="Email"
  phx-blur="validate_email"
  phx-debounce="500"
  errors={if @email_error, do: [@email_error], else: []}
/>