# Tabs

> Tab list of links to their panels. Frame component.

The tabs are links to their panels. Without JavaScript the row is a table of contents, and the panels follow one after another below it. With JavaScript the component turns the links into tabs: Tab skips past the whole row and into the selected panel, the arrow keys move between the tabs, and the screen reader says “tab, 2 of 3, selected” where it would otherwise have said “link”.

> **Two paths to the same markup**
>
> When the markup is produced with JavaScript, `lt.tabs()` writes the links, the ids on the panels and the headings, and the classes. When it is produced without JavaScript, in a Go template or plain HTML, you write the same by hand: a `.lt-tabs__list` with links and one `.lt-tabs__panel` per link, with the id the link points to. The component writes the rest in both cases: the roles, `aria-selected`, `aria-controls` from `href`, the tab stops, `hidden="until-found"` on the panels that are not selected, and the panel’s name. Only the panels need an id, and it comes from `href`. The links have none, and the component never writes one: an update from the server therefore recognises them, and the selection stays. See [Markup and behaviour](/markup-and-behaviour.md#ids-that-survive-an-update).
>
> The exception is React: there `lt.tabs()` from `@lo-tek/interfaces/react` writes the whole state, see the React tab below.

## How to use it

**Plain HTML**

```html
<!-- The stylesheets the page needs. Without a build step it is `<link>`,
     not `import`: a browser does not resolve a package name. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/tabs/tabs.css">
<!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css -->
```

```html
<!-- The links point to the panels. The component writes the roles, the
     selection and `hidden`. Without JavaScript this is a table of contents. -->
<lt-tabs id="case-tabs">
  <ul class="lt-tabs__list" aria-label="Parts of the case">
    <li><a href="#application">Application</a></li>
    <li><a href="#attachments">Attachments</a></li>
  </ul>
  <section class="lt-tabs__panel" id="application">
    <h2>Application</h2>
    <p>The application was sent on 4 March 2026.</p>
  </section>
  <section class="lt-tabs__panel" id="attachments">
    <h2>Attachments</h2>
    <p>Three attachments have been uploaded.</p>
  </section>
</lt-tabs>


<!-- Once for the whole app, in the page template. Not once per component. -->
<script type="module">
  import { defineLtTabs } from
    "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/frame/tabs/lt-tabs.js"


  defineLtTabs()
</script>


<script type="module">
  document.getElementById("case-tabs").addEventListener("tab-select", (evt) => {
    console.log("selected tab", evt.detail.index)
  })
</script>
```

The heading in the panel gives the panel its name when it says the same as the tab. If it says something else, the panel gets the tab’s name as `aria-label`.

**React**

```ts
// src/lotek.d.ts, gives <lt-tabs> types in JSX
import "@lo-tek/interfaces/react-jsx"
```

```tsx
import { lt } from "@lo-tek/interfaces/react"
import { defineLtTabs } from "@lo-tek/interfaces/tabs"
import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/tabs.css"


defineLtTabs()


const PARTS = [
  { key: "application", title: "Application" },
  { key: "attachments", title: "Attachments" },
  { key: "messages", title: "Messages" },
]


export function CaseTabs({ selected = 0 }: { selected?: number }) {
  const tabs = lt.tabs({
    id: "case",
    keys: PARTS.map((part) => part.key),
    label: "Parts of the case",
    selected,
  })


  return (
    <lt-tabs>
      <ul {...tabs.list}>
        {tabs.tabs.map((tab, i) => (
          <li key={tab.id} {...tabs.item}>
            <a {...tab}>{PARTS[i].title}</a>
          </li>
        ))}
      </ul>
      {tabs.panels.map((panel, i) => (
        <section key={panel.id} {...panel}>
          <h2>{PARTS[i].title}</h2>
          <p>Content for {PARTS[i].title.toLowerCase()}.</p>
        </section>
      ))}
    </lt-tabs>
  )
}
```

`lt.tabs()` from `@lo-tek/interfaces/react` writes the whole state: the roles, `aria-selected`, `aria-controls`, the tab stops and `hidden`, and gives the links ids so the panel can point to its tab with `aria-labelledby`. The component then has nothing to write when it is registered, and React hydrates without mismatches. The import also gives `className` and `tabIndex` instead of the HTML names, so React does not warn.

`selected` applies when the page loads, and is the index in `keys`. The server does not see the fragment in the address, so a shared link to `#case-attachments` opens the first tab in a React app. If you want to follow the address, read it yourself after the page has loaded, and pass the index as `selected`. If the app should move the selection after the user has clicked, set `server-controlled` on `<lt-tabs>`, see [When the server decides the tab](#when-the-server-decides-the-tab).

React 19 writes `hidden="until-found"` as `hidden=""`. Find in page and links into a hidden panel therefore do not open the tab in a React app until React supports the value.

If you write the tabs by hand in JSX, without `lt.tabs()`, the component writes the roles before React hydrates, and React reports one mismatch per element. Then set `suppressHydrationWarning` on the row, the list items, the links and the panels. Use `lt.tabs()` instead.

**Astro**

```astro
---
import { lt } from "@lo-tek/interfaces"
import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/tabs.css"


const parts = [
  { key: "application", title: "Application" },
  { key: "attachments", title: "Attachments" },
  { key: "messages", title: "Messages" },
]
const tabs = lt.tabs({
  id: "case",
  keys: parts.map((part) => part.key),
  label: "Parts of the case",
})
---


<lt-tabs>
  <ul {...tabs.list}>
    {tabs.tabs.map((tab, i) => <li><a {...tab}>{parts[i].title}</a></li>)}
  </ul>
  {tabs.panels.map((panel, i) => (
    <section {...panel}>
      <h2 {...tabs.headings[i]}>{parts[i].title}</h2>
      <p>Content for {parts[i].title.toLowerCase()}.</p>
    </section>
  ))}
</lt-tabs>


<script>
  import { defineLtTabs } from "@lo-tek/interfaces/tabs"
  defineLtTabs()
</script>
```

An Astro page hydrates nothing, so here the plain `lt.tabs()` applies, and the component writes the roles when it is registered. `headings` gives the headings a stable id, so the panel’s name points to the same thing after an update.

**Datastar**

```html
<!-- Datastar itself. Without it the `data-` attributes below do nothing. -->
<script type="module"
        src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"></script>


<!-- The stylesheets the page needs. Without a build step it is `<link>`,
     not `import`: a browser does not resolve a package name. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/tabs/tabs.css">
<!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css -->


<!-- Once for the whole app, in the page template. Not once per component. -->
<script type="module">
  import { defineLtTabs } from
    "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/frame/tabs/lt-tabs.js"


  defineLtTabs()
</script>


<lt-tabs server-controlled data-signals__ifmissing="{selectedTab: 0}" data-on:tab-select="$selectedTab = evt.detail.index">
  <ul class="lt-tabs__list" aria-label="Parts of the case">
    <li><a href="#application">Application</a></li>
    <li><a href="#attachments">Attachments</a></li>
  </ul>
  <section class="lt-tabs__panel" id="application">
    <h2>Application</h2>
    <p>The application was sent on 4 March 2026.</p>
  </section>
  <section class="lt-tabs__panel" id="attachments" hidden="until-found">
    <h2>Attachments</h2>
    <p>Three attachments have been uploaded.</p>
  </section>
</lt-tabs>
```

The signal `$selectedTab` is sent with the next action Datastar makes against the server, like `@get` or `@post`, so the server knows which tab the user chose and writes `hidden` on the panels accordingly. Then it is the server that controls the tab, and `server-controlled` belongs with it. Without it the two fight over the same thing: the component puts back the tab the user chose, while the server sends its own. If you leave the signal out, nothing is needed, and the component keeps the selection itself.

Hide the panels with `hidden="until-found"`, not bare `hidden`, so find in page can find the text in them. The component writes `hidden` back in the form the server used.

## Tabs or accordion

Tabs fit when the first part matters most to most people, and nobody needs everything at once. If the content is to be read in order, or compared part by part, use an [accordion](/components/accordion.md) instead: there several panels can be open, and `name` gives you one at a time when that is the point. Tabs hide everything except the selected one, also for someone printing the page.

## Which tab is selected

The first tab is selected when the page loads, with two exceptions: the address points to a panel or something inside it, or the server has written the selection in the markup. The component reads in this order:

1. `aria-selected="true"` on a tab, as `lt.tabs()` from the React entry point writes it. The address then selects no tab, and if it points to another tab in the row, it is corrected to the selected one.
2. The address, when it points to a panel or something inside it.
3. The first panel without `hidden`. The server can therefore select a tab in markup without roles by writing `hidden="until-found"` on the other panels.
4. Otherwise the first.

What the component itself wrote does not count as the markup’s answer: if the server switches tab by moving `hidden` alone, the highlight follows.

The plain `lt.tabs()` has no `selected`. A different starting point comes from the address or from the server’s `hidden`, and afterwards from the app through `selected` and `select()` on the element:

```ts
const tabs = document.querySelector("lt-tabs")
if (!tabs) throw new Error("element not found")


tabs.selected // 0
tabs.select(1) // selects "Attachments" and sends `tab-select`
tabs.selected = 2 // the same as select(2)
```

The component has no `selected` attribute. The answer is in the markup, as `aria-selected` on the tab, and is read from there. Two places to keep in sync is one too many.

The panel is found by the id in `href`, and `aria-controls` is written from it. The panels can therefore be in a different order than the tabs, and outside `<lt-tabs>`. A panel outside is still not in what the component observes, so if an update tears `hidden` off it, it does not come back by itself. If a link points to an id that does not exist, the tab can be selected without anything being shown, and the component warns in the console.

`tab-select` is sent when the user or the component changes the selection: click, arrow keys, `select()`, the address and find in page. It is not sent when the server moves the selection in an update, so the app does not react to its own echo, and not when the tab being selected is already selected.

## The address and find in page

Every selection is written to the address, like `#case-attachments`, without a new history entry. A shared link therefore opens the tab the reader was on, and a link to something inside a panel, like `#attachments-list`, selects the tab and scrolls there. That address is not rewritten, so the link into the content is not lost. If the address changes while the page is open, the tab is selected.

Find in page finds text in the panels that are hidden, because they are hidden with `hidden="until-found"`. If the search hits a hidden panel, the component selects that tab, and only that panel is shown. The search does not change the address.

If the address cannot be written, as in Safari after many quick selections or in an `<iframe srcdoc>`, the tab is selected anyway.

Two limitations follow from there being only one address:

- Two tab lists on the same page share it. A selection in one overwrites the address of the other, and only the last can be restored from a shared link.
- An app that routes on the fragment, like `#/case/1`, gets the address overwritten by the tabs. Then `server-controlled` belongs with it, see below: there the address selects no tab, and the component does not write it either.

## Why the panels are hidden with `hidden="until-found"`

The HTML standard says that `hidden` should not be used to hide the panels in a tab list, and that a visible element should not link to a hidden one. LO-TEK departs from that, on purpose. `until-found` was made precisely so that links to fragments and find in page can reveal content that has been put away, and that is what a tab list does: the content exists, the reader just has not asked for it yet. With a class and `display: none` instead, find in page would not find the text, and the server could no longer select a tab with `hidden`.

A panel hidden with `until-found` keeps its box, so the stylesheet zeroes the space around it. The content of a hidden panel cannot get focus, and the panel itself has no tab stop.

## Before the component is registered

The panels are on the page before `defineLtTabs()` has run. The plain `lt.tabs()` does not write `hidden`, so without JavaScript all the panels are shown one below the other, as a table of contents should be. With JavaScript, `tabs.css` shows only one panel until the component is registered, the one the component is going to select, so the content does not jump:

- If the server wrote `hidden` itself, that applies.
- Otherwise the panel the address points to, or the panel with that inside it. That is also where a link in the row leads when the component is never registered.
- Otherwise the first.

Under `server-controlled` the address does not select, and the first visible panel is shown. The rule only reaches the panels inside `<lt-tabs>`: a panel outside must have `hidden` from the server. And it cannot connect `aria-selected` on a tab to its panel, so markup with `aria-selected`, as from the React entry point, must also have `hidden` on the panels that are not selected. The React entry point writes both.

See [Markup and behaviour](/markup-and-behaviour.md#before-the-component-is-registered).

## When the server decides the tab

With `server-controlled` on `<lt-tabs>` the server decides which tab is selected. The component does not put the user’s choice back after an update from the server, but it still does the rest:

- It writes out what the markup says: `aria-selected`, the tab stop and `hidden` in step. The server can therefore move the tab by moving `hidden` alone.
- A click selects the tab at once, sends `tab-select` and writes the tab to the address. The selection stands until the server answers, and then the answer applies.
- Find in page and a link to something in a hidden panel select the tab the same way as a click.
- The address selects no tab, neither when the page loads nor when the address changes. It is the server that knows which tab applies.
- Before the component is registered, `tabs.css` shows the first panel that is not hidden, and not the one the address points to.

If the attribute is set while the page is open, what is in the markup is the starting point, and a choice the user made before is forgotten.

## Attributes

| Attribute | Type | Effect |
| - | - | - |
| `activation` | `automatic` or `manual` | What the arrow keys do. With `automatic`, the default, they select the tab at once. With `manual` they only move focus, and Enter or Space selects. That fits when the content of a panel takes time to load. |
| `server-controlled` | boolean | The server decides which tab is selected. See [When the server decides the tab](#when-the-server-decides-the-tab). |

## Classes you can style

| Class | What it is |
| - | - |
| `.lt-tabs__list` | The list of links. Gets `role="tablist"` from the component, or from `lt.tabs()` in the React entry point |
| `.lt-tabs__panel` | One panel. Those that are not selected have `hidden="until-found"` |

The links have no class of their own; they are styled as `.lt-tabs__list a`. The selected one has `aria-selected="true"`, and a disabled tab has `aria-disabled="true"`.

| Variable | Fallback |
| - | - |
| `--lt-tabs-gap` | `var(--lt-spacing-1)` |
| `--lt-tabs-padding` | `var(--lt-spacing-2) var(--lt-spacing-4)` |
| `--lt-tabs-panel-space` | `var(--lt-spacing-4)` |

```css
.lt-tabs__list {
  --lt-tabs-gap: 0;
}
```

## Accessibility

The tabs are `role="tab"` in a `role="tablist"`, the list items around them are `role="presentation"` so the screen reader does not count them as a list, and the panels are `role="tabpanel"`. If you write them with `lt.tabs()` from the React entry point, the roles are in the HTML before the script has run. Otherwise the component sets them when it is registered, and puts them back if an update removes them.

The panel gets its name from the heading in it, when it says the same as the tab. If it says something else, or does not exist, the panel gets the tab’s name as `aria-label`. A connection the server wrote itself, `aria-label` or `aria-labelledby`, the component never touches.

The row must have a name: `aria-label` on `.lt-tabs__list`, or `aria-labelledby` pointing at a heading. `label` is therefore required in `lt.tabs()`, and the component warns in the console when the name is missing. Without it the screen reader just says “tab list”.

| Key | Does |
| - | - |
| Arrow keys | Move between the tabs, and wrap around from last to first. With `automatic` they select the tab at once |
| Home and End | First and last tab |
| Enter and Space | Select the tab that has focus, when `activation="manual"` |
| Tab | Out of the row and into the selected panel. Nothing in a panel that is not selected can be reached with Tab |

Right and left arrow move in a horizontal row. If the list has `aria-orientation="vertical"`, it is up and down arrow, and in a horizontal row those scroll the page as usual. On a page with `dir="rtl"` left and right arrow swap direction, so the next tab is always the one next to it in the reading direction. A key with Alt, Ctrl or Cmd held down belongs to the browser: Cmd-click and middle-click on a tab open the panel in a new browser tab, like a link.

A tab with `aria-disabled="true"` is skipped, and cannot be selected. One tab has `tabindex="0"`, normally the selected one. If the selected one is disabled, the tab stop moves to the next enabled one. The selected panel has `tabindex="0"`, so it gets focus also when it has nothing focusable inside, otherwise Tab skips straight past the content that was just shown. The hidden panels do not have it.

Without JavaScript the row is a plain list of links, without tab roles, and every link can be reached with Tab. The stylesheet gives them a target size of at least 24 pixels.
