Controlled components emit requested changes while Elixir remains authoritative over persisted values. Native displays retain transient focus, selection, pointer, and drag state across ordinary rerenders.

Inputs and choices

<UI.input
  id="name"
  label="Name"
  value={assigns.name}
  placeholder="Name"
  cleanable={true}
  phx-change="name_changed"
/>

<UI.select
  id="language"
  label="Language"
  value={assigns.language}
  options={[{"Rust", "rust"}, {"Elixir", "elixir"}]}
  phx-change="language_changed"
/>

<UI.combobox
  id="framework"
  label="Framework"
  value={assigns.framework}
  options={assigns.framework_options}
  search_placeholder="Search frameworks"
  phx-change="framework_changed"
  phx-search="framework_searched"
/>

Buttons, checkboxes, inputs, selects, and comboboxes require non-empty semantic labels. Native text-input metadata exposes the controlled value and placeholder; masked inputs expose a password-input role without leaking their value. Select metadata exposes the visible label of the controlled option, while searchable comboboxes retain the upstream expanded-state semantics inside a labeled group. Input, select, and combobox labels are semantic rather than visible captions. Use UI.field/1 when the interface also needs a visual label and controlled help or error feedback:

<UI.field
  label="Display name"
  required={true}
  help="Used in workspace activity."
  error={assigns.errors[:name]}
  class="flex flex-col gap-2"
>
  <UI.input
    id="display-name"
    label="Display name"
    value={assigns.name}
    focus_request={assigns.name_focus_request}
    phx-change="name_changed"
    phx-submit="save"
  />
</UI.field>

A field accepts exactly one control. Error feedback replaces help text and stays owned by the view. Enter emits optional phx-submit with the input's current value. Increment focus_request to focus the input after a failed submission; using a monotonically increasing request avoids repeatedly stealing focus during ordinary rerenders.

Select, combobox, tabs, and radio-group options accept strings, {label, value} tuples, or %{label: label, value: value} maps. Radio maps may also set disabled: true. Option values must be unique.

Editable controlled components require a non-empty phx-change. This includes checkboxes, inputs, selects, comboboxes, switches, radio groups, accordions, tabs, and sliders. Requiring an owner for native edits prevents a control from appearing editable while silently snapping back to an unchanged Elixir value. phx-search on comboboxes and phx-release on sliders remain optional secondary events.

Inputs preserve native focus, cursor, selection, clipboard, and IME state while controlled snapshots are reconciled. Combobox search and option replacement can be asynchronous.

Boolean and grouped controls

<UI.switch
  id="notifications"
  label="Notifications"
  checked={assigns.notifications}
  loading={assigns.loading}
  phx-change="notifications_changed"
/>

<UI.radio_group
  id="plan"
  label="Plan"
  value={assigns.plan}
  options={[
    {"Free", "free"},
    %{label: "Pro", value: "pro", disabled: true},
    {"Team", "team"}
  ]}
  orientation="horizontal"
  phx-change="plan_changed"
/>

Switch and radio-group labels are semantic contracts, not decorative captions: they provide stable native accessibility names. Switches support Enter and Space. Radio groups use roving tab stops and Left/Up/Right/Down navigation, wrapping around disabled options.

Tabs, accordions, sliders, and splits

Tabs expose a native tab-list with labeled tab roles and controlled selected state. The selected tab is the single Tab stop; Left/Up and Right/Down move and select with wrapping, Home/End select endpoints, and Enter/Space activate the focused tab. Pointer selection focuses the chosen tab. Disabled tab bars expose disabled semantics and install no pointer, keyboard, Tab, or accessible Click action. Every activation path emits the same controlled phx-change value.

<UI.tabs
  id="section"
  value={assigns.section}
  options={[{"General", "general"}, {"Advanced", "advanced"}]}
  variant="underline"
  phx-change="section_changed"
/>

<UI.accordion
  id="details"
  expanded={assigns.expanded}
  multiple={true}
  phx-change="details_changed"
>
  <UI.accordion_item id="account" title="Account">
    <text>Account details</text>
  </UI.accordion_item>
</UI.accordion>

<UI.slider
  id="volume"
  label="Volume"
  value={assigns.volume}
  min={0}
  max={100}
  step={5}
  phx-change="volume_changed"
  phx-release="volume_released"
/>
<UI.split
  id="workspace-split"
  orientation="horizontal"
  sizes={assigns.split_sizes}
  min_sizes={[180, 320]}
  resize_request={assigns.split_resize_request}
  phx-change="workspace_resized"
>
  <div>Navigation</div>
  <div>Content</div>
</UI.split>

Tab changes carry one string value. Accordion changes carry an ordered list of expanded item IDs. A slider label names the accessibility group around the native slider, which exposes its controlled value, range, step, and orientation. Slider changes are continuous and phx-release fires once pointer interaction ends. Linear and logarithmic slider scales are supported.

A split has exactly two children and a horizontal or vertical native resize axis. sizes, min_sizes, and max_sizes are bounded two-element pixel lists. The display owns pointer drag mechanics and emits the resulting two sizes through phx-change; applications persist those values in assigns. Controlled sizes are not reapplied during ordinary rerenders, which avoids resetting an active drag. Increment the monotonic resize_request token to programmatically apply sizes. This primitive carries no sidebar, editor, dock, or persistence policy.

Progress

progress/1 renders a native accessible progress indicator. Values are controlled and must remain between zero and max; set indeterminate={true} when the amount of completed work is unknown.

<UI.progress id="import" label="Importing image" value={assigns.progress} max={100} />

Button variants and sizes

Button variants are default, primary, secondary, danger, warning, success, info, ghost, link, and text. Component sizes are xs, sm, md, and lg where supported.

Focus

The semantic <button> primitive is a styled native button region with built-in button accessibility role, Tab focus, Enter/Space activation, and unified pointer/AccessKit activation. Use <UI.button> when GPUI Component variants, sizing, loading, selection, or clipboard capabilities are needed.

Focusable primitives use a monotonic request token instead of an application-controlled focus boolean:

<button
  id="search-trigger"
  focus_request={assigns.trigger_focus_request}
  phx-focus="trigger-focused"
  phx-blur="trigger-blurred"
>
  ...
</button>

Increment focus_request to request native focus once. Rerendering with the same token does not steal focus again. Native pointer and keyboard focus remain native state and emit phx-focus/phx-blur only when focus actually changes. The event value contains the stable element ID:

%{type: :focus, value: %{id: "search-trigger"}}
%{type: :blur, value: %{id: "search-trigger"}}

The first generic contract supports <button>, low-level <text_input>, <UI.input>, and <text_surface>. Focus behavior requires a non-empty stable id; arbitrary focusable containers and controlled focused={true} state are intentionally unsupported.

Use <text_input> only for an unlabelled low-level single-line primitive. Ordinary forms should prefer <UI.input>; revisioned editor and composer surfaces should use <text_surface>.