A set of panels shown one at a time, with an indicator that slides between the tabs. Use it when the content belongs to one page and switching between it should not change the URL — for navigation between routes, reach for TabNav instead.
import { Tabs } from 'frontile';
@defaultValue picks the tab that starts selected, and Tabs tracks the rest
itself. t.List groups the tabs and needs an accessible @label; each
t.Panel is paired with a t.Tab of the same @value and only the selected
one renders.
import { Tabs } from 'frontile';
<template>
<div class='demo-stack items-center'>
<Tabs @defaultValue='account' as |t|>
<t.List @label='Settings'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
<t.Tab @value='billing'>Billing</t.Tab>
</t.List>
<t.Panel @value='account'>Update your name, email, and photo.</t.Panel>
<t.Panel @value='security'>Manage passwords and two-factor auth.</t.Panel>
<t.Panel @value='billing'>View invoices and update your plan.</t.Panel>
</Tabs>
</div>
</template>
The mode is decided by whether @value is passed, not by what it holds.
Omit the argument entirely and Tabs is uncontrolled; write it at all —
including @value={{undefined}} — and it is controlled.
Without @value, @defaultValue seeds the initial selection and Tabs keeps
the current one internally, while @onChange still fires on every pick so you
can observe the value without owning it. Passing @value makes the selection
reflect only what you pass, so pair it with @onChange and assign the new
value back to your own state — reach for this when something outside Tabs
also drives the selection, such as a query parameter.
Selected: account
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Tabs } from 'frontile';
export default class Example extends Component {
@tracked section = 'account';
onChange = (value: string): void => {
this.section = value;
};
<template>
<div class='demo-stack items-center'>
<Tabs @value={{this.section}} @onChange={{this.onChange}} as |t|>
<t.List @label='Settings'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
</t.List>
<t.Panel @value='account'>Account panel</t.Panel>
<t.Panel @value='security'>Security panel</t.Panel>
</Tabs>
<p class='text-body-sm text-neutral-strong'>Selected: {{this.section}}</p>
</div>
</template>
}
@variant='solid' (the default) renders a filled, pill-shaped indicator on a
recessed track. @variant='underline' drops the track and pins a thin bar to
the leading edge instead — reach for it in a denser layout, such as a page
header, where the solid track would compete with surrounding content.
import { Tabs } from 'frontile';
<template>
<div class='demo-stack items-center'>
<Tabs @defaultValue='account' @variant='solid' as |t|>
<t.List @label='Solid'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
<t.Tab @value='billing'>Billing</t.Tab>
</t.List>
</Tabs>
<Tabs @defaultValue='account' @variant='underline' as |t|>
<t.List @label='Underline'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
<t.Tab @value='billing'>Billing</t.Tab>
</t.List>
</Tabs>
</div>
</template>
@color colors the indicator, so the selected tab carries the meaning
rather than the whole component. default keeps the neutral fill; the rest
tint it and switch the selected label to the matching contrast ink in the
solid variant.
import { Tabs } from 'frontile';
import { array } from '@ember/helper';
<template>
<div class='demo-stack items-center'>
{{#each
(array
'default' 'primary' 'secondary' 'tertiary' 'success' 'warning' 'danger'
)
as |intent|
}}
<Tabs @defaultValue='account' @color={{intent}} as |t|>
<t.List @label='{{intent}} intent'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
</t.List>
</Tabs>
{{/each}}
</div>
</template>
import { Tabs } from 'frontile';
import { array } from '@ember/helper';
<template>
<div class='demo-stack items-center'>
{{#each (array 'sm' 'md' 'lg') as |size|}}
<Tabs @defaultValue='account' @size={{size}} as |t|>
<t.List @label='{{size}} size'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
</t.List>
</Tabs>
{{/each}}
</div>
</template>
@orientation='vertical' stacks the tabs in a column, switches the arrow
keys that move between them to up/down, and lays the panel out beside the
list rather than beneath it.
import { Tabs } from 'frontile';
<template>
<div class='demo-stack items-center'>
<Tabs @defaultValue='account' @orientation='vertical' as |t|>
<t.List @label='Settings'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
<t.Tab @value='billing'>Billing</t.Tab>
</t.List>
<t.Panel @value='account'>Update your name, email, and photo.</t.Panel>
<t.Panel @value='security'>Manage passwords and two-factor auth.</t.Panel>
<t.Panel @value='billing'>View invoices and update your plan.</t.Panel>
</Tabs>
</div>
</template>
Both variants work vertically. underline pins its bar to the inline start
edge and runs the rule down the side of the list, so it suits a settings
sidebar where a filled track would compete with the panel beside it.
import { Tabs } from 'frontile';
<template>
<div class='demo-stack items-center'>
<Tabs
@defaultValue='account'
@orientation='vertical'
@variant='underline'
as |t|
>
<t.List @label='Settings'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
<t.Tab @value='billing'>Billing</t.Tab>
</t.List>
<t.Panel @value='account'>Update your name, email, and photo.</t.Panel>
<t.Panel @value='security'>Manage passwords and two-factor auth.</t.Panel>
<t.Panel @value='billing'>View invoices and update your plan.</t.Panel>
</Tabs>
</div>
</template>
@isFullWidth={{true}} stretches the tab list to its container and gives
every tab equal width.
import { Tabs } from 'frontile';
<template>
<div class='demo-stack items-center'>
<div
class='flex w-96 max-w-full flex-col items-start gap-3 rounded-lg border border-neutral-soft p-4'
>
<Tabs @defaultValue='account' as |t|>
<t.List @label='Default width'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
</t.List>
</Tabs>
<Tabs @defaultValue='account' @isFullWidth={{true}} as |t|>
<t.List @label='Full width'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
</t.List>
</Tabs>
</div>
</div>
</template>
An individual tab can be disabled with its own @isDisabled; keyboard
navigation skips it and it cannot be clicked or selected. @isDisabled on
Tabs disables every tab.
import { Tabs } from 'frontile';
<template>
<div class='demo-stack items-center'>
<Tabs @defaultValue='account' as |t|>
<t.List @label='Settings'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='billing' @isDisabled={{true}}>Billing</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
</t.List>
</Tabs>
</div>
</template>
@activationMode='manual' changes what the arrow keys do: they move focus
between tabs without selecting them, and the focused tab is only selected on
Enter or Space. The default, automatic, selects a tab as soon as focus
reaches it. Reach for manual when selecting a tab is expensive enough — a
network request, a heavy re-render — that arrowing past several tabs
shouldn't trigger it each time.
import { Tabs } from 'frontile';
<template>
<div class='demo-stack items-center'>
<Tabs @defaultValue='account' @activationMode='manual' as |t|>
<t.List @label='Settings'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
<t.Tab @value='billing'>Billing</t.Tab>
</t.List>
<t.Panel @value='account'>Account panel</t.Panel>
<t.Panel @value='security'>Security panel</t.Panel>
<t.Panel @value='billing'>Billing panel</t.Panel>
</Tabs>
</div>
</template>
Every tab publishes data-selected ("true" / "false") and
data-disabled, so a @classes.tab override that should only affect the
selected tab needs a data-[selected=true]: modifier rather than reaching
for aria-selected or :disabled.
import { Tabs } from 'frontile';
import { hash } from '@ember/helper';
<template>
<div class='demo-stack items-center'>
<Tabs
@defaultValue='account'
@classes={{hash
tab='data-[selected=true]:text-on-primary'
indicator='bg-primary'
}}
as |t|
>
<t.List @label='Settings'>
<t.Tab @value='account'>Account</t.Tab>
<t.Tab @value='security'>Security</t.Tab>
</t.List>
</Tabs>
</div>
</template>
t.List renders role="tablist" with aria-orientation, each t.Tab is a
role="tab" (aria-selected, aria-controls pointing at its panel), and
each t.Panel is a role="tabpanel" (aria-labelledby pointing back at its
tab, tabindex="0") that only exists in the DOM while selected.
t.List needs an accessible name from @label, or pass aria-labelledby on
t.List directly.
| Key | Behaviour |
|---|---|
Tab |
Moves focus into or out of the tab list. Only the selected tab is a tab stop. |
ArrowRight / ArrowDown |
Moves focus to the next enabled tab, wrapping at the end. |
ArrowLeft / ArrowUp |
Moves focus to the previous enabled tab, wrapping at the start. |
Home / End |
Moves focus to the first / last enabled tab. |
Enter / Space |
Selects the focused tab. Only needed in manual activation — automatic activation selects on arrival. |
Horizontal orientation uses left/right, vertical orientation uses up/down. Disabled tabs are skipped entirely.
Element: HTMLDivElement
| Name | Type | Default | Description |
|---|---|---|---|
activationMode
|
enum
|
'automatic'
|
automatic moves selection with focus, which the APG recommends when
panel content is already loaded. manual moves focus only and waits for
Enter or Space.
|
classes
|
SlotsToClasses<'base' | 'indicator' | 'list' | 'tab' | 'panel'>
|
- | Class names for each slot of the component, merged with the theme's. |
color
|
enum
|
'neutral'
|
The colour intent applied to the indicator. |
defaultValue
|
T
|
- | Sets the initially selected value when uncontrolled. |
isDisabled
|
boolean
|
false
|
Disables every tab. |
isFullWidth
|
boolean
|
false
|
Stretches the tab list to its container and gives every tab equal width. |
onChange
|
function
|
- | Called with the newly selected value when a tab is chosen. |
orientation
|
enum
|
'horizontal'
|
Lays the tabs 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 tabs, driving padding and text size. |
value
|
T
|
- |
The currently selected value. Compared against each tab's Passing this argument at all puts the component in controlled mode --
passing it as |
variant
|
enum
|
'solid'
|
The visual style of the tab list. |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: HTMLButtonElement
| Name | Type | Default | Description |
|---|---|---|---|
context
*
|
Object
|
- | Supplied by Tabs. Not part of the public API. |
value
*
|
T
|
- |
The value this tab represents, paired with a Panel of the same value.
|
class
|
string
|
- | Class names appended to this tab's theme classes. |
isDisabled
|
boolean
|
false
|
Disables this tab alone: it cannot be activated and keyboard navigation skips over it. |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: HTMLDivElement
| Name | Type | Default | Description |
|---|---|---|---|
context
*
|
Object
|
- | Supplied by Tabs. Not part of the public API. |
value
*
|
T
|
- |
The value this panel is paired with; must match a Tab's @value.
|
class
|
string
|
- | Class names appended to this panel's theme classes. |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |