Components Toggle Group

Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

Toggle group

A segmented selection rail - one pressed option, or several with multiple. Server-driven and stateless: pass value, handle on_change, no hook, no client state.
Single select

One rail, one pressed option, rendered as native radios: real radiogroup semantics, one tab stop, arrow keys move the selection, no JavaScript. Pass the current value and an on_change event; in your app you assign the value the event hands back and the chip follows. This preview renders a fixed value - registry examples are static by design.

heex
<.toggle_group aria_label="Density" value="cozy" on_change="set_density">
<:item value="compact">Compact</:item>
<:item value="cozy">Cozy</:item>
<:item value="comfortable">Comfortable</:item>
</.toggle_group>
Multiple select

With multiple, value is a list and every member renders pressed. The server owns the toggle logic: add the option if it is missing, drop it if it is there. Icon-only items carry an aria-label so the option still reads out.

heex
<.toggle_group
multiple
aria_label="Formatting"
value={["bold", "italic"]}
on_change="toggle_format"
>
<:item value="bold" aria-label="Bold"><.icon name="hero-bold" /></:item>
<:item value="italic" aria-label="Italic"><.icon name="hero-italic" /></:item>
<:item value="underline" aria-label="Underline"><.icon name="hero-underline" /></:item>
</.toggle_group>
Try it - this one is live

Click around. The single rail needs nothing from you: it renders native radios, so the browser moves the chip itself - no JavaScript, no server. The multiple rail toggles each chip with a client-side LiveView.JS command on on_change. In a real app you would usually send an event and let the server own the value instead; these two are here to show the pressed states are real, not screenshots.

heex
<div class="flex flex-col items-start gap-4">
<.toggle_group aria_label="View" value="grid">
<:item value="list"><.icon name="hero-list-bullet" /> List</:item>
<:item value="grid"><.icon name="hero-squares-2x2" /> Grid</:item>
<:item value="board"><.icon name="hero-view-columns" /> Board</:item>
</.toggle_group>
<.toggle_group
multiple
aria_label="Formatting"
value={["bold"]}
on_change={JS.toggle_attribute({"aria-pressed", "true", "false"})}
>
<:item value="bold" aria-label="Bold"><.icon name="hero-bold" /></:item>
<:item value="italic" aria-label="Italic"><.icon name="hero-italic" /></:item>
<:item value="underline" aria-label="Underline"><.icon name="hero-underline" /></:item>
</.toggle_group>
</div>
The device rail

The canonical icon-only use: a desktop / tablet / phone switcher above a preview frame, the strip every component playground puts in its toolbar. Icon-only items carry aria-labels so the options still read out. The playground's Try it section runs this one live against real content.

heex
<.toggle_group aria_label="Preview device" value="tablet" on_change="set_device">
<:item value="desktop" aria-label="Desktop"><.icon name="hero-computer-desktop" /></:item>
<:item value="tablet" aria-label="Tablet"><.icon name="hero-device-tablet" /></:item>
<:item value="mobile" aria-label="Phone"><.icon name="hero-device-phone-mobile" /></:item>
</.toggle_group>
Outline variant

The toolbar look: a bordered rail with floating chips, on the same border and wash ramp as outline buttons - compose it next to a button_group and the two read as kin. The wash rail suits settings and content; the outline rail suits chrome.

heex
<.toggle_group
variant="outline"
aria_label="Preview device"
value="desktop"
on_change="set_device"
>
<:item value="desktop" aria-label="Desktop"><.icon name="hero-computer-desktop" /></:item>
<:item value="tablet" aria-label="Tablet"><.icon name="hero-device-tablet" /></:item>
<:item value="mobile" aria-label="Phone"><.icon name="hero-device-phone-mobile" /></:item>
</.toggle_group>
Accent variant

The selection painted in the brand accent instead of a neutral chip. Same wash rail as solid, so it stays in the family - reach for it when the choice is a committed setting rather than a view preference. It follows your primary colour, so it retints with the theme.

heex
<.toggle_group variant="accent" aria_label="Billing period" value="annual" on_change="set_period">
<:item value="monthly">Monthly</:item>
<:item value="annual">Annual</:item>
</.toggle_group>
Icons with labels

Items take any content: icon plus text reads fastest for view switchers. The pressed chip and the wash come from the rail, so mixed content stays aligned without extra classes.

heex
<.toggle_group aria_label="View" value="grid" on_change="set_view">
<:item value="list"><.icon name="hero-list-bullet" /> List</:item>
<:item value="grid"><.icon name="hero-squares-2x2" /> Grid</:item>
<:item value="board"><.icon name="hero-view-columns" /> Board</:item>
</.toggle_group>
Sizes

Three sizes share the same radii math as the rest of the rail family, so a sm toggle group next to a scheme switch reads as kin. Disabled works per item or for the whole rail.

heex
<div class="flex flex-col items-start gap-4">
<.toggle_group aria_label="Page size small" size="sm" value="10" on_change="set_page_size">
<:item value="6">6</:item>
<:item value="10">10</:item>
<:item value="14">14</:item>
<:item value="full" disabled>Full</:item>
</.toggle_group>
<.toggle_group aria_label="Page size medium" size="md" value="10" on_change="set_page_size">
<:item value="6">6</:item>
<:item value="10">10</:item>
<:item value="14">14</:item>
</.toggle_group>
<.toggle_group aria_label="Page size large" size="lg" value="10" on_change="set_page_size">
<:item value="6">6</:item>
<:item value="10">10</:item>
<:item value="14">14</:item>
</.toggle_group>
</div>
Properties
Attribute Type Default Description
aria_label* string the ARIA label for the group
class any nil extra classes for the rail
disabled boolean false disables every item
id string nil
multiple boolean false treat value as a list; any number of options can be pressed
on_change any nil event name (or JS command) sent on press; the pressed option arrives in phx-value-toggle. Omit it and put phx-click on individual items instead
rest global
size string "md"
one of: "sm", "md", "lg"
value any nil the selected value - a single term, or a list when `multiple`
variant string "solid" solid is the wash rail with a neutral chip; outline is the bordered toolbar rail; accent paints the selection in the brand colour
one of: "solid", "outline", "accent"
:item slot slot