Words on one hairline, the current one underlined in ink.
On this page
Tabs are words on one hairline, the current one underlined in ink. They switch between sibling pages or panels; for a compact choice of values, use a segmented control.
Where the browser supports CSS anchor positioning, the underline is one mark that glides to the current tab when aria-selected changes, with a softened top so the ink does not end in a hard corner. Elsewhere each tab draws its own underline.
For in-page panels, follow the WAI-ARIA tabs pattern: a role="tablist" container, role="tab" buttons with aria-selected and aria-controls, and role="tabpanel" regions. The stylesheet draws every state; the tabsbehavior switches the panels and gives the tablist its keys.
Tab list
<divclass="tabs"role="tablist"aria-label="Account"><buttontype="button"class="tabs-item"role="tab"id="tab-profile"aria-selected="true"aria-controls="panel-profile">Profile</button><buttontype="button"class="tabs-item"role="tab"id="tab-billing"aria-selected="false"aria-controls="panel-billing"tabindex="-1">Billing</button><buttontype="button"class="tabs-item"role="tab"id="tab-members"aria-selected="false"aria-controls="panel-members"tabindex="-1">Members</button></div><divrole="tabpanel"id="panel-profile"aria-labelledby="tab-profile"class="pt-mdtext-smfg-text-body">Profile settings</div><divrole="tabpanel"id="panel-billing"aria-labelledby="tab-billing"class="pt-mdtext-smfg-text-body"hidden>Billing settings</div><divrole="tabpanel"id="panel-members"aria-labelledby="tab-members"class="pt-mdtext-smfg-text-body"hidden>Members of the workspace</div><scripttype="module">import{ attach }from'@master/ui/behaviors'attach()</script>
Focus a tab and use the arrow keys, Home or End. A click selects too.
tabs takes the tablist, the element with the tabs class and role="tablist", has no options, and returns the function that detaches it.
One tab stop. The selected tab is the one in the tab order. The arrow keys move to the tab before or after and select it, skipping disabled tabs and wrapping at the ends; Home and End go to the first and the last. A tablist with aria-orientation="vertical" takes the up and down arrows instead.
Panels. A tab names its panel in aria-controls. The panel of the selected tab is shown and the others get hidden.
The value. Selecting a tab dispatches a bubbling change from the tablist with detail.value: the tab's data-value, or its text. The function the behavior returns has the same value; set it to select a tab from outside.
Without the script the selected tab's panel is the only one without hidden, and nothing switches. Use links in a nav when each tab can be a page.
The tabs component renders a tablist and its panels from data, joins each tab to its panel by id, attaches the behavior and passes the selected tab in and out. Tabs that are links in a nav stay markup. Copy it into the project with npx master-ui add tabs; React, Svelte and Vue says how the components are built and used.
ui/tabs.tsx
'use client'import{ useId, useState,type ComponentProps,type ReactNode }from'react'import{ tabs }from'@master/ui/behaviors/tabs.js'import{ useBehavior }from'./use-behavior'exportinterfaceTab{/** What selecting the tab gives `onValueChange`. */value:stringlabel:ReactNode/** The panel the tab shows. */content:ReactNodedisabled?:boolean}exportinterfaceTabsPropsextendsOmit<ComponentProps<'div'>,'defaultValue'|'children'|'role'>{tabs:Tab[]/** The value of the selected tab. The first tab by default. */value?:stringdefaultValue?:stringonValueChange?:(value:string)=>void/** Classes for each panel, such as its padding. */panelClassName?:string}/** Tabs that switch panels in place. Name the tablist with `aria-label` or `aria-labelledby`. */exportfunctionTabs({tabs: list, value, defaultValue, onValueChange, panelClassName, className,...root }:TabsProps){const id =useId()const ref =useBehavior(tabs,undefined,{ value, defaultValue,onValueChange: onValueChange as((value:string|null)=>void)|undefined})const[first]=useState(value ?? defaultValue ?? list.find(tab =>!tab.disabled)?.value)return(<><divclassName={className ?`tabs ${className}`:'tabs'}role="tablist"ref={ref}{...root}>{list.map((tab, index)=>(<buttontype="button"className="tabs-item"role="tab"key={tab.value}id={`${id}tab${index}`}data-value={tab.value}aria-selected={tab.value=== first}aria-controls={`${id}panel${index}`}tabIndex={tab.value=== first ?0:-1}disabled={tab.disabled}>{tab.label}</button>))}</div>{list.map((tab, index)=>(<divclassName={panelClassName}role="tabpanel"key={tab.value}id={`${id}panel${index}`}aria-labelledby={`${id}tab${index}`}hidden={tab.value!== first}>{tab.content}</div>))}</>)}
ui/Tabs.svelte
<scriptlang="ts"module>importtype{ Snippet }from'svelte'importtype{ HTMLAttributes }from'svelte/elements'exportinterfaceTab{/** What selecting the tab gives `onValueChange`. */value:stringlabel:string/** The panel the tab shows, as text. Leave it out when `panel` writes it. */content?:stringdisabled?:boolean}exportinterfaceTabsPropsextendsOmit<HTMLAttributes<HTMLDivElement>,'role'|'children'>{tabs:Tab[]/** The value of the selected tab. Bind it, or leave it out: the first tab is selected. */value?:stringonValueChange?:(value:string)=>void/** Writes the panel of a tab, when it is more than text. */panel?:Snippet<[Tab]>/** Classes for each panel, such as its padding. */panelClass?:string}</script><scriptlang="ts">import{ untrack }from'svelte'import{ tabs }from'@master/ui/behaviors/tabs.js'import{ behavior }from'./behavior.svelte.js'let{tabs: list, value =$bindable(), onValueChange, panel, panelClass,class: className,...root }:TabsProps=$props()const id =$props.id()const first =untrack(()=> value ?? list.find(tab =>!tab.disabled)?.value)const attach =behavior(tabs,undefined,{value:()=> value,change:(next)=>{if(next ===null)return value = nextonValueChange?.(next)},})</script><!--@component Tabs that switch panels in place. Name the tablist with `aria-label` or `aria-labelledby`.--><divclass={['tabs', className]}role="tablist"{@attach attach}{...root}>{#each list as tab, index (tab.value)}<buttontype="button"class="tabs-item"role="tab"id="{id}tab{index}"data-value={tab.value}aria-selected={tab.value=== first}aria-controls="{id}panel{index}"tabindex={tab.value=== first ?0:-1}disabled={tab.disabled}>{tab.label}</button>{/each}</div>{#each list as tab, index (tab.value)}<divclass={panelClass}role="tabpanel"id="{id}panel{index}"aria-labelledby="{id}tab{index}"hidden={tab.value!== first}>{#if panel}{@renderpanel(tab)}{:else}{tab.content}{/if}</div>{/each}
ui/Tabs.vue
<scriptlang="ts">exportinterfaceTab{/** What selecting the tab gives the model. */value:stringlabel:string/** The panel the tab shows, as text. The `panel` slot writes richer panels. */content?:stringdisabled?:boolean}</script><scriptsetuplang="ts">import{ useId, useTemplateRef }from'vue'import{ tabs as tablist }from'@master/ui/behaviors/tabs.js'import{ useBehavior }from'./use-behavior'defineOptions({inheritAttrs:false})const props =defineProps<{tabs:Tab[]/** Classes for each panel, such as its padding. */panelClass?:string}>()/** The value of the selected tab: `v-model`. The first tab by default. */const model =defineModel<string>()defineSlots<{/** What a tab's panel holds, in place of its `content`. */panel?:(scope:{tab:Tab})=>unknown}>()/* Tabs that switch panels in place. Name the tablist with `aria-label` or `aria-labelledby`. */const id =useId()const first = model.value?? props.tabs.find(tab =>!tab.disabled)?.valueconst root =useTemplateRef<HTMLDivElement>('root')useBehavior(root, tablist,undefined, model)</script><template><divclass="tabs"role="tablist"ref="root"v-bind="$attrs"><buttonv-for="(tab, index)in tabs":key="tab.value"type="button"class="tabs-item"role="tab":id="`${id}-tab-${index}`":data-value="tab.value":aria-selected="tab.value=== first":aria-controls="`${id}-panel-${index}`":tabindex="tab.value=== first ?0:-1":disabled="tab.disabled">{{ tab.label}}</button></div><divv-for="(tab, index)in tabs":key="tab.value":class="panelClass ||undefined"role="tabpanel":id="`${id}-panel-${index}`":aria-labelledby="`${id}-tab-${index}`":hidden="tab.value!== first"><slotname="panel":tab="tab">{{ tab.content}}</slot></div></template>
/* * Tabs: words on one hairline; the current one is underlined in ink. * The current tab keeps its weight so the row never shifts between states. * With anchor positioning the underline is one mark that glides to the current tab. */@layer components {.tabs{--tabs-item-height:2.75rem;--tabs-gap:1.5rem;--tabs-font-size:.875rem;--icon-size:1rem;display:flex;gap:0var(--tabs-gap);overflow-x:auto;box-shadow:inset0-1px0var(--color-line-divider);scrollbar-width:none;&.tabs-sm{--tabs-item-height:2.25rem;--tabs-gap:1rem;--tabs-font-size:.8125rem;--icon-size:.875rem;}@media(forced-colors:active){border-bottom:1pxsolid CanvasText;}/* On touch a small tab is as tall as any other target. */@media(pointer:coarse){--tabs-item-height:2.75rem;}}/* A tab: a link with aria-current="page", or a role="tab" button with aria-selected="true". */.tabs-item{display:inline-flex;flex:none;align-items:center;gap:.375rem;min-height:var(--tabs-item-height);margin:0;padding:0;border:0;border-radius:0;background:none;color:var(--color-text-muted);font:400var(--tabs-font-size)/1.4var(--font-family-sans);font-feature-settings:inherit;text-decoration:none;white-space:nowrap;appearance:none;-webkit-tap-highlight-color:transparent;cursor:pointer;touch-action:manipulation;transition-property:color, box-shadow;transition-duration:var(--duration-quick);transition-timing-function:var(--easing-glide);user-select:none;-webkit-user-select:none;@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-current="page"],[aria-selected="true"]){color:var(--color-text-strong);box-shadow:inset0-2px0var(--color-text-strong);}&:focus-visible{@apply--focus-ring;outline-offset:-2px;border-radius:calc((var(--radius-chip)-2px)*var(--corner-scale));corner-shape:var(--corner-shape);}&:is(:disabled,[aria-disabled="true"]){color:var(--color-text-disabled);pointer-events:none;}@media(forced-colors:active){&:is([aria-current="page"],[aria-selected="true"]){border-bottom:2pxsolid Highlight;}}}/* * The gliding underline. The current tab names itself as an anchor; the row scopes that name and draws one * mark between the anchor's edges, with a softened top so the ink does not end in a hard corner. */@supports(anchor-scope: --tabs-mark){.tabs{position:relative;anchor-scope: --tabs-mark;&:has(.tabs-item:is([aria-current="page"],[aria-selected="true"]))::after{content:'';position:absolute;position-anchor: --tabs-mark;right:anchor(right);bottom:0;left:anchor(left);height:2px;border-radius:2px2px00;background:var(--color-text-strong);pointer-events:none;transition-property:right,left;transition-duration:var(--duration-base);transition-timing-function:var(--easing-glide);}@media(prefers-reduced-motion:reduce){&::after{transition:none;}}@media(forced-colors:active){&::after{display:none;}}}.tabs-item:is([aria-current="page"],[aria-selected="true"]){anchor-name: --tabs-mark;box-shadow:none;}}}