Frontile

SegmentedControl

A row (or column) of mutually exclusive options with a single indicator that slides between them. Use it in place of a small RadioGroup or a set of ToggleButtons whenever the choice is small, fixed, and always visible — a view switcher, a billing period, a density setting.

Import

import { SegmentedControl } from 'frontile';

Usage

@defaultValue picks the item that starts selected and the control tracks the rest itself, so the shortest working control needs no state and no handler. Click between the items below: the indicator slides.

import { SegmentedControl } from 'frontile';

<template>
  <SegmentedControl @defaultValue='week' aria-label='Date range' as |Ctl|>
    <Ctl.Item @value='day'>Day</Ctl.Item>
    <Ctl.Item @value='week'>Week</Ctl.Item>
    <Ctl.Item @value='month'>Month</Ctl.Item>
  </SegmentedControl>
</template>

Controlled and uncontrolled

The mode is decided by whether @value is passed, not by what it holds. Omit the argument entirely and the control is uncontrolled; write it at all — including @value={{undefined}}, or @value={{this.selection}} where selection happens to be undefined — and it is controlled. That is what lets a controlled control start with nothing selected and, later, be cleared back to nothing by assigning undefined.

Without @value the control is uncontrolled. @defaultValue seeds the initial selection, the control keeps the current one internally, and @onChange still fires on every pick — so you can observe the value without having to own it. That is the right default for a control whose selection nothing else drives.

Passing @value makes it controlled: the selection then only ever reflects what you pass, so pair it with @onChange and assign the new value back to your own state. Reach for it when something outside the control also sets the selection — a query parameter, a saved preference, a value you validate before accepting. @defaultValue is ignored in that mode.

Selected: week

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { SegmentedControl } from 'frontile';

export default class Example extends Component {
  @tracked range = 'week';

  onChange = (value: string): void => {
    this.range = value;
  };

  <template>
    <div class='flex flex-col items-start gap-3'>
      <SegmentedControl
        @value={{this.range}}
        @onChange={{this.onChange}}
        aria-label='Date range'
        as |Ctl|
      >
        <Ctl.Item @value='day'>Day</Ctl.Item>
        <Ctl.Item @value='week'>Week</Ctl.Item>
        <Ctl.Item @value='month'>Month</Ctl.Item>
      </SegmentedControl>

      <p class='text-body-sm text-neutral-strong'>Selected: {{this.range}}</p>
    </div>
  </template>
}

@value is compared against each item's @value with ===. Object values must be referentially stable, or every item will read as unselected; two items that share a value both render as selected, which is useful when the same choice has more than one entry point but otherwise worth avoiding.

Colors

@color colors the selection indicator, so the selected option carries the meaning rather than the control as a whole. default keeps the neutral raised pill; the rest tint it and switch the selected label to the matching contrast ink.

import { SegmentedControl } from 'frontile';

<template>
  <div class='flex flex-col items-start gap-3'>
    <SegmentedControl
      @defaultValue='week'
      @color='neutral'
      aria-label='Neutral color'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>
    <SegmentedControl
      @defaultValue='week'
      @color='primary'
      aria-label='Primary color'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>
    <SegmentedControl
      @defaultValue='week'
      @color='secondary'
      aria-label='Secondary color'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>
    <SegmentedControl
      @defaultValue='week'
      @color='tertiary'
      aria-label='Tertiary color'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>
    <SegmentedControl
      @defaultValue='week'
      @color='success'
      aria-label='Success color'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>
    <SegmentedControl
      @defaultValue='week'
      @color='warning'
      aria-label='Warning color'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>
    <SegmentedControl
      @defaultValue='week'
      @color='danger'
      aria-label='Danger color'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>
  </div>
</template>

Sizes

import { SegmentedControl } from 'frontile';

<template>
  <div class='flex flex-col items-start gap-3'>
    <SegmentedControl
      @defaultValue='week'
      @size='sm'
      aria-label='Small'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>
    <SegmentedControl
      @defaultValue='week'
      @size='md'
      aria-label='Medium'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>
    <SegmentedControl
      @defaultValue='week'
      @size='lg'
      aria-label='Large'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>
  </div>
</template>

Ghost

@variant='ghost' removes the recessed track, leaving only the indicator pill. Reach for it inline — next to a heading, inside a toolbar — where a solid track would compete with the surrounding content.

import { SegmentedControl } from 'frontile';

<template>
  <SegmentedControl
    @defaultValue='month'
    @variant='ghost'
    aria-label='Date range'
    as |Ctl|
  >
    <Ctl.Item @value='day'>Day</Ctl.Item>
    <Ctl.Item @value='week'>Week</Ctl.Item>
    <Ctl.Item @value='month'>Month</Ctl.Item>
  </SegmentedControl>
</template>

Separators

@hasSeparators={{true}} draws a hairline between neighbouring items. The line on either side of the selected item is hidden, so the indicator never appears to slide across a visible rule.

Four items with the first selected leaves two hairlines showing at rest. Click along the row and watch them wink out as the indicator arrives and return behind it.

import { SegmentedControl } from 'frontile';

<template>
  <SegmentedControl
    @defaultValue='day'
    @hasSeparators={{true}}
    aria-label='Date range'
    as |Ctl|
  >
    <Ctl.Item @value='day'>Day</Ctl.Item>
    <Ctl.Item @value='week'>Week</Ctl.Item>
    <Ctl.Item @value='month'>Month</Ctl.Item>
    <Ctl.Item @value='year'>Year</Ctl.Item>
  </SegmentedControl>
</template>

Full width

@isFullWidth={{true}} stretches the control to its container and gives every item equal width. Both controls below sit in the same 24rem panel: the first shrinks to its labels, the second fills the line.

import { SegmentedControl } from 'frontile';

<template>
  <div
    class='flex w-96 max-w-full flex-col items-start gap-3 rounded-lg border border-neutral-soft p-4'
  >
    <SegmentedControl @defaultValue='week' aria-label='Default width' as |Ctl|>
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>

    <SegmentedControl
      @defaultValue='week'
      @isFullWidth={{true}}
      aria-label='Full width'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>
  </div>
</template>

Vertical

@orientation='vertical' stacks the items in a column and switches the arrow keys that move between them to up/down. The track and the indicator swap their pill radius for a rounded rectangle, and every item takes the column's full width, so the indicator slides straight down instead of resizing at each stop — clearest when the labels differ in length.

import { SegmentedControl } from 'frontile';

<template>
  <SegmentedControl
    @defaultValue='week'
    @orientation='vertical'
    aria-label='Date range'
    as |Ctl|
  >
    <Ctl.Item @value='day'>Day</Ctl.Item>
    <Ctl.Item @value='week'>This week</Ctl.Item>
    <Ctl.Item @value='month'>Month to date</Ctl.Item>
    <Ctl.Item @value='year'>Year</Ctl.Item>
  </SegmentedControl>
</template>

Disabled

An individual item can be disabled with its own @isDisabled; keyboard navigation skips it and it cannot be clicked. @isDisabled on the control disables every item, which is why the second control below does not respond.

import { SegmentedControl } from 'frontile';

<template>
  <div class='flex flex-col items-start gap-3'>
    <SegmentedControl
      @defaultValue='day'
      aria-label='One item disabled'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week' @isDisabled={{true}}>Week</Ctl.Item>
      <Ctl.Item @value='month'>Month</Ctl.Item>
    </SegmentedControl>

    <SegmentedControl
      @defaultValue='day'
      @isDisabled={{true}}
      aria-label='Whole control disabled'
      as |Ctl|
    >
      <Ctl.Item @value='day'>Day</Ctl.Item>
      <Ctl.Item @value='week'>Week</Ctl.Item>
    </SegmentedControl>
  </div>
</template>

Icon and label

Each item yields isSelected, so a demo can show only an icon when unselected and add the label once selected — a compact idle state that expands to explain itself. Clicking between them is also the clearest look at the indicator resizing as it moves, not just translating.

import { SegmentedControl } from 'frontile';
import { ViewIcon, ComponentIcon, TargetIcon } from 'site/components/icons';

<template>
  <SegmentedControl @defaultValue='list' aria-label='Layout' as |Ctl|>
    <Ctl.Item @value='list' as |item|>
      <ViewIcon />
      {{if item.isSelected 'List'}}
    </Ctl.Item>
    <Ctl.Item @value='grid' as |item|>
      <ComponentIcon />
      {{if item.isSelected 'Grid'}}
    </Ctl.Item>
    <Ctl.Item @value='target' as |item|>
      <TargetIcon />
      {{if item.isSelected 'Focus'}}
    </Ctl.Item>
  </SegmentedControl>
</template>

Form mode

Passing @name switches items from <button role="radio"> to <label> wrapping a native, visually-hidden radio input under that name, so the control's value submits with an ordinary form post — no @onChange required, though one still fires. Keyboard behaviour also changes: a same-named native radio group already handles arrow keys and focus on its own, so the component steps aside rather than layering its own handling on top.

Form mode also constrains what an item's @value can usefully be. A form post carries strings, so the input's value attribute is String(value) — an object value submits as the literal [object Object], and a null as "null". @onChange still receives the original typed value, so use @name with string (or otherwise string-round-trippable) item values, and reach for button mode when the values are objects.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { SegmentedControl, Button } from 'frontile';

export default class Example extends Component {
  @tracked submitted = '';

  onSubmit = (event: SubmitEvent): void => {
    event.preventDefault();
    const data = new FormData(event.target as HTMLFormElement);
    this.submitted = String(data.get('range'));
  };

  <template>
    <form {{on 'submit' this.onSubmit}}>
      <SegmentedControl
        @defaultValue='week'
        @name='range'
        aria-label='Range'
        as |Ctl|
      >
        <Ctl.Item @value='day'>Day</Ctl.Item>
        <Ctl.Item @value='week'>Week</Ctl.Item>
        <Ctl.Item @value='month'>Month</Ctl.Item>
      </SegmentedControl>

      <div class='mt-3'>
        <Button @type='submit'>Submit</Button>
      </div>
    </form>

    {{#if this.submitted}}
      <p data-test-submitted>Submitted: {{this.submitted}}</p>
    {{/if}}
  </template>
}

A consumer that flips @value asynchronously — after a network round trip, say — will briefly see form mode's selected-label colour flip, revert, and flip again while the indicator itself only moves once, at the end. This is a side effect of re-asserting native checked state a frame after the click so it cannot disagree with a declined or absent @onChange; it is bounded to a single frame and only visible on an async @value.

Any interactive content yielded into an item ends up nested inside that item's <label> in form mode. A nested link or button becomes a click/activation hazard — clicking it also toggles the radio — so keep yielded content to text, icons, and other non-interactive markup in form mode.

Custom styling

Every slot is overridable through @classes, so a control can be restyled well past the built-in variants without dropping to a fork. The example below is a brand-styled toggle: an adaptive card-coloured track with a hairline border, a filled primary pill, and the brand color carried by the unselected labels instead of neutral ink.

Three of the four pieces come from arguments rather than overrides — @color='primary' fills the indicator and picks the contrast ink for the selected label, @isFullWidth={{true}} stretches the control and divides it evenly, and @size='lg' sets the padding. Only the track's surface and the label treatment need @classes.

text-neutral-bolder is the strongest neutral ink, which adapts with the theme, so the unselected labels stay legible on the card surface without hard-coding a colour per mode. font-label text-header-md overrides the size variant's own type step; because both live in Tailwind's font-size group, the @classes value is the one that wins.

import { SegmentedControl } from 'frontile';
import { hash } from '@ember/helper';

<template>
  {{! The demo preview container is shrink-to-fit, so a width has to be
      given explicitly for `@isFullWidth` to have anything to stretch into. }}
  <div class='w-[34rem] max-w-full'>
    <SegmentedControl
      @defaultValue='resident'
      @color='primary'
      @size='lg'
      @isFullWidth={{true}}
      @classes={{hash
        base='bg-surface-card border border-neutral-soft'
        item='text-neutral-bolder font-label text-header-md'
      }}
      aria-label='Account type'
      as |Ctl|
    >
      <Ctl.Item @value='resident'>Resident</Ctl.Item>
      <Ctl.Item @value='business'>Business</Ctl.Item>
    </SegmentedControl>
  </div>
</template>

Styling the selected and disabled items

@classes.item applies to every item, so an override that should only affect the selected one needs a modifier. Each item publishes two data attributes for this, in both rendering modes:

Attribute Values
data-selected "true" / "false"
data-disabled "true" / "false"

So data-[selected=true]: is the hook, and it is the same in button mode and form mode:

@classes={{hash item='text-primary data-[selected=true]:text-on-primary'}}

Reach for these rather than the underlying ARIA or DOM state. Button mode's item carries aria-checked and takes a native disabled, while form mode's item is a <label> whose sr-only input holds both — so aria-checked: and disabled: silently do nothing in form mode, and has-[:checked]: does nothing in button mode. The data attributes are the one hook that spans both. The theme itself is written against them for exactly this reason.

This also matches how the rest of Frontile exposes selection: Switch, Listbox, NativeSelect and Table all publish data-selected the same way.

Overriding @classes.indicator is how a different indicator shape is built — an underline rather than a pill, say. SelectionIndicator's own documentation covers the custom properties to position it with, and the one trap to avoid.

Accessibility

The control renders role="radiogroup" with aria-orientation, and each item is a radio (role="radio" in button mode, a native <input type="radio"> in form mode) — not a tab. Use SegmentedControl for a choice that only changes a value; once tabs exist, prefer them for options that each reveal their own panel.

The group needs an accessible name from the consumer: pass aria-label (as every demo above does) or aria-labelledby.

Key Behaviour
Tab Moves focus to the group. Only the selected item is a tab stop.
ArrowRight / ArrowDown Moves selection to the next enabled item, wrapping at the end.
ArrowLeft / ArrowUp Moves selection to the previous enabled item, wrapping at the start.
Home / End Moves selection to the first / last enabled item.

Horizontal orientation uses left/right, vertical orientation uses up/down. Disabled items are skipped entirely. In button mode, arrow navigation both moves focus and selects immediately (automatic activation), matching the native <input type="radio"> behaviour form mode gets for free — form mode does not implement this table itself; it defers to the browser's own same-named-radio-group keyboard handling instead of duplicating it, since fighting native radio semantics would only reintroduce the bugs the browser already solved.

API

SegmentedControl

Element: HTMLDivElement

Arguments

Name Type Default Description
classes SlotsToClasses<'base' | 'item' | 'indicator'> - Class names for each slot of the component, merged with the theme's.
color enum 'neutral' The colour applied to the selected item's indicator and label.
defaultValue T undefined Sets the initially selected value when the control is used uncontrolled (that is, when @value is not provided). Ignored in controlled mode.
hasSeparators boolean false Draws a hairline between neighbouring items, hidden around the selected one so the indicator never crosses a visible line.
isDisabled boolean false Disables every item. Individual items can be disabled with the item's own @isDisabled.
isFullWidth boolean false Stretches the control to its container and gives every item equal width.
name string - When set, items render as <label> wrapping a native radio input under this name, so the control submits with its form. Without it the control renders buttons and reports only through onChange.
onChange function - Called with the newly selected value when an item is chosen.
orientation enum 'horizontal' Lays the items out in a row or a column, and switches the arrow keys that move between them to match.
size enum 'md' The size of the control, driving item padding and text size.
value T -

The currently selected value. Compared against each item's @value with ===, so object values must be referentially stable.

Passing this argument at all puts the component in controlled mode -- passing it as undefined included, which is how a controlled control says "nothing is selected". The selection then only ever reflects what you pass, so pair it with @onChange and update your own state; setting it back to undefined clears the selection. Omit the argument entirely to let the control track the selection itself, seeded by @defaultValue.

variant enum 'solid' The visual style of the control's track and indicator.

Blocks

Name Type Default Description
default * Array -

SegmentedControlItem

Element: enum

Arguments

Name Type Default Description
context * Object - Supplied by SegmentedControl. Not part of the public API.
value * T - The value this item represents. Selecting it calls the control's @onChange with exactly this value.
class string - Class names appended to this item's theme classes.
isDisabled boolean false Disables this item alone: it cannot be clicked and keyboard navigation skips over it. The whole control can be disabled with the control's own @isDisabled.

Blocks

Name Type Default Description
default * Array -
Released under MIT License - Created by Josemar Luedke