Skip to content

Split Sidebar into Tabs

6 min read

This recipe provides a comprehensive walkthrough for implementing a tabbed sidebar switcher in Starlight, similar to the one used in the official Astro Documentation.

By default, Starlight provides a vertical sidebar. For larger documentation sites, a tabbed interface can help organize content into top-level categories, making navigation more intuitive.

  1. Configure Sidebar Labels

    Create a dedicated configuration file for your sidebar. This file will export separate arrays for each tab, combined into a single configuration for Starlight. Define your labels within a constant in this file.

    config/sidebar.ts
    import type { StarlightUserConfig } from "@astrojs/starlight/types";
    const enLabels = {
    "astro.recipes": "Astro Recipes",
    "starlight.recipes": "Starlight Recipes",
    } as const;
    type NavKey = keyof typeof enLabels;
    /**
    * Helper to create a sidebar group with localized labels.
    */
    export function group(key: NavKey, group: any): any {
    return {
    label: enLabels[key],
    ...group,
    };
    }
    export const astroSidebar = [
    group("astro.recipes", {
    items: [{ autogenerate: { directory: "astro" } }],
    }),
    ] satisfies StarlightUserConfig["sidebar"];
    export const starlightSidebar = [
    group("starlight.recipes", {
    items: [{ autogenerate: { directory: "starlight" } }],
    }),
    ] satisfies StarlightUserConfig["sidebar"];
  2. Create Custom Tab Components

    Implement the necessary components for the tabbed interface. These components handle the layout, tab selection, and accessibility.

    src/components/tabs/TabbedContent.astro
    ---
    export interface Props {
    class?: string;
    }
    ---
    <tabbed-content class={Astro.props.class}>
    <ul role="tablist" class="tab-list">
    <slot name="tab-list" />
    </ul>
    <slot />
    </tabbed-content>
    <script>
    class Tabs extends HTMLElement {
    TabStore: Set<HTMLElement>[] = [];
    PanelStore: Set<HTMLElement>[] = [];
    constructor() {
    super();
    const tabs = this.querySelectorAll<HTMLElement>('[role="tab"]');
    const panels = this.querySelectorAll<HTMLElement>('[role="tabpanel"]');
    Array.prototype.forEach.call(tabs, (tab: HTMLElement, i: number) => {
    tab.addEventListener('click', (e) => {
    e.preventDefault();
    this.switchTab(tab, i);
    });
    tab.addEventListener('keydown', (e) => {
    const index = Array.prototype.indexOf.call(tabs, e.currentTarget);
    const dir =
    e.key === 'ArrowLeft' ? index - 1 : e.key === 'ArrowRight' ? index + 1 : null;
    if (dir !== null) {
    e.preventDefault();
    if (tabs[dir]) this.switchTab(tabs[dir], dir);
    }
    });
    if (!this.TabStore[i]) this.TabStore.push(new Set());
    this.TabStore[i].add(tab);
    });
    // Initialization logic for panels and initial tab selection
    let initialTab = 0;
    Array.prototype.forEach.call(tabs, (tab: HTMLElement, i: number) => {
    if (tab.dataset.initial) initialTab = i;
    });
    // Re-initialize logic after Starlight page transitions
    window.addEventListener('astro:after-swap', () => {
    const tabs = this.querySelectorAll<HTMLElement>('[role="tab"]');
    Array.prototype.forEach.call(tabs, (tab: HTMLElement, i: number) => {
    const index = Array.prototype.indexOf.call(tabs, tab);
    const dir = index;
    if (dir !== null) {
    if (tabs[dir]) this.switchTab(tabs[dir], dir);
    }
    });
    });
    Array.prototype.forEach.call(panels, (panel: HTMLElement, i: number) => {
    panel.setAttribute('role', 'tabpanel');
    panel.setAttribute('tabindex', '-1');
    panel.setAttribute('aria-labelledby', tabs[i].id);
    panel.hidden = true;
    if (!this.PanelStore[i]) this.PanelStore.push(new Set());
    this.PanelStore[i].add(panel);
    });
    tabs[initialTab].removeAttribute('tabindex');
    tabs[initialTab].setAttribute('aria-selected', 'true');
    panels[initialTab].hidden = false;
    }
    switchTab(newTab: HTMLElement, index: number) {
    this.TabStore.forEach((store) =>
    store.forEach((oldTab) => {
    oldTab.removeAttribute('aria-selected');
    oldTab.setAttribute('tabindex', '-1');
    })
    );
    this.TabStore[index].forEach((newTab) => {
    newTab.removeAttribute('tabindex');
    newTab.setAttribute('aria-selected', 'true');
    });
    this.PanelStore.forEach((store) =>
    store.forEach((oldPanel) => {
    oldPanel.hidden = true;
    })
    );
    this.PanelStore[index].forEach((newPanel) => {
    newPanel.hidden = false;
    });
    newTab.focus();
    }
    }
    customElements.define('tabbed-content', Tabs);
    </script>
    src/components/tabs/TabListItem.astro
    ---
    import type { HTMLAttributes } from 'astro/types';
    export interface Props {
    id: string;
    initial?: boolean;
    class?: string;
    }
    const { id, initial } = Astro.props;
    const linkAttributes: HTMLAttributes<'a'> = initial ? { 'data-initial': 'true' } : {};
    ---
    <li class:list={Astro.props.class}>
    <a href={'#' + id} {...linkAttributes}><slot /></a>
    </li>
    src/components/tabs/TabPanel.astro
    ---
    import type { HTMLAttributes } from 'astro/types';
    export interface Props {
    id: string;
    initial?: boolean;
    }
    const { id, initial } = Astro.props;
    const attributes: HTMLAttributes<'div'> = initial ? { 'data-initial': 'true' } : {};
    ---
    <div {id} {...attributes}>
    <slot />
    </div>
    <style>
    div {
    animation: tab-panel-appear 10s steps(2, jump-none) 1;
    }
    div[data-initial],
    div[role='tabpanel'] {
    animation: none;
    }
    @keyframes tab-panel-appear {
    from {
    display: none;
    content-visibility: hidden;
    }
    }
    </style>
  3. Create the Custom Sidebar Component

    This component overrides Starlight’s default Sidebar. It uses the components from the previous step and assigns icons like “astro” and “starlight”.

    src/components/Sidebar.astro
    ---
    import { Icon } from "@astrojs/starlight/components";
    import SidebarPersister from "@astrojs/starlight/components/SidebarPersister.astro";
    import SidebarSublist from "@astrojs/starlight/components/SidebarSublist.astro";
    import type { Props } from "@astrojs/starlight/props";
    import TabbedContent from "./tabs/TabbedContent.astro";
    import TabListItem from "./tabs/TabListItem.astro";
    import TabPanel from "./tabs/TabPanel.astro";
    const { sidebar } = Astro.locals.starlightRoute;
    // Make sure all top-level items in the sidebar are groups.
    type SidebarEntry = (typeof sidebar)[number];
    type Group = Extract<SidebarEntry, { type: "group" }>;
    function assertGroups(sidebar: SidebarEntry[]): asserts sidebar is Group[] {
    for (const entry of sidebar) {
    if (entry.type !== "group") {
    throw new Error("Top-level links are not permitted in the docs sidebar.");
    }
    }
    }
    assertGroups(sidebar);
    /** Convert a group label to an `id` we can use to identify tab panels. */
    const makeId = (label: string) =>
    "__tab-" + label.toLowerCase().replaceAll(/\s+/g, "-");
    /** Get the icon for a group. */
    const getIcon = (label: string) => {
    if (label.toLowerCase().includes("astro")) return "astro";
    if (label.toLowerCase().includes("starlight")) return "starlight";
    return "open-book";
    };
    /** Determine if an array of sidebar items contains the current page. */
    const isCurrent = (sidebar: SidebarEntry[]): boolean =>
    sidebar
    .map((entry) =>
    entry.type === "link" ? entry.isCurrent : isCurrent(entry.entries),
    )
    .some((entry) => entry === true);
    const anyTabIsCurrent = sidebar.some(({ entries }) => isCurrent(entries));
    const isInitial = (entries: SidebarEntry[], index: number) =>
    anyTabIsCurrent ? isCurrent(entries) : index === 0;
    ---
    <SidebarPersister>
    <TabbedContent class="tabbed-sidebar">
    <Fragment slot="tab-list">
    {
    sidebar.map(({ label, entries }, index) => (
    <TabListItem
    id={makeId(label)}
    initial={isInitial(entries, index)}
    class="tab-item"
    >
    <Icon class="icon" name={getIcon(label)} /> {label}
    </TabListItem>
    ))
    }
    </Fragment>
    {
    sidebar.map(({ label, entries }, index) => (
    <TabPanel id={makeId(label)} initial={isInitial(entries, index)}>
    <SidebarSublist sublist={entries} />
    </TabPanel>
    ))
    }
    </TabbedContent>
    </SidebarPersister>
    <style>
    /** Always show the scrollbar gutter. */
    :global(.sidebar-pane) {
    overflow-y: scroll;
    }
    /* Styles for the custom tab switcher. */
    .tabbed-sidebar {
    /* Layout variables */
    --tab-switcher-border-width: 1px;
    --tab-switcher-padding: calc(
    0.25rem - var(--tab-switcher-border-width)
    );
    --tab-item-border-radius: 0.5rem;
    --tab-switcher-border-radius: calc(
    var(--tab-item-border-radius) + var(--tab-switcher-padding) +
    var(--tab-switcher-border-width)
    );
    /* Color variables */
    --tab-switcher-border-color: var(--sl-color-hairline-light);
    --tab-switcher-background-color: var(
    --sl-color-gray-7,
    var(--sl-color-gray-6)
    );
    --tab-switcher-text-color: var(--sl-color-gray-3);
    --tab-switcher-text-color--active: var(--sl-color-white);
    --tab-switcher-icon-color: var(--sl-color-gray-4);
    --tab-switcher-icon-color--active: var(--sl-color-text-accent);
    --tab-item-background-color--hover: var(--sl-color-gray-6);
    --tab-item-background-color--active: var(--sl-color-black);
    }
    /* Dark theme variations */
    :global([data-theme="dark"]) .tabbed-sidebar {
    --tab-switcher-text-color: var(--sl-color-gray-2);
    --tab-switcher-icon-color: var(--sl-color-gray-3);
    --tab-item-background-color--hover: var(--sl-color-gray-5);
    }
    @media (min-width: 50rem) {
    /* Dark theme variations with the desktop sidebar visible */
    :global([data-theme="dark"]) .tabbed-sidebar {
    --tab-switcher-background-color: var(--sl-color-black);
    --tab-item-background-color--hover: var(--sl-color-gray-6);
    --tab-item-background-color--active: var(--sl-color-gray-6);
    }
    }
    .tabbed-sidebar.tab-list {
    border: var(--tab-switcher-border-width) solid
    var(--tab-switcher-border-color);
    border-radius: var(--tab-switcher-border-radius);
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    padding: var(--tab-switcher-padding);
    background-color: var(--tab-switcher-background-color);
    margin-bottom: 1.5rem;
    }
    .tab-item :global(a) {
    border: var(--tab-switcher-border-width) solid transparent;
    border-radius: var(--tab-item-border-radius);
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: calc(0.5rem - var(--tab-switcher-border-width));
    background-clip: padding-box;
    line-height: var(--sl-line-height-headings);
    text-decoration: none;
    color: var(--tab-switcher-text-color);
    font-weight: 600;
    }
    .tab-item :global(a:hover) {
    color: var(--tab-switcher-text-color--active);
    background-color: var(--tab-item-background-color--hover);
    }
    .tab-item :global(a[aria-selected="true"]) {
    border-color: var(--tab-switcher-border-color);
    color: var(--tab-switcher-text-color--active);
    background-color: var(--tab-item-background-color--active);
    }
    .icon {
    margin: 0.25rem;
    color: var(--tab-switcher-icon-color);
    }
    .tab-item :global(a:hover) .icon {
    color: inherit;
    }
    .tab-item :global(a[aria-selected="true"]) .icon {
    color: var(--tab-switcher-icon-color--active);
    }
    </style>
  4. Configure Astro

    Finally, register your custom component and import the modular sidebar configs.

    astro.config.mjs
    import starlight from "@astrojs/starlight";
    import { defineConfig } from "astro/config";
    import { astroSidebar, starlightSidebar } from "./config/sidebar";
    export default defineConfig({
    integrations: [
    starlight({
    components: {
    Sidebar: "./src/components/Sidebar.astro",
    },
    sidebar: [...astroSidebar, ...starlightSidebar],
    }),
    ],
    });
  1. Astro Documentation Sidebar Config
  2. Starlight Sidebar Customization