Navigation

v0.0.4
Forms

Segmented control

A few options in one well; a sheet glides to the current one.

On this page

A segmented control is a well holding a few mutually exclusive options; the current one rises as a sheet. The track's radius is the option's radius plus its 4px inset, so the corners stay concentric, and each size is exactly as tall as the matching button.

Each option is a segmented-item. When the choice is a value, make it a label around a native radio with segmented-input, so the group keeps arrow-key navigation and works in forms:

Segmented control

<div class="segmented" role="radiogroup" aria-label="View">  <label class="segmented-item">    <input type="radio" class="segmented-input" name="view" value="list" checked>List</label>  <label class="segmented-item">    <input type="radio" class="segmented-input" name="view" value="board">Board</label>  <label class="segmented-item">    <input type="radio" class="segmented-input" name="view" value="timeline">Timeline</label></div>

#The gliding thumb

Where the browser supports CSS anchor positioning, the raised sheet is one thumb that travels to the current option instead of blinking from one to the next. The current option names itself as an anchor, the track scopes that name to itself with anchor-scope, and a single pseudo-element is drawn on the anchor's edges; when the choice changes, those edges transition. No script measures anything, and several segmented controls on a page do not interfere. Elsewhere, and with reduced motion, the current option carries the sheet itself and changes at once.

#As toggle buttons

When each option triggers an action, use buttons with aria-pressed and update it from your script. Links to sibling pages use aria-current="page".

Toggle buttons

<div class="segmented" role="group" aria-label="Text alignment">  <button type="button" class="segmented-item" aria-pressed="true">Left</button>  <button type="button" class="segmented-item" aria-pressed="false">Center</button>  <button type="button" class="segmented-item" aria-pressed="false">Right</button></div>

#Sizes

Sizes

<div class="segmented segmented-sm" role="radiogroup" aria-label="Small">  <label class="segmented-item">    <input type="radio" class="segmented-input" name="small" checked>Day</label>  <label class="segmented-item">    <input type="radio" class="segmented-input" name="small">Week</label>  <label class="segmented-item">    <input type="radio" class="segmented-input" name="small">Month</label></div><div class="segmented" role="radiogroup" aria-label="Medium">  <label class="segmented-item">    <input type="radio" class="segmented-input" name="medium" checked>Day</label>  <label class="segmented-item">    <input type="radio" class="segmented-input" name="medium">Week</label>  <label class="segmented-item">    <input type="radio" class="segmented-input" name="medium">Month</label></div><div class="segmented segmented-lg" role="radiogroup" aria-label="Large">  <label class="segmented-item">    <input type="radio" class="segmented-input" name="large" checked>Day</label>  <label class="segmented-item">    <input type="radio" class="segmented-input" name="large">Week</label>  <label class="segmented-item">    <input type="radio" class="segmented-input" name="large">Month</label></div>

#With icons

With icons

<div class="segmented" role="radiogroup" aria-label="Theme">  <label class="segmented-item">    <input type="radio" class="segmented-input" name="theme" checked>    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">      <circle cx="12" cy="12" r="4" />      <path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />    </svg>Light</label>  <label class="segmented-item">    <input type="radio" class="segmented-input" name="theme">    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">      <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />    </svg>Dark</label></div>

#Classes

ClassDescription
segmentedThe well around the options; 38px tall, matching a button
segmented-itemAn option: a button with aria-pressed, a link with aria-current, or a label around a segmented-input radio
segmented-inputThe radio inside a label option; it covers the option invisibly, so the label is the target and the radio keeps the state
segmented-smA 32px track; the default is 38px. With segmented
segmented-lgA 44px track; the default is 38px. With segmented

The current option has aria-pressed="true", aria-selected="true" or aria-current="page", or holds a checked segmented-input.

#Source

@master/ui/components/segmented.css
/* * Segmented control: a well holding a few options; the current one rises as a sheet. * The track's padding is the inset around each option, and its radius is the option's radius plus * that inset, so the corners stay concentric. Total heights match buttons: 32, 38 and 44px. * With anchor positioning the sheet is one thumb that glides to the current option; elsewhere the * current option carries the sheet itself. */@layer components {  .segmented {    --segmented-inset: 4px;    --segmented-item-height: 1.875rem;    --segmented-item-radius: var(--radius-control-sm);    --segmented-font-size: .8125rem;    --icon-size: .875rem;    display: inline-flex;    flex-wrap: wrap;    gap: 2px;    max-width: 100%;    padding: var(--segmented-inset);    border-radius: calc(var(--segmented-item-radius) * var(--corner-scale) + var(--segmented-inset));    corner-shape: var(--corner-shape);    background: var(--color-surface-inset);    box-shadow: var(--shadow-pressed);    &.segmented-sm {      --segmented-inset: 3px;      --segmented-item-height: 1.625rem;      --segmented-item-radius: var(--radius-chip);      --segmented-font-size: .75rem;      --icon-size: .75rem;    }    &.segmented-lg {      --segmented-item-height: 2.25rem;      --segmented-item-radius: var(--radius-control);      --segmented-font-size: .875rem;      --icon-size: 1rem;    }    @media (forced-colors: active) {      outline: 1px solid CanvasText;      outline-offset: -1px;    }  }  /* An option: a button with aria-pressed, a link with aria-current, or a label around a .segmented-input radio. */  .segmented-item {    position: relative;    display: inline-flex;    flex: 1 0 auto;    align-items: center;    justify-content: center;    gap: .375rem;    min-width: 3.5rem;    min-height: var(--segmented-item-height);    margin: 0;    padding: 0 .75rem;    border: 0;    border-radius: calc(var(--segmented-item-radius) * var(--corner-scale));    corner-shape: var(--corner-shape);    background: transparent;    color: var(--color-text-muted);    font: var(--font-weight-control) var(--segmented-font-size)/1 var(--font-family-sans);    font-feature-settings: inherit;    text-decoration: none;    white-space: nowrap;    appearance: none;    user-select: none;    -webkit-user-select: none;    -webkit-tap-highlight-color: transparent;    cursor: pointer;    touch-action: manipulation;    /* The raised face swaps instantly; only ink and depth ease, so no frame shows a half-transparent face. */    transition-property: color, box-shadow;    transition-duration: var(--duration-quick);    transition-timing-function: var(--easing-glide);    @media (hover: hover) {      &:hover {        color: var(--color-text-strong);      }    }    /* Pressed: the strong ink, as under the pointer, so a tap is answered too. */    &:active {      color: var(--color-text-strong);    }    &:is([aria-pressed="true"], [aria-selected="true"], [aria-current="page"], :has(.segmented-input:checked)) {      @apply --surface-sheet;      color: var(--color-text-strong);      box-shadow: var(--shadow-raised);    }    &:is(:focus-visible, :has(.segmented-input:focus-visible)) {      @apply --focus-ring;      z-index: 1;    }    &:is(:disabled, [aria-disabled="true"], :has(.segmented-input:disabled)) {      color: var(--color-text-disabled);      cursor: not-allowed;    }    /* On touch the track is 44px at every size, so it still matches the button beside it. */    @media (pointer: coarse) {      min-height: max(var(--segmented-item-height), calc(2.75rem - var(--segmented-inset) * 2));    }    @media (forced-colors: active) {      &:is([aria-pressed="true"], [aria-selected="true"], [aria-current="page"], :has(.segmented-input:checked)) {        outline: 2px solid Highlight;        outline-offset: -2px;      }    }  }  /* The radio inside an option covers it invisibly, so the label is the target and the radio keeps its semantics. */  .segmented-input {    position: absolute;    inset: 0;    width: 100%;    height: 100%;    margin: 0;    padding: 0;    border: 0;    border-radius: inherit;    opacity: 0;    appearance: none;    cursor: inherit;  }  /*   * The gliding thumb. The current option names itself as an anchor; the track scopes that name to itself and   * draws one sheet on the anchor's edges. When the choice changes the edges transition, so the sheet travels   * between options instead of blinking from one to the next.   */  @supports (anchor-scope: --segmented-thumb) {    .segmented {      position: relative;      anchor-scope: --segmented-thumb;      /* :has() cannot nest, so the track asks for a current option or a checked radio directly. */      &:has(.segmented-item:is([aria-pressed="true"], [aria-selected="true"], [aria-current="page"]), .segmented-input:checked)::before {        content: '';        position: absolute;        position-anchor: --segmented-thumb;        inset: anchor(top) anchor(right) anchor(bottom) anchor(left);        border-radius: calc(var(--segmented-item-radius) * var(--corner-scale));        corner-shape: var(--corner-shape);        @apply --surface-sheet;        box-shadow: var(--shadow-raised);        pointer-events: none;        transition-property: top, right, bottom, left;        transition-duration: var(--duration-base);        transition-timing-function: var(--easing-glide);        @media (prefers-reduced-motion: reduce) {          transition: none;        }      }      /* In forced colors the current option draws its own outline; the thumb has nothing to add. */      @media (forced-colors: active) {        &::before {          display: none;        }      }    }    .segmented-item:is([aria-pressed="true"], [aria-selected="true"], [aria-current="page"], :has(.segmented-input:checked)) {      anchor-name: --segmented-thumb;      background: none;      box-shadow: none;    }  }}