shshadcn-htmx

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.json

2. Use it

components/ui/segmented-control.tsx
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
components/ui/segmented-control.tsx
/** @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

templates/components/segmented-control.html
{% 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
templates/components/segmented-control.html
{# 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

components/segmented-control.tmpl
{{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
components/segmented-control.tmpl
{{/*
  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

lib/my_app_web/components/segmented_control.ex
<.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
lib/my_app_web/components/segmented_control.ex
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

snippets/segmented-control.html
<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
snippets/segmented-control.html
<!--
  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.

View
<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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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>

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.

Date range
<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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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.)

Layout
Showing the list layout.
<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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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>

API Reference

<SegmentedControl>

PropTypeDefaultDescription
name*string
Shared name attribute. Every SegmentedControlItem reuses it so the browser groups the radios.
defaultValuestring
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.
disabledbooleanfalse
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.
checkedboolean
On SegmentedControlItem: pre-select this segment.
requiredboolean
On SegmentedControlItem: native required, making the whole name-group required.
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
ariaDescribedbystring
Id of an element describing this control (announced after the name).MDNaria-describedby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference

* required