---
title: "Tabs"
description: "Firelight tabs with the ARIA tab roles, arrow-key navigation with a roving tabindex, and Lit's cache directive to keep each panel's DOM while it's hidden."
---

# Tabs

Build tabs that screen readers announce as tabs, that the arrow keys move between, and whose panels keep what was typed into them.

- [ARIA Authoring Practices: Tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/)

Type a note in the Notes tab, switch to About, then back: the note is still there. With the focus on
a tab, the arrow keys, Home and End move between tabs.

```fsharp
module Snippets.SettingsTabs

open Fable.Core
open Browser.Types
open Firelight
open type Firelight.Lit

let private tabs = [ "general", "General"; "notes", "Notes"; "about", "About" ]

// Each panel is its own template, so `cache` can keep each one's DOM.
let private general () =
    html $"""<label><input type="checkbox"> Send me a weekly summary</label>"""

let private notes () =
    html $"""<label for="notes">Notes</label><textarea id="notes" rows="3"></textarea>"""

let private about () =
    html $"""<p>Settings, version 1.0.</p>"""

[<AttachMembers>]
type SettingsTabs() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [ "selected", PropertyDeclaration<string>(state = true) ]

    static member styles =
        css
            $$"""
        :host { display: block; width: 20rem; max-width: 100%; }
        [role="tablist"] { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--border); }
        [role="tab"] { font: inherit; padding: 0.4rem 0.9rem; border: 1px solid transparent; border-bottom: 0;
                       border-radius: 0.5rem 0.5rem 0 0; background: none; color: inherit; cursor: pointer; }
        [role="tab"][aria-selected="true"] { border-color: var(--border); background: var(--bg); margin-bottom: -1px; }
        [role="tabpanel"] { display: grid; gap: 0.5rem; padding: 1rem; border: 1px solid var(--border);
                            border-top: 0; background: var(--bg); }
        textarea { font: inherit; }
        p { margin: 0; }
        """

    member val selected = "general" with get, set

    member this.Select(id: string, moveFocus: bool) =
        this.selected <- id

        if moveFocus then
            promise {
                let! _ = this.updateComplete
                this.query<HTMLElement> ("#tab-" + id) |> Option.iter _.focus()
            }
            |> Promise.start

    /// Arrow keys, Home and End move between the tabs, as in a native tab strip.
    member this.OnKey(e: KeyboardEvent) =
        let ids = tabs |> List.map fst
        let i = ids |> List.findIndex ((=) this.selected)

        let next =
            match e.key with
            | "ArrowRight" -> Some ids[(i + 1) % ids.Length]
            | "ArrowLeft" -> Some ids[(i + ids.Length - 1) % ids.Length]
            | "Home" -> Some(List.head ids)
            | "End" -> Some(List.last ids)
            | _ -> None

        next
        |> Option.iter (fun id ->
            e.preventDefault ()
            this.Select(id, true)
        )

    member this.Tab(id: string, label: string) =
        let selected = id = this.selected

        // Only the selected tab is in the Tab order; the arrow keys reach the others.
        html
            $"""
        <button role="tab" id="tab-{id}" aria-selected={selected} aria-controls="panel"
            tabindex={if selected then 0 else -1} @click={fun _ -> this.Select(id, false)}>{label}</button>"""

    override this.render() =
        let panel =
            match this.selected with
            | "notes" -> notes ()
            | "about" -> about ()
            | _ -> general ()

        html
            $"""
        <div role="tablist" aria-label="Settings" @keydown={Ev.keyboard this.OnKey}>
            {tabs |> List.map this.Tab}
        </div>
        <div role="tabpanel" id="panel" aria-labelledby="tab-{this.selected}" tabindex="0">{cache panel}</div>"""

defineElement<SettingsTabs> "my-settings-tabs"
```

```html
<my-settings-tabs></my-settings-tabs>
```

## How it works

The roles do the announcing. `role="tablist"` holds `role="tab"` buttons, each with
`aria-selected`, and `role="tabpanel"` holds the selected tab's content, named by that tab through
`aria-labelledby`. A screen reader then says "General, tab, selected, 1 of 3".

- **One Tab stop.** Only the selected tab has `tabindex="0"`; the others have `-1`. Tab moves
  into the strip and on to the panel, and the arrow keys move along the strip. This is the
  keyboard behaviour the ARIA pattern describes.
- **Arrow keys.** One `@keydown` on the tablist handles them all. `OnKey` picks the next tab,
  selects it, and focuses it once the update has rendered, by awaiting `updateComplete`. It calls
  `preventDefault` only for keys it handles, so the page doesn't scroll on Home and End.
- **Keeping the panels.** `match` picks a different template for each tab. Lit would normally
  throw away the old panel's DOM and build the new one, losing what was typed. `cache` keeps the
  DOM of each template it has shown, and puts it back when that template returns. Each panel must
  be its own template for that, as `general`, `notes` and `about` are.

`cache` keeps the DOM, not your data. Anything that matters after the user leaves the page belongs
in state, as in [Remember state across visits](https://roboz0r.github.io/Firelight/cookbook/persist-state/).

Selecting a tab on arrow keys, rather than moving the focus and waiting for Enter, suits panels that
render at once. For panels that load data, the ARIA pattern suggests manual activation instead.

## Related

- [Templates: Directives](https://roboz0r.github.io/Firelight/guides/templates/#directives), for `cache` and the others.
- [Lifecycle: Reach the DOM after it renders](https://roboz0r.github.io/Firelight/guides/lifecycle/#reach-the-dom-after-it-renders),
  for focusing after an update.
