Components
Segmented Control
A compact, horizontally-joined set of mutually exclusive options — List / Grid, Day / Week / Month. It is a native radio group in disguise: a <fieldset> wraps <input type="radio"> options that share a name, so arrow-key navigation and one-selected-at-a-time come for free. It selects a value, not a panel — that is what separates it from tabs.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/segmented-control.json2. Use it
import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control"
<SegmentedControl name="view" ariaLabel="View" defaultValue="list">
<SegmentedControlItem value="list" name="view" id="view-list" checked>List</SegmentedControlItem>
<SegmentedControlItem value="grid" name="view" id="view-grid">Grid</SegmentedControlItem>
</SegmentedControl>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Segmented control — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A compact, horizontally-joined set of mutually-exclusive options
// (List / Grid, Day / Week / Month). It selects a *value*, not a panel —
// that is what makes it a radio group rather than tabs. There is no
// tablist / tabpanel relationship here; picking a segment just changes a
// form value (and, optionally, fires an htmx request).
//
// Built on the native radio group: a <fieldset> groups the options, the
// <legend> names the group, and N <input type="radio"> share a `name` so
// the browser handles arrow-key navigation, roving focus, and
// one-selected-at-a-time for free — zero JS.
//
// Sources read while building this:
// - Settings UI pattern (grouped controls inside a <fieldset>, each
// option = label + appearance:none input styled via :checked):
// repos/web.dev/src/site/content/en/patterns/components/settings/index.md
// repos/web.dev/src/site/content/en/patterns/components/settings/assets/body.html
// repos/web.dev/src/site/content/en/patterns/components/settings/assets/style.css
// - Why a real grouped <input>, not a styled <div> (label association,
// keyboard + AT semantics come free):
// repos/web.dev/src/site/content/en/learn/forms/accessibility/index.md:50-66
// - Native radio behaviour (arrow keys move + select within a name group,
// only one :checked):
// repos/mdn/files/en-us/web/html/reference/elements/input/radio/index.md
// - APG radio group contract (Tab enters on the checked item; arrows move
// between items): repos/aria-practices/content/patterns/radio/
// - htmx v4 — change is the default trigger for inputs; wrap in a <form>
// to post on every pick:
// repos/htmx/www/src/content/reference/01-attributes/06-hx-trigger.md:39
//
// Style analogue: registry/ui/radio-group.tsx (same appearance-none +
// :checked approach) and registry/ui/tabs.tsx (the joined, muted-track look).
//
// Composition (mirrors shadcn's API shape):
// <SegmentedControl name="view" ariaLabel="View" defaultValue="list">
// <SegmentedControlItem value="list">List</SegmentedControlItem>
// <SegmentedControlItem value="grid">Grid</SegmentedControlItem>
// </SegmentedControl>
export type SegmentedControlSize = "default" | "sm"
type SegmentedControlProps = PropsWithChildren<{
// Shared radio name — every <SegmentedControlItem> inside reuses it so the
// browser groups them. Required.
name: string
// Value of the segment that starts selected. Pass the same string to the
// matching item's `value` (or just set `checked` on that item).
defaultValue?: string
size?: SegmentedControlSize
disabled?: boolean
// The whole control is a labelled group. Provide a visible label via the
// <legend> (ariaLabel renders one, visually hidden) or point at an existing
// element with ariaLabelledby.
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
class?: ClassValue
// htmx / data / aria attributes ride along onto the <fieldset>. Wrap the
// control in a <form hx-post … hx-trigger="change"> to persist the pick.
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
// The track: a muted, rounded, inline-flex bar — same visual language as the
// TabsList, but it holds radios instead of role="tab" buttons.
const trackBase =
"group/segmented inline-flex w-fit items-center gap-1 rounded-lg bg-muted p-[3px] text-muted-foreground " +
"has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50 " +
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
const trackSize: Record<SegmentedControlSize, string> = {
default: "h-9",
sm: "h-8 text-xs",
}
export function segmentedControlTrackClasses(opts?: {
size?: SegmentedControlSize
class?: ClassValue
}): string {
return cn(trackBase, trackSize[opts?.size ?? "default"], opts?.class)
}
export function SegmentedControl(props: SegmentedControlProps) {
const {
name,
defaultValue,
size = "default",
disabled,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
class: className,
children,
...rest
} = props
return (
<fieldset
data-slot="segmented-control"
data-name={name}
data-size={size}
data-default-value={defaultValue}
data-disabled={disabled ? "true" : undefined}
disabled={disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
class={segmentedControlTrackClasses({ size, class: className })}
{...rest}
>
{/* A <legend> names the group for assistive tech. We hide it visually
(sr-only) by default since the segment labels usually carry the
meaning; pass ariaLabel to populate it. */}
{ariaLabel ? (
<legend class="sr-only">{ariaLabel}</legend>
) : null}
{children}
</fieldset>
)
}
// Each segment: a <label> wrapping an appearance-none radio (the .peer) and
// the visible text. The label is what we paint; peer-checked promotes it to
// the "active" look (raised card on the muted track), exactly like the
// selected TabsTrigger — but driven purely by the native :checked state.
const itemBase =
"relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none " +
"hover:text-foreground " +
// The radio is visually collapsed but stays in the layout for hit-testing
// and as the .peer that styles the label.
"has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm " +
"dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground " +
"has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 " +
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
const inputBase =
"peer sr-only"
export function segmentedControlItemClasses(opts?: { class?: ClassValue }): string {
return cn(itemBase, opts?.class)
}
type SegmentedControlItemProps = PropsWithChildren<{
// Value submitted (and matched against the parent defaultValue) when this
// segment is selected.
value: string
// The parent SegmentedControl sets the shared name; pass it through when
// you render items outside the <SegmentedControl> wrapper.
name?: string
id?: string
checked?: boolean
defaultChecked?: boolean
disabled?: boolean
required?: boolean
ariaLabel?: string
class?: ClassValue
// htmx / data / aria attributes ride along onto the underlying <input>.
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
export function SegmentedControlItem(props: SegmentedControlItemProps) {
const {
value,
name,
id,
checked,
defaultChecked,
disabled,
required,
ariaLabel,
class: className,
children,
...rest
} = props
return (
<label
data-slot="segmented-control-item"
data-value={value}
class={segmentedControlItemClasses({ class: className })}
>
<input
type="radio"
class={inputBase}
value={value}
name={name}
id={id}
checked={checked}
// hono/jsx renders defaultChecked as the `checked` attribute on SSR.
// Keep both props so callers can use either spelling.
defaultChecked={defaultChecked}
disabled={disabled}
required={required}
aria-label={ariaLabel}
data-slot="segmented-control-input"
{...rest}
/>
<span data-slot="segmented-control-label">{children}</span>
</label>
)
}
1. Save the file
Copy segmented-control.html into templates/components/.
2. Use it
{% from "components/segmented-control.html" import segmented_control_open, segmented_control_close, segmented_control_item %}
{{ segmented_control_open(name="view", aria_label="View", default_value="list") }}
{{ segmented_control_item("List", value="list", name="view", id="view-list", checked=true) }}
{{ segmented_control_item("Grid", value="grid", name="view", id="view-grid") }}
{{ segmented_control_close() }}View source
{# SegmentedControl macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/segmented-control.tsx. A <fieldset> groups native
<input type="radio"> options sharing a `name`; the browser handles
arrow-key navigation + one-selected-at-a-time. Picks a value, not a panel.
Sources: web.dev settings pattern (grouped fieldset + appearance:none
inputs styled via :checked) and learn/forms/accessibility; APG radio
group. See the .tsx header for exact paths.
Usage:
{% from "components/segmented-control.html" import segmented_control_open, segmented_control_close, segmented_control_item %}
{{ segmented_control_open(name="view", aria_label="View", default_value="list") }}
{{ segmented_control_item("List", value="list", name="view", id="view-list", checked=true) }}
{{ segmented_control_item("Grid", value="grid", name="view", id="view-grid") }}
{{ segmented_control_close() }} #}
{% macro segmented_control_open(
name,
default_value=none,
size="default",
disabled=false,
aria_label=none,
aria_labelledby=none,
aria_describedby=none,
extra_class="",
**attrs
) -%}
{%- set track -%}
group/segmented inline-flex w-fit items-center gap-1 rounded-lg bg-muted p-[3px] text-muted-foreground has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 {% if size == "sm" %}h-8 text-xs{% else %}h-9{% endif %}
{%- endset -%}
<fieldset data-slot="segmented-control"
data-name="{{ name }}"
data-size="{{ size }}"
{%- if default_value %} data-default-value="{{ default_value }}"{% endif %}
{%- if disabled %} data-disabled="true" disabled{% endif %}
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
{%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
class="{{ track }} {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
{%- if aria_label %}<legend class="sr-only">{{ aria_label }}</legend>{% endif %}
{%- endmacro %}
{% macro segmented_control_close() %}</fieldset>{% endmacro %}
{% macro segmented_control_item(
text,
value,
name,
id=none,
checked=false,
disabled=false,
required=false,
aria_label=none,
extra_class="",
**attrs
) %}
{%- set item -%}
relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4
{%- endset -%}
<label data-slot="segmented-control-item" data-value="{{ value }}" class="{{ item }} {{ extra_class }}">
<input type="radio" class="peer sr-only"
value="{{ value }}" name="{{ name }}"
{%- if id %} id="{{ id }}"{% endif %}
{%- if checked %} checked{% endif %}
{%- if disabled %} disabled{% endif %}
{%- if required %} required{% endif %}
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
data-slot="segmented-control-input"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
<span data-slot="segmented-control-label">{{ text }}</span>
</label>
{% endmacro %}
1. Save the file
Add segmented-control.tmpl alongside your templates.
2. Use it
{{template "segmented_control" (dict
"Name" "view" "AriaLabel" "View" "DefaultValue" "list"
"Body" (htmlSafe `
{{template "segmented_control_item" (dict "Text" "List" "Value" "list" "Name" "view" "ID" "view-list" "Checked" true)}}
{{template "segmented_control_item" (dict "Text" "Grid" "Value" "grid" "Name" "view" "ID" "view-grid")}}`)
)}}View source
{{/*
SegmentedControl templates — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/segmented-control.tsx.
A <fieldset> groups native <input type="radio"> options sharing a `name`;
the browser handles arrow-key navigation + one-selected-at-a-time. Picks a
value, not a panel. Sources: web.dev settings pattern + learn/forms/
accessibility, APG radio group (see the .tsx header for exact paths).
Two templates:
- "segmented_control" — the role-bearing <fieldset> track (pass a
Body field containing the items).
- "segmented_control_item" — one segment (label + radio + text).
Usage:
{{template "segmented_control" (dict
"Name" "view" "AriaLabel" "View" "DefaultValue" "list"
"Body" (htmlSafe `
{{template "segmented_control_item" (dict "Text" "List" "Value" "list" "Name" "view" "ID" "view-list" "Checked" true)}}
{{template "segmented_control_item" (dict "Text" "Grid" "Value" "grid" "Name" "view" "ID" "view-grid")}}`)
)}}
*/}}
{{define "segmented_control"}}
{{- $size := or .Size "default" -}}
{{- $track := "group/segmented inline-flex w-fit items-center gap-1 rounded-lg bg-muted p-[3px] text-muted-foreground has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50" -}}
{{- if eq $size "sm"}}{{$track = printf "%s h-8 text-xs" $track}}{{else}}{{$track = printf "%s h-9" $track}}{{end -}}
<fieldset data-slot="segmented-control"
data-name="{{.Name}}"
data-size="{{$size}}"
{{- if .DefaultValue}} data-default-value="{{.DefaultValue}}"{{end}}
{{- if .Disabled}} data-disabled="true" disabled{{end}}
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
class="{{$track}}">
{{- if .AriaLabel}}<legend class="sr-only">{{.AriaLabel}}</legend>{{end}}
{{.Body}}
</fieldset>
{{end}}
{{define "segmented_control_item"}}
{{- $item := "relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" -}}
<label data-slot="segmented-control-item" data-value="{{.Value}}" class="{{$item}}">
<input type="radio" class="peer sr-only"
value="{{.Value}}" name="{{.Name}}"
{{- if .ID}} id="{{.ID}}"{{end}}
{{- if .Checked}} checked{{end}}
{{- if .Disabled}} disabled{{end}}
{{- if .Required}} required{{end}}
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
data-slot="segmented-control-input">
<span data-slot="segmented-control-label">{{.Text}}</span>
</label>
{{end}}
1. Save the file
Drop segmented_control.ex into lib/my_app_web/components/.
2. Use it
<.segmented_control name="view" aria-label="View" default_value="list">
<.segmented_control_item value="list" name="view" id="view-list" checked>List</.segmented_control_item>
<.segmented_control_item value="grid" name="view" id="view-grid">Grid</.segmented_control_item>
</.segmented_control>View source
defmodule ShadcnHtmx.Components.SegmentedControl do
@moduledoc """
Segmented control — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/segmented-control.tsx. A `<fieldset>` groups native
`<input type="radio">` options sharing a `name`, so the platform handles
arrow-key navigation + one-selected-at-a-time. It picks a *value*, not a
panel — that is what distinguishes it from tabs.
Sources read while building this:
- web.dev settings pattern (grouped fieldset + appearance:none inputs
styled via :checked): repos/web.dev/.../patterns/components/settings
- web.dev learn/forms/accessibility (real grouped input, not a styled
div): repos/web.dev/.../learn/forms/accessibility
- APG radio group: repos/aria-practices/content/patterns/radio/
## Examples
<.segmented_control name="view" aria-label="View" default_value="list">
<.segmented_control_item value="list" name="view" id="view-list" checked>List</.segmented_control_item>
<.segmented_control_item value="grid" name="view" id="view-grid">Grid</.segmented_control_item>
</.segmented_control>
"""
use Phoenix.Component
@track_base "group/segmented inline-flex w-fit items-center gap-1 rounded-lg bg-muted p-[3px] text-muted-foreground " <>
"has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50 " <>
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
@item_base "relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none " <>
"hover:text-foreground " <>
"has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm " <>
"dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground " <>
"has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 " <>
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
attr :name, :string, required: true
attr :default_value, :string, default: nil
attr :size, :string, default: "default", values: ["default", "sm"]
attr :disabled, :boolean, default: false
attr :class, :string, default: nil
attr :rest, :global, include: ~w(aria-label aria-labelledby aria-describedby)
slot :inner_block, required: true
def segmented_control(assigns) do
assigns =
assigns
|> assign(:track_base, @track_base)
|> assign(:size_class, if(assigns.size == "sm", do: "h-8 text-xs", else: "h-9"))
|> assign(:legend, assigns.rest["aria-label"])
~H"""
<fieldset
data-slot="segmented-control"
data-name={@name}
data-size={@size}
data-default-value={@default_value}
data-disabled={@disabled && "true"}
disabled={@disabled}
class={[@track_base, @size_class, @class]}
{@rest}
>
<legend :if={@legend} class="sr-only">{@legend}</legend>
{render_slot(@inner_block)}
</fieldset>
"""
end
attr :value, :string, required: true
attr :name, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global,
include: ~w(id checked disabled required form aria-label)
slot :inner_block, required: true
def segmented_control_item(assigns) do
assigns = assign(assigns, :item_base, @item_base)
~H"""
<label
data-slot="segmented-control-item"
data-value={@value}
class={[@item_base, @class]}
>
<input
type="radio"
class="peer sr-only"
value={@value}
name={@name}
data-slot="segmented-control-input"
{@rest}
/>
<span data-slot="segmented-control-label">{render_slot(@inner_block)}</span>
</label>
"""
end
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<fieldset data-slot="segmented-control" data-name="view" aria-label="View"
class="group/segmented inline-flex h-9 w-fit items-center gap-1 rounded-lg bg-muted p-[3px] …">
<legend class="sr-only">View</legend>
<label data-slot="segmented-control-item"
class="… has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm">
<input type="radio" class="peer sr-only" name="view" value="list" checked>
<span>List</span>
</label>
<!-- one <label> per segment -->
</fieldset>View source
<!--
shadcn-htmx — raw HTML segmented control snippet.
Mirrors registry/ui/segmented-control.tsx. A <fieldset> groups native
<input type="radio"> options sharing a `name` — the browser handles
arrow-key navigation between siblings in the same group, auto-activates on
focus, and only one can be :checked at a time. It selects a *value*, not a
panel (that is what makes it a radio group, not tabs). Zero JS.
The visible "pill" is the <label>; the radio is sr-only and acts as the
.peer / has() target that promotes the checked label to the raised look.
Sources: web.dev settings pattern (grouped fieldset + appearance:none
inputs styled via :checked) + learn/forms/accessibility; APG radio group.
TRACK base:
group/segmented inline-flex w-fit items-center gap-1 rounded-lg bg-muted
p-[3px] text-muted-foreground h-9
has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50
data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50
ITEM (label) base:
relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center
justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm
font-medium whitespace-nowrap text-foreground/60 transition-all select-none
hover:text-foreground
has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm
dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground
has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50
-->
<fieldset data-slot="segmented-control" data-name="view" data-size="default" data-default-value="list"
aria-label="View"
class="group/segmented inline-flex h-9 w-fit items-center gap-1 rounded-lg bg-muted p-[3px] text-muted-foreground has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50">
<legend class="sr-only">View</legend>
<label data-slot="segmented-control-item" data-value="list"
class="relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<input type="radio" class="peer sr-only" value="list" name="view" id="view-list" checked data-slot="segmented-control-input">
<span data-slot="segmented-control-label">List</span>
</label>
<label data-slot="segmented-control-item" data-value="grid"
class="relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<input type="radio" class="peer sr-only" value="grid" name="view" id="view-grid" data-slot="segmented-control-input">
<span data-slot="segmented-control-label">Grid</span>
</label>
<label data-slot="segmented-control-item" data-value="board"
class="relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<input type="radio" class="peer sr-only" value="board" name="view" id="view-board" disabled data-slot="segmented-control-input">
<span data-slot="segmented-control-label">Board</span>
</label>
</fieldset>
Examples
Basic — pick a view
Tab into the control (focus lands on the checked segment), then press ←/→ to move + select. The browser groups the radios by their shared name.
This is a native radio group styled as a joined bar. The visible pill is the <label>; the radio inside it is sr-only and drives the active look via the has-[:checked] variant. Because the radios share a name, the platform supplies roving focus, arrow-key selection, and the one-at-a-time invariant. No JavaScript.
<SegmentedControl name="view" ariaLabel="View" defaultValue="list">
<SegmentedControlItem value="list" name="view" id="list" checked>List</SegmentedControlItem>
<SegmentedControlItem value="grid" name="view" id="grid">Grid</SegmentedControlItem>
<SegmentedControlItem value="board" name="view" id="board">Board</SegmentedControlItem>
</SegmentedControl>{{ segmented_control_open(name="view", aria_label="View", default_value="list") }}
{{ segmented_control_item("List", value="list", name="view", id="list", checked=true) }}
{{ segmented_control_item("Grid", value="grid", name="view", id="grid") }}
{{ segmented_control_item("Board", value="board", name="view", id="board") }}
{{ segmented_control_close() }}{{template "segmented_control" (dict "Name" "view" "AriaLabel" "View" "DefaultValue" "list"
"Body" (htmlSafe `
{{template "segmented_control_item" (dict "Text" "List" "Value" "list" "Name" "view" "ID" "list" "Checked" true)}}
{{template "segmented_control_item" (dict "Text" "Grid" "Value" "grid" "Name" "view" "ID" "grid")}}
{{template "segmented_control_item" (dict "Text" "Board" "Value" "board" "Name" "view" "ID" "board")}}`))}}<.segmented_control name="view" aria-label="View" default_value="list">
<.segmented_control_item value="list" name="view" id="list" checked>List</.segmented_control_item>
<.segmented_control_item value="grid" name="view" id="grid">Grid</.segmented_control_item>
<.segmented_control_item value="board" name="view" id="board">Board</.segmented_control_item>
</.segmented_control><fieldset data-slot="segmented-control" data-name="ex-sc-view" data-size="default" data-default-value="list" aria-label="View" class="group/segmented inline-flex w-fit items-center gap-1 rounded-lg bg-muted p-[3px] text-muted-foreground has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 h-9">
<legend class="sr-only">View</legend>
<label data-slot="segmented-control-item" data-value="list" class="relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<input type="radio" class="peer sr-only" value="list" name="ex-sc-view" id="ex-sc-list" checked="" data-slot="segmented-control-input"/>
<span data-slot="segmented-control-label">List</span>
</label>
<label data-slot="segmented-control-item" data-value="grid" class="relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<input type="radio" class="peer sr-only" value="grid" name="ex-sc-view" id="ex-sc-grid" data-slot="segmented-control-input"/>
<span data-slot="segmented-control-label">Grid</span>
</label>
<label data-slot="segmented-control-item" data-value="board" class="relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<input type="radio" class="peer sr-only" value="board" name="ex-sc-view" id="ex-sc-board" data-slot="segmented-control-input"/>
<span data-slot="segmented-control-label">Board</span>
</label>
</fieldset>Further reading
Small size + disabled option
Use size="sm" for toolbars. Disable a single segment with the disabled attribute — arrow keys skip over it automatically.
A disabled segment carries the native disabled attribute on its radio, so the browser removes it from the group's roving sequence — pressing the arrow keys jumps past it. To disable the whole control, set disabled on the <SegmentedControl> — it renders a disabled <fieldset>, which natively disables every control inside it.
<SegmentedControl name="range" ariaLabel="Date range" size="sm" defaultValue="week">
<SegmentedControlItem value="day" name="range" id="day">Day</SegmentedControlItem>
<SegmentedControlItem value="week" name="range" id="week" checked>Week</SegmentedControlItem>
<SegmentedControlItem value="month" name="range" id="month">Month</SegmentedControlItem>
<SegmentedControlItem value="year" name="range" id="year" disabled>Year</SegmentedControlItem>
</SegmentedControl>{{ segmented_control_open(name="range", aria_label="Date range", size="sm", default_value="week") }}
{{ segmented_control_item("Day", value="day", name="range", id="day") }}
{{ segmented_control_item("Week", value="week", name="range", id="week", checked=true) }}
{{ segmented_control_item("Month", value="month", name="range", id="month") }}
{{ segmented_control_item("Year", value="year", name="range", id="year", disabled=true) }}
{{ segmented_control_close() }}{{template "segmented_control" (dict "Name" "range" "AriaLabel" "Date range" "Size" "sm" "DefaultValue" "week"
"Body" (htmlSafe `
{{template "segmented_control_item" (dict "Text" "Day" "Value" "day" "Name" "range" "ID" "day")}}
{{template "segmented_control_item" (dict "Text" "Week" "Value" "week" "Name" "range" "ID" "week" "Checked" true)}}
{{template "segmented_control_item" (dict "Text" "Month" "Value" "month" "Name" "range" "ID" "month")}}
{{template "segmented_control_item" (dict "Text" "Year" "Value" "year" "Name" "range" "ID" "year" "Disabled" true)}}`))}}<.segmented_control name="range" aria-label="Date range" size="sm" default_value="week">
<.segmented_control_item value="day" name="range" id="day">Day</.segmented_control_item>
<.segmented_control_item value="week" name="range" id="week" checked>Week</.segmented_control_item>
<.segmented_control_item value="month" name="range" id="month">Month</.segmented_control_item>
<.segmented_control_item value="year" name="range" id="year" disabled>Year</.segmented_control_item>
</.segmented_control><fieldset data-slot="segmented-control" data-name="ex-sc-range" data-size="sm" data-default-value="week" aria-label="Date range" class="group/segmented inline-flex w-fit items-center gap-1 rounded-lg bg-muted p-[3px] text-muted-foreground has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 h-8 text-xs">
<legend class="sr-only">Date range</legend>
<label data-slot="segmented-control-item" data-value="day" class="relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<input type="radio" class="peer sr-only" value="day" name="ex-sc-range" id="ex-sc-day" data-slot="segmented-control-input"/>
<span data-slot="segmented-control-label">Day</span>
</label>
<label data-slot="segmented-control-item" data-value="week" class="relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<input type="radio" class="peer sr-only" value="week" name="ex-sc-range" id="ex-sc-week" checked="" data-slot="segmented-control-input"/>
<span data-slot="segmented-control-label">Week</span>
</label>
<label data-slot="segmented-control-item" data-value="month" class="relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<input type="radio" class="peer sr-only" value="month" name="ex-sc-range" id="ex-sc-month" data-slot="segmented-control-input"/>
<span data-slot="segmented-control-label">Month</span>
</label>
<label data-slot="segmented-control-item" data-value="year" class="relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<input type="radio" class="peer sr-only" value="year" name="ex-sc-range" id="ex-sc-year" disabled="" data-slot="segmented-control-input"/>
<span data-slot="segmented-control-label">Year</span>
</label>
</fieldset>Further reading
htmx — switch view server-side
Wrap the control in a form. change is the default htmx trigger for inputs, so every pick posts the new value and swaps the rendered view in.
Segmented controls shine for view switchers. Put the control in a <form> with hx-post and hx-trigger="change" — selecting a segment submits the radio's value and the server renders the matching view into the target. (htmx v4's default trigger for form inputs is already change.)
<form hx-post="/api/layout" hx-trigger="change"
hx-target="#result" hx-swap="innerHTML">
<SegmentedControl name="layout" ariaLabel="Layout" defaultValue="list">
<SegmentedControlItem value="list" name="layout" id="list" checked>List</SegmentedControlItem>
<SegmentedControlItem value="grid" name="layout" id="grid">Grid</SegmentedControlItem>
</SegmentedControl>
<div id="result" aria-live="polite" />
</form><form hx-post="/api/layout" hx-trigger="change"
hx-target="#result" hx-swap="innerHTML">
{{ segmented_control_open(name="layout", aria_label="Layout", default_value="list") }}
{{ segmented_control_item("List", value="list", name="layout", id="list", checked=true) }}
{{ segmented_control_item("Grid", value="grid", name="layout", id="grid") }}
{{ segmented_control_close() }}
<div id="result" aria-live="polite"></div>
</form><form hx-post="/api/layout" hx-trigger="change"
hx-target="#result" hx-swap="innerHTML">
{{template "segmented_control" (dict "Name" "layout" "AriaLabel" "Layout" "DefaultValue" "list"
"Body" (htmlSafe `
{{template "segmented_control_item" (dict "Text" "List" "Value" "list" "Name" "layout" "ID" "list" "Checked" true)}}
{{template "segmented_control_item" (dict "Text" "Grid" "Value" "grid" "Name" "layout" "ID" "grid")}}`))}}
<div id="result" aria-live="polite"></div>
</form><form hx-post={~p"/api/layout"} hx-trigger="change"
hx-target="#result" hx-swap="innerHTML">
<.segmented_control name="layout" aria-label="Layout" default_value="list">
<.segmented_control_item value="list" name="layout" id="list" checked>List</.segmented_control_item>
<.segmented_control_item value="grid" name="layout" id="grid">Grid</.segmented_control_item>
</.segmented_control>
<div id="result" aria-live="polite"></div>
</form><form hx-post="/segmented-control/view" hx-trigger="change" hx-target="#ex-sc-result" hx-swap="innerHTML" class="space-y-4">
<fieldset data-slot="segmented-control" data-name="layout" data-size="default" data-default-value="list" aria-label="Layout" class="group/segmented inline-flex w-fit items-center gap-1 rounded-lg bg-muted p-[3px] text-muted-foreground has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 h-9">
<legend class="sr-only">Layout</legend>
<label data-slot="segmented-control-item" data-value="list" class="relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<input type="radio" class="peer sr-only" value="list" name="layout" id="ex-sc-h-list" checked="" data-slot="segmented-control-input"/>
<span data-slot="segmented-control-label">List</span>
</label>
<label data-slot="segmented-control-item" data-value="grid" class="relative inline-flex h-[calc(100%-2px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all select-none hover:text-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm dark:has-[:checked]:border-input dark:has-[:checked]:bg-input/30 dark:has-[:checked]:text-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<input type="radio" class="peer sr-only" value="grid" name="layout" id="ex-sc-h-grid" data-slot="segmented-control-input"/>
<span data-slot="segmented-control-label">Grid</span>
</label>
</fieldset>
<div id="ex-sc-result" class="rounded-md border bg-card p-4 text-sm text-muted-foreground" aria-live="polite">
Showing the
<strong>list</strong>
layout.
</div>
</form>Further reading
API Reference
<SegmentedControl>
| Prop | Type | Default | Description |
|---|---|---|---|
name* | string | — | Shared name attribute. Every SegmentedControlItem reuses it so the browser groups the radios. |
defaultValue | string | — | Value of the segment that starts selected. Match it to one item's value (or set checked on that item). |
size | "default"|"sm" | "default" | Track height + text size. Use sm for toolbars. |
disabled | boolean | false | Disable the whole control. Renders a disabled <fieldset>, which natively disables every radio inside. |
value* | string | — | On SegmentedControlItem: the value submitted when that segment is selected and matched against defaultValue. |
checked | boolean | — | On SegmentedControlItem: pre-select this segment. |
required | boolean | — | On SegmentedControlItem: native required, making the whole name-group required. |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required