Switch

v0.0.10

v0.0.10StableHeadlessStatelessAccessibleJSApache-2.0
Installation mix mishka.ui.gen.headless switch
Source View Source

Overview of the Phoenix and LiveView Headless Switch Component

The Mishka Chelekom Headless Switch is an on/off control for Phoenix LiveView with no styling of its own. It renders a <button role="switch"> with the WAI-ARIA wiring and its state as data attributes: data-checked or data-unchecked, and data-indeterminate for the third state a "select all" switch needs. Its Toggle hook keeps the state on the root, the track and the thumb, so each of them can be styled from its own attributes.

Every part takes a *_class attribute: class for the root, track_class, thumb_class, label_class, input_class, and on_icon_class and off_icon_class for the two optional icons inside the track.

Base UI Switch example (Phoenix headless)

Base UI Switch example (Phoenix headless)

heex
<.switch
  id="baseui-switch-hero"
  checked
  class="group inline-flex items-center gap-2 text-sm font-normal text-neutral-950 select-none dark:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-950 dark:focus-visible:outline-white"
  track_class="flex h-5 w-9 shrink-0 items-center border border-neutral-950 bg-white p-0.5 transition-colors duration-150 ease-[ease] dark:border-white dark:bg-neutral-950 group-data-[checked]:bg-neutral-950 dark:group-data-[checked]:bg-white"
  thumb_class="block size-3.5 bg-neutral-950 transition-[translate,background-color] duration-150 ease-[ease] data-[checked]:translate-x-4 data-[checked]:bg-white dark:bg-white dark:data-[checked]:bg-neutral-950"
>
  Notifications
</.switch>

Switch example - icons in the track (Phoenix headless)

The on_icon and off_icon slots are drawn inside the track, after the thumb, each in a span of its own. Neither is drawn when its slot is empty. Here the track is the named group group/track, and each icon fades in or out with group-data-[checked]/track:.

Switch example - icons in the track (Phoenix headless)

heex
<.switch
  id="switch-icons"
  checked
  class="group inline-flex items-center gap-2 text-sm text-neutral-950 select-none dark:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-950 dark:focus-visible:outline-white"
  track_class="group/track relative flex h-6 w-11 shrink-0 items-center rounded-full bg-neutral-300 p-0.5 transition-colors duration-150 data-[checked]:bg-neutral-950 dark:bg-neutral-700 dark:data-[checked]:bg-white"
  thumb_class="relative z-10 block size-5 rounded-full bg-white shadow-sm transition-[translate] duration-150 data-[checked]:translate-x-5 dark:bg-neutral-950"
  on_icon_class="absolute left-1.5 flex text-white opacity-0 transition-opacity duration-150 group-data-[checked]/track:opacity-100 dark:text-neutral-950"
  off_icon_class="absolute right-1.5 flex text-neutral-600 opacity-100 transition-opacity duration-150 group-data-[checked]/track:opacity-0 dark:text-neutral-300"
>
  <:on_icon><.icon name="hero-check-mini" class="size-3.5" /></:on_icon>
  <:off_icon><.icon name="hero-x-mark-mini" class="size-3.5" /></:off_icon>
  Wi-Fi
</.switch>

Switch example - indeterminate (Phoenix headless)

Set indeterminate when the switch stands for a group that is only partly on, such as a "select all" row. The root reads aria-checked="mixed", and the root, track and thumb carry data-indeterminate instead of data-checked or data-unchecked. The state is rendered on the server, so it is right on the first paint. The next press resolves it to on.

Switch example - indeterminate (Phoenix headless)

heex
<.switch
  id="switch-select-all"
  checked={@selected != [] and length(@selected) == length(@items)}
  indeterminate={@selected != [] and length(@selected) < length(@items)}
  on_change="toggle_all"
  class="group inline-flex items-center gap-2 text-sm text-neutral-950 select-none dark:text-white"
  track_class="flex h-6 w-11 shrink-0 items-center rounded-full bg-neutral-300 p-0.5 transition-colors duration-150 data-[checked]:bg-neutral-950 data-[indeterminate]:bg-neutral-500 dark:bg-neutral-700 dark:data-[checked]:bg-white"
  thumb_class="block size-5 rounded-full bg-white shadow-sm transition-[translate] duration-150 data-[checked]:translate-x-5 data-[indeterminate]:translate-x-2.5 dark:bg-neutral-950"
>
  Select all
</.switch>
elixir
def handle_event("toggle_all", %{"checked" => checked}, socket) do
  selected = if checked, do: socket.assigns.items, else: []
  {:noreply, assign(socket, :selected, selected)}
end

Attributes and slots

id is required: the label's id and aria-labelledby are built from it. checked sets the state on render, and indeterminate takes over from it while it is true. disabled ignores every press and sets data-disabled. readonly blocks the toggle but keeps the switch focusable, and sets aria-readonly. required sets aria-required, and required on the hidden checkbox.

For a form, give the switch a name. It then renders a hidden checkbox that submits value (default "on") while the switch is on. unchecked_value adds a second hidden input, so a value is submitted while it is off too, and form names the form the inputs belong to when the switch is rendered outside it. on_change is a LiveView event pushed on every toggle with %{"checked" => true} or %{"checked" => false}.

The default slot is the label. on_icon and off_icon are optional icons inside the track.

Parts & data attributes

Each part carries a stable data-part and a structural chelekom-switch__* class you can target.

Partdata-partState / ARIAPurpose
Root(button)role="switch", aria-checked (true, false or mixed), aria-labelledby, aria-readonly, aria-required, data-checked, data-unchecked, data-indeterminate, data-disabled, data-readonly, data-requiredThe control itself.
Inputinputaria-hiddenHidden checkbox that submits the value; rendered when the switch has a name.
Tracktrackaria-hidden, data-checked, data-unchecked, data-indeterminateThe box the thumb slides in; the hook keeps its state.
Thumbthumbaria-hidden, data-checked, data-unchecked, data-indeterminateThe sliding dot; the hook keeps its state.
On iconon-iconaria-hidden; the state of the first renderThe on_icon slot, inside the track after the thumb.
Off iconoff-iconaria-hidden; the state of the first renderThe off_icon slot, inside the track after the thumb.
Labellabel-Wraps the default slot; the root is labelled by it.

Accessibility

Follows the WAI-ARIA Switch pattern: the root exposes role="switch" with a live aria-checked (true, false, or mixed while indeterminate), and Enter or Space toggles the state. readonly blocks the flip but keeps the control focusable, while disabled ignores interaction entirely. The icons are aria-hidden, so give the switch a text label.