Tabs

Give each item an href and the tabs are links — one page per tab, no script, aria-current on the active one. Give each item a panel and they become a real tablist whose panels swap in place.

On a static site the link form is usually right: it gives each view a URL, and it survives JavaScript being off. Reach for panels when the content is small and switching should not cost a navigation.

No script at all. The active tab is whichever one you mark.

tabs({
  items: [
    { label: 'Overview', href: '#overview', active: true },
    { label: 'Installation', href: '#installation' },
    { label: 'API', href: '#api' },
  ],
})

Panel tabs

Each tab imports its handler on the first click — onclick="import('/su/tabs.js').then(m=>m.select(this))" — so these really switch, arrow keys included, with nothing imported on this page. Until that module lands, the panel the server marked active is simply the one that shows.

npm install sitelo javascript-to-html
tabs({
  value: 'install',
  items: [
    { id: 'install', label: 'Install', panel: card({ variant: 'flat' }, cardBody(code('npm install sitelo javascript-to-html'))) },
    { id: 'use', label: 'Use', panel: card({ variant: 'flat' }, cardBody(code("import * as ui from 'sitelo/ui'"))) },
    { id: 'build', label: 'Build', panel: card({ variant: 'flat' }, cardBody(code('sitelo build'))) },
  ],
})

Pills

src/index.ht.js
stack({ gap: 'lg' },
  tabs({
    variant: 'pills',
    items: [
      { label: 'All', href: '#all', active: true },
      { label: 'Guides', href: '#guides' },
      { label: 'Examples', href: '#examples' },
    ],
  }),
  tabs({
    variant: 'pills',
    value: 'js',
    items: [
      { id: 'js', label: 'JavaScript', panel: card({ variant: 'flat' }, cardBody(code('src/index.ht.js'))) },
      { id: 'ts', label: 'TypeScript', panel: card({ variant: 'flat' }, cardBody(code('src/index.ht.ts'))) },
      { id: 'jsx', label: 'JSX', panel: card({ variant: 'flat' }, cardBody(code('src/index.ht.jsx'))) },
    ],
  }),
)

Colors

stack({ gap: 'lg' },
  tabs({ color: 'primary', items: [{ label: 'Primary', href: '#p', active: true }, { label: 'Other', href: '#p2' }] }),
  tabs({ color: 'neutral', items: [{ label: 'Neutral', href: '#n', active: true }, { label: 'Other', href: '#n2' }] }),
  tabs({ color: 'danger', items: [{ label: 'Danger', href: '#d', active: true }, { label: 'Other', href: '#d2' }] }),
)

Many tabs

The tab list scrolls horizontally rather than wrapping, so the row keeps its shape on a phone.

tabs({
  items: [
    'Overview', 'Routing', 'Data', 'Assets', 'Images', 'Islands', 'TypeScript', 'CLI', 'Deployment',
  ].map((label, index) => ({ label, href: '#many-' + index, active: index === 0 })),
})

Disabled

This one works.

tabs({
  value: 'now',
  items: [
    { id: 'now', label: 'Available', panel: card({ variant: 'flat' }, cardBody(text({ variant: 'small' }, 'This one works.'))) },
    { id: 'soon', label: 'Coming soon', disabled: true, panel: card({ variant: 'flat' }, cardBody('')) },
  ],
})

Accessibility

The panel form renders a proper role="tablist" with aria-selected, aria-controls and roving tabindex. The script adds arrow-key movement, Home and End. The link form is deliberately not a tablist — links that navigate are links, and giving them tab semantics would lie about what they do.

Props

PropTypeDefaultDescription
itemsArray[]Strings, or { id, label, href, panel, active, disabled } objects.
valuestringId of the active item. Falls back to active, then the first.
variant'underline' | 'pills''underline'How the active tab is marked.
color'primary' | 'neutral' | 'success' | 'warning' | 'danger''primary'Colour of the active tab.
labelstring'Tabs'Accessible name for the tablist. Panel form only.