Tabs
Tabs are made of three pieces working together: a group container, one clickable header per view, and the content panel shown for whichever header is currently active. This exact trio powers the tabs at the top of this very page.
Dependencies
When to use
Use tabs to let someone switch between a few different views of related
content that all live on the same page, without navigating away. This very
page's own Overview, API, Examples, and Accessibility split is a tabs
component. For a sequence of steps someone has to complete in order, such
as a checkout or a multi-step form, a
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/tabs.js";
<eun-tab-group>
<eun-tab slot="navigation" panel="overview">Overview</eun-tab>
<eun-tab slot="navigation" panel="details">Details</eun-tab>
<eun-tab-panel name="overview">Overview content.</eun-tab-panel>
<eun-tab-panel name="details">Details content.</eun-tab-panel>
</eun-tab-group>
Importing the file registers all three elements at once. Each
eun-tab's panel attribute must match the name of the
eun-tab-panel it controls. The group wires them together itself,
with nothing to configure on the panels.
npm install @eunomia/elements
import "@eunomia/elements/tabs.js";
function Settings() {
return (
<eun-tab-group>
<eun-tab slot="navigation" panel="overview">
Overview
</eun-tab>
<eun-tab slot="navigation" panel="details">
Details
</eun-tab>
<eun-tab-panel name="overview">Overview content.</eun-tab-panel>
<eun-tab-panel name="details">Details content.</eun-tab-panel>
</eun-tab-group>
);
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/tabs.js";
</script>
<template>
<eun-tab-group>
<eun-tab slot="navigation" panel="overview">Overview</eun-tab>
<eun-tab slot="navigation" panel="details">Details</eun-tab>
<eun-tab-panel name="overview">Overview content.</eun-tab-panel>
<eun-tab-panel name="details">Details content.</eun-tab-panel>
</eun-tab-group>
</template>
Alternatives
eun-stepperGuidance
- Use tabs to switch between views of related content on the same page
- Keep labels short: one or two words
- Give each
eun-taba uniquepanelmatching oneeun-tab-panel'sname
- Using tabs for a multi-step, sequential process: see Alternatives above
- Nesting a tab group's panels inside another tab group's panel of the same instance
- Hiding a validation error inside an inactive tab without any visible indicator
Live testing
Properties
Tab group <eun-tab-group>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| aria-label | string | — | Accessible name for the tablist, forwarded to the internal `role="tablist"` element. Recommended when the group's purpose isn't already clear from a preceding heading, or when several tab groups appear on the same page |
| align | 'start' | 'center' | 'end' | "start" | The tabs position alignment according to the component |
| animation | 'fade' | 'slide' | 'none' | "slide" | The animation to apply to a click/keyboard-triggered panel switch ; a touch swipe's own release settling into place is never this, see the class doc and `--tab-panel-swipe-duration` |
| hideIndicator | boolean | false | Indicates whether the indicators under a tab should be displayed or not |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| overflowState | boolean | false | The current overflow position of the scrollable element. |
Slots
| Name | Description |
|---|---|
| (default) | The tab content corresponding to the `tab-panel` component |
| navigation | The tab header corresponding to the `tab` component |
Events
| Name | Type | Description |
|---|---|---|
| tabchange | TabChangeEvent | Emitted event on active panel change (click, keyboard, or a committed touch swipe alike) |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --tab-header-background | Set a custom color to the background of the tab header |
| --tab-indicator-color | Set a custom color to the indicator positionned under the active tab |
| --tab-hover-indicator-color | Set a custom color to the indicator positionned under the hover tab |
| --tab-indicator-height | Set a custom height to the indicator positionned under the active tab |
| --tab-divider-color | Set a custom color to the divider under the tab header |
| --tab-divider-size | Set a custom thickness to the divider under the tab header |
| --tab-gap | Set a custom gap between each tabs of the tab group |
| --tab-panel-gap | Set a custom gap between the tab header and the panel |
| --tab-divider-gap | Set a custom gap between the tab divider and the tabs |
| --tab-arrows-color | Set a custom background color to the tab arrow navigation in case of overflowing component |
| --tab-panel-animation | Custom animation time for the tab panel switch (click/keyboard only) |
| --tab-panel-swipe-duration | Custom duration for a touch swipe's release settling into place (committing to the new panel, or springing back) ; forced to `0ms` under `prefers-reduced-motion: reduce` or `animation="none"`, regardless of this |
Tab <eun-tab>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| panel | string | — | The name of the panel associated with this tab |
| active | boolean | false | Indicates whether the tab is currently active (selected) |
| icon | EunomiaIconName | undefined | — | The name of the Eunomia icon to display inside the tab |
| badge | boolean | false | Indicates whether a badge (notification indicator) should be displayed |
Import the exact TypeScript type behind any property above, see
Slots
| Name | Description |
|---|---|
| (default) | The label of the tab |
| icon | Used to set a custom icon different from Eunomia one |
| badge | Positions slotted content (typically a `eun-badge`) into the same spot the boolean `badge` dot indicator occupies |
CSS custom properties
| Name | Description |
|---|---|
| --tab-background-color | Set a custom background color for the tab |
| --tab-hover-background-color | Set a custom background color for the tab in case of hover state |
| --tab-active-background-color | Set a custom background color for the tab in case of active state |
| --tab-text-color | Set a custom color for the tab label |
| --tab-hover-text-color | Set a custom color to the tab label in case of hover state |
| --tab-active-text-color | Srt a custom color to the tab label in case of active state |
| --tab-padding | Set a custom padding into the tab. |
| --tab-border | Set a custom border to the tab. |
| --tab-border-active | Set a custom border to the tab on active state. |
| --tab-border-radius | Set a custom border radius to the tab. |
| --tab-focus-outline | Set a custom color to the focus outline |
| --tab-notification-color | Set a custom color to the notification badge |
| --tab-disabled-display | Set a custom `display` for a disabled tab, e.g. `none` to hide it entirely instead of showing it dimmed |
Tab panel <eun-tab-panel>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| name | string | — | The name of the panel |
| is-hidden | boolean | true | Indicates whether the panel is visible or not |
Import the exact TypeScript type behind any property above, see
Slots
| Name | Description |
|---|---|
| (default) | The panel content |
Alignment
align on eun-tab-group positions the tab headers: start
(default), center, or end.
start (default)
<eun-tab-group> ... </eun-tab-group>
center
<eun-tab-group align="center"> ... </eun-tab-group>
end
<eun-tab-group align="end"> ... </eun-tab-group>
Animation
animation controls the panel-switch transition for a click or keyboard
activation: slide (default), fade, or none. Click between the tabs
below to compare. A touch swipe is never affected by this setting, so see
slide (default)
fade
none
<eun-tab-group animation="fade"> ... </eun-tab-group>
<eun-tab-group animation="none"> ... </eun-tab-group>
Touch swipe
On a touch device, dragging horizontally anywhere over the active panel
pages to the next/previous tab. The panel and its incoming neighbor
track the finger 1:1 the whole way, not just a jump once the gesture
ends, and release past the swipe threshold to commit or short of it to
spring back, easing the short remaining distance into place either way.
That settle is a plain position transition, not animation (see
Disabled tabs are skipped, same as ArrowLeft/ArrowRight, and
dragging past the first/last enabled tab meets resistance instead of
doing nothing. See the
Icons and badges
A eun-tab accepts an icon attribute (a Eunomia icon name) or a
custom icon in the icon slot, plus a boolean badge to show a
notification dot.
import "@eunomia/elements/icon.js";
<eun-tab slot="navigation" panel="inbox" icon="mail" badge> Inbox </eun-tab>
The icon module must be imported alongside the tabs for the icon to render.
Numeric badge
For a real count instead of a plain dot, slot a
eun-badgebadge slot. It lands in the exact same spot the boolean dot
above occupies, and takes priority over it if both are somehow set at once.
import "@eunomia/elements/icon.js";
import "@eunomia/elements/badge.js";
<eun-tab slot="navigation" panel="inbox" icon="mail">
Inbox
<eun-badge slot="badge">5</eun-badge>
</eun-tab>
Disabled tab
A disabled tab can't be activated, by click or keyboard, and is skipped when navigating with arrow keys.
<eun-tab slot="navigation" panel="b" disabled>Disabled</eun-tab>
Set --tab-disabled-display: none; to hide a disabled tab entirely
instead of showing it dimmed out, for cases where a permission or
feature-flag check should remove the option rather than just block
it.
<eun-tab
slot="navigation"
panel="b"
disabled
style="--tab-disabled-display: none;"
>Hidden</eun-tab
>
Overflow scrolling
When the tab headers are wider than the available space, the header row becomes horizontally scrollable and left/right arrows appear to scroll it, rather than wrapping or shrinking the tabs. This is especially common on mobile viewports.
<eun-tab-group style="max-width: 320px; --tab-arrows-color: #f4f4f2;">
...
</eun-tab-group>
--tab-arrows-color defaults to --eun-color-grey-0 (plain white), which only blends in on a white surface. Set it to whatever background the tabs actually sit on, such as this page's own demo-block background, so the gradient fades into it instead of leaving a visible seam.
The demo above is capped at 320px to reproduce a mobile-width overflow regardless of your actual viewport. The same behavior kicks in on any real viewport once the tabs no longer fit.
Hiding the indicator
Set the boolean hideIndicator on eun-tab-group to remove the
moving underline beneath the active tab.
<eun-tab-group hide-indicator> ... </eun-tab-group>
Theming
Every color and spacing value is exposed as a CSS custom property on
eun-tab-group and eun-tab (full list in the API tab), so the
tabs repaint with the active theme automatically. See
<eun-tab-group
style="--tab-indicator-color: #b45309; --tab-active-text-color: #b45309;"
>
...
</eun-tab-group>
Keyboard interactions
When focus is on a tab:
| Key | Action |
|---|---|
ArrowRight |
Moves focus to the next tab (wraps to the first) |
ArrowLeft |
Moves focus to the previous tab (wraps to the last) |
Home |
Moves focus to the first tab |
End |
Moves focus to the last tab |
Enter / Space |
Activates the focused tab |
Disabled tabs are skipped entirely when navigating with arrow keys.
Aria roles
eun-tab-group exposes role="tablist" on its header row,
eun-tab sets role="tab" with aria-selected and aria-controls
kept in sync with the active state, and eun-tab-panel sets
role="tabpanel" with aria-labelledby pointing back at its tab,
all wired up automatically from the panel / name pairing, with
nothing to set by hand.
Labeling the tablist
When a page has more than one tab group, or the group's purpose isn't already obvious from a preceding heading, give it an accessible name so screen reader users can tell tab groups apart:
<eun-tab-group aria-label="Account settings"> ... </eun-tab-group>
aria-label set on <eun-tab-group> is forwarded to the internal
role="tablist" element. It isn't visible, only announced.
Disabled tabs
A disabled eun-tab gets aria-disabled="true" in addition to being
skipped by arrow-key navigation, so assistive technology announces
its unavailable state instead of only relying on the dimmed style.
--tab-disabled-display: none; (see the Examples tab) removes it
visually and from the tab order entirely, rather than announcing it
as unavailable, for cases like a permission check where the option
shouldn't appear to exist at all.
Focus management
Only the active tab is in the tab order (tabindex="0"). Inactive
tabs are reachable with arrow keys but skipped by Tab,
matching the standard eun-tab-panel also gets tabindex="0" by default, so a panel whose
content has no focusable element of its own (plain text, an image, ...)
is still reachable by keyboard right after its tab.
Touch swipe
Dragging left/right anywhere over the active panel is only recognized as
a swipe once the drag is more horizontal than vertical, so a vertical
drag still scrolls the page normally. It activates the same next/previous
tab ArrowRight/ArrowLeft would, tracking the finger live rather than
only reacting once the gesture ends, then easing the short remaining
distance into place on release (committing, or springing back). It's
purely an additive touch convenience: every tab stays present and
independently reachable regardless of pointer type, and click/keyboard
activation are unchanged. Disabled tabs are skipped, same as arrow-key
navigation.
Reduced motion
The panel-switch animation (slide / fade), the moving active-tab /
hover indicators, and a touch swipe's own release-settling transition
all respect prefers-reduced-motion: reduce: with that OS-level setting
on, every transition and animation is disabled regardless of the
animation attribute, so a click/keyboard panel switch, and a swipe's
release, happen instantly instead of easing. A swipe's live 1:1
tracking while the finger is still moving is unaffected either way,
since that's direct manipulation rather than a decorative animation. See
Touch swipe above.