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.
Installation
npx @glasskit-ui/cli add tabsInstall 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
| Prop | Type | Default | Description |
|---|---|---|---|
items | { id, label }[] | — | The tabs. |
value | string | — | Active tab id (controlled). |
onChange | (id: string) => void | — | Fires 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.
Navigator
A screen stack with system-back integration. Every push adds a real history entry, so the Display's back gesture pops it via popstate. The stack rides in history.state, so a mid-flow reload restores the screen, and opt-in paths mirror pushes into the URL. Pop restores focus to the row that pushed.
Deck
A horizontal paged flow (wizard, onboarding). Shows one page with step dots beneath. Uncontrolled with Neural Band swipe to advance, or controlled via index and onIndexChange. Pages advance on pinch or D-pad, never scroll.