Navigation

v0.0.4
Navigation

Tabs

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.

Tabs

<nav class="tabs" aria-label="Project">  <a class="tabs-item" href="#overview" aria-current="page">Overview</a>  <a class="tabs-item" href="#activity">Activity</a>  <a class="tabs-item" href="#settings">Settings</a></nav>

#With counts

With counts

<nav class="tabs" aria-label="Repository">  <a class="tabs-item" href="#code" aria-current="page">Code</a>  <a class="tabs-item" href="#issues">Issues    <span class="badge badge-sm badge-pill">24</span>  </a>  <a class="tabs-item" href="#pulls">Pull requests    <span class="badge badge-sm badge-pill">3</span>  </a></nav>

#Small

Small

<nav class="tabs tabs-sm" aria-label="Period">  <a class="tabs-item" href="#day">Day</a>  <a class="tabs-item" href="#week" aria-current="page">Week</a>  <a class="tabs-item" href="#month">Month</a></nav>

#Tab panels

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 tabs behavior switches the panels and gives the tablist its keys.

Tab list

<div class="tabs" role="tablist" aria-label="Account">  <button type="button" class="tabs-item" role="tab" id="tab-profile" aria-selected="true" aria-controls="panel-profile">Profile</button>  <button type="button" class="tabs-item" role="tab" id="tab-billing" aria-selected="false" aria-controls="panel-billing" tabindex="-1">Billing</button>  <button type="button" class="tabs-item" role="tab" id="tab-members" aria-selected="false" aria-controls="panel-members" tabindex="-1">Members</button></div><div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile" class="pt-md text-sm fg-text-body">Profile settings</div><div role="tabpanel" id="panel-billing" aria-labelledby="tab-billing" class="pt-md text-sm fg-text-body" hidden>Billing settings</div><div role="tabpanel" id="panel-members" aria-labelledby="tab-members" class="pt-md text-sm fg-text-body" hidden>Members of the workspace</div><script type="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.

app.js
import { tabs } from '@master/ui/behaviors/tabs.js'const detach = tabs(document.querySelector('.tabs[role="tablist"]'))
  • 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.

#React, Svelte and Vue

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'export interface Tab {  /** What selecting the tab gives `onValueChange`. */  value: string  label: ReactNode  /** The panel the tab shows. */  content: ReactNode  disabled?: boolean}export interface TabsProps extends Omit<ComponentProps<'div'>, 'defaultValue' | 'children' | 'role'> {  tabs: Tab[]  /** The value of the selected tab. The first tab by default. */  value?: string  defaultValue?: string  onValueChange?: (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`. */export function Tabs({ 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 (    <>      <div className={className ? `tabs ${className}` : 'tabs'} role="tablist" ref={ref} {...root}>        {list.map((tab, index) => (          <button type="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) => (        <div className={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
<script lang="ts" module>  import type { Snippet } from 'svelte'  import type { HTMLAttributes } from 'svelte/elements'  export interface Tab {    /** What selecting the tab gives `onValueChange`. */    value: string    label: string    /** The panel the tab shows, as text. Leave it out when `panel` writes it. */    content?: string    disabled?: boolean  }  export interface TabsProps extends Omit<HTMLAttributes<HTMLDivElement>, 'role' | 'children'> {    tabs: Tab[]    /** The value of the selected tab. Bind it, or leave it out: the first tab is selected. */    value?: string    onValueChange?: (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><script lang="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 = next      onValueChange?.(next)    },  })</script><!--  @component  Tabs that switch panels in place. Name the tablist with `aria-label` or `aria-labelledby`.--><div class={['tabs', className]} role="tablist" {@attach attach} {...root}>  {#each list as tab, index (tab.value)}    <button 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>  {/each}</div>{#each list as tab, index (tab.value)}  <div class={panelClass} role="tabpanel" id="{id}panel{index}" aria-labelledby="{id}tab{index}" hidden={tab.value !== first}>    {#if panel}{@render panel(tab)}{:else}{tab.content}{/if}  </div>{/each}
ui/Tabs.vue
<script lang="ts">export interface Tab {  /** What selecting the tab gives the model. */  value: string  label: string  /** The panel the tab shows, as text. The `panel` slot writes richer panels. */  content?: string  disabled?: boolean}</script><script setup lang="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>  <div class="tabs" role="tablist" ref="root" v-bind="$attrs">    <button v-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>  <div v-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"><slot name="panel" :tab="tab">{{ tab.content }}</slot></div></template>

#Classes

ClassDescription
tabsThe hairline row, on a nav with a label or on a role="tablist". It scrolls sideways when the tabs do not fit
tabs-itemA tab: a link with aria-current="page", or a role="tab" button with aria-selected
tabs-smCompact tabs, 36px tall with 13px labels; the default is 44px with 14px labels. With tabs

#Source

@master/ui/components/tabs.css
/* * 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: 0 var(--tabs-gap);    overflow-x: auto;    box-shadow: inset 0 -1px 0 var(--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: 1px solid 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: 400 var(--tabs-font-size)/1.4 var(--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: inset 0 -2px 0 var(--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: 2px solid 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: 2px 2px 0 0;        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;    }  }}