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 { SegmentedControl } from 'frontile';
@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>
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.
@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>
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>
@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>
@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>
@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>
@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>
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>
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>
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
@valueasynchronously — 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 nativecheckedstate 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.
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>
@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.
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.
Element: HTMLDivElement
| 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 Passing this argument at all puts the component in controlled mode --
passing it as |
variant
|
enum
|
'solid'
|
The visual style of the control's track and indicator. |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: enum
| 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.
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |