GlassKit UI
Navigation
Copy for LLM

Tabs

A top-level tab strip (the home's quick-controls, home, apps pager). Each tab is D-pad-focusable; the active one gets an accent indicator. Controlled via value and onChange. Tabs do not touch history, so back exits the app.

Active tabHome
Home

Installation

npx @glasskit-ui/cli add tabs

Install the SDK (it provides GlassViewport, useDpad and the stylesheet), then copy these files into your project:

npm install @glasskit-ui/react
// components/lib/utils.tsimport { clsx, type ClassValue } from "clsx";import { twMerge } from "tailwind-merge";export type { ClassValue };/** * Merge class names the shadcn way: clsx joins conditionals, tailwind-merge * de-dupes conflicting Tailwind utilities so a consumer's `className` override * wins (e.g. passing `px-2` beats the component's `px-6`). Lens components are * Tailwind utilities + `--gk-*` tokens, so this de-dupe matters. */export function cn(...inputs: ClassValue[]): string {  return twMerge(clsx(inputs));}/** * Accessible name from a free-form `label` prop: the label itself when it's a * plain string, otherwise undefined (a ReactNode can't become an aria-label). */export function stringLabel(label: unknown): string | undefined {  return typeof label === "string" ? label : undefined;}
// components/glasskit/tabs.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";export type TabItem = { id: string; label: ReactNode };/** * <Tabs> — a top-level tab strip (the home's quick-controls | home | apps * pager). Anchor it at the top of the view — pass it as <Screen>'s `status` * slot — so context stays glanceable above the content it switches. * Selected = accent underline; focused = the system focus ring (two distinct * affordances). Controlled via `value` + `onChange`. RTL-safe (logical * layout). * * Deliberate ARIA deviation: no `aria-controls`/`tabpanel` wiring — on the * lens a tab switch swaps the whole 600×600 screen, so there is no co-rendered * panel to point at. */export function Tabs({  items,  value,  onChange,  className,}: {  items: TabItem[];  value: string;  onChange?: (id: string) => void;  className?: string;}) {  return (    <div className={cn("flex w-full gap-1.5", className)} role="tablist">      {items.map((t) => {        const on = t.id === value;        return (          <button            key={t.id}            type="button"            role="tab"            aria-selected={on}            onClick={onChange ? () => onChange(t.id) : undefined}            className={cn(              "focusable t-body relative flex-1 rounded-xl border-0 bg-transparent px-2 py-[13px] text-center text-foreground-faint",              // gk-tab--on stays: the active-tab accent pill is a ::after              // pseudo-element (and it brightens the label to #fff).              on && "gk-tab--on",            )}          >            {t.label}          </button>        );      })}    </div>  );}

Usage

<Tabs  value={tab}  onChange={setTab}  items={[    { id: "controls", label: "Controls" },    { id: "home", label: "Home" },    { id: "apps", label: "Apps" },  ]}/>

Props

PropTypeDefaultDescription
items{ id, label }[]The tabs.
valuestringActive tab id (controlled).
onChange(id: string) => voidFires on select.

How to use it

Tabs is a top-level strip for peer views: a small set of screens with no hierarchy that the wearer flips between in any order. The home pager (controls, home, apps) is the canonical example. The tab bar stays visible, so the current view is always glanceable.

When Tabs is the right model

Reach for Tabs when:

  • There are a few destinations (roughly two to four) and they are equals.
  • Order does not matter; the wearer jumps straight to the one they want.
  • You want the current section visible at all times.

If one tab drills into detail screens, that tab owns a Navigator; the Tabs just swap which stack shows. If the screens are sequential rather than peers, use a Deck.

Controlled by value and onChange

Tabs is controlled. You hold the active id and update it on change.

import { Tabs } from "@/components/ui/tabs";

function HomePager() {
  const [tab, setTab] = useState("home");
  return (
    <Screen
      status={
        <Tabs
          value={tab}
          onChange={setTab}
          items={[
            { id: "controls", label: "Controls" },
            { id: "home", label: "Home" },
            { id: "apps", label: "Apps" },
          ]}
        />
      }
    >
      {tab === "controls" && <Controls />}
      {tab === "home" && <Home />}
      {tab === "apps" && <Apps />}
    </Screen>
  );
}

Each tab is D-pad-focusable and the active one carries an accent indicator. The strip is RTL-safe (logical layout), so it flips correctly without you mirroring anything.

Tabs and the back gesture

Tabs do not put anything on the history stack, so the back gesture does not move between tabs; it leaves the app. That is the right behavior for peers: the wearer does not expect "back" to cycle tabs. If you need back to return to a previous view, that is a hierarchy, which means Navigator.

On this page