Primitives
Stepper
Adjust a value in discrete steps (glasses have no fine slider). One focus stop: focus it, then swipe left/right (ArrowLeft/Right) to change by step; bounds clamp the ends. Controlled via value + onChange.
Installation
npx @glasskit-ui/cli add stepperInstall 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/stepper.tsximport type { KeyboardEvent, ReactNode } from "react";import { cn } from "../lib/utils";/** * <Stepper> — adjust a value in discrete steps (glasses have no fine slider). * A single D-pad-focusable control: focus it once, then swipe ◀ / ▶ (the Neural * Band's horizontal swipe arrives as ArrowLeft/ArrowRight) to change the value * by `step`; vertical swipes still navigate away. The − and + are visual hints, * not separate focus stops. Controlled: pass `value` + `onChange`. Bounds clamp * the ends; omit `onChange` for a read-only readout that leaves the focus ring. */export function Stepper({ value, onChange, min, max, step = 1, label, unit, className,}: { value: number; onChange?: (next: number) => void; min?: number; max?: number; step?: number; label?: ReactNode; unit?: ReactNode; className?: string;}) { const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n)); const atMin = min != null && value <= min; const atMax = max != null && value >= max; const name = typeof label === "string" ? label : "value"; const readOnly = onChange == null; function onKeyDown(e: KeyboardEvent<HTMLDivElement>) { if (!onChange) return; if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; // ↑/↓ navigate // This control owns its horizontal axis (like a slider). The focus engine // listens on window; stopPropagation here keeps it from moving the ring. e.preventDefault(); e.stopPropagation(); const rtl = getComputedStyle(e.currentTarget).direction === "rtl"; const dir = e.key === "ArrowRight" ? 1 : -1; const next = clamp(value + (rtl ? -dir : dir) * step); if (next !== value) onChange(next); } const glyph = "text-[22px] leading-none transition-opacity"; return ( <div className={cn("flex flex-col items-center gap-2", className)}> {label != null ? ( <span className="t-caption uppercase tracking-[0.16em] text-foreground-faint"> {label} </span> ) : null} <div role="spinbutton" tabIndex={readOnly ? undefined : 0} className={cn( "surface inline-flex items-center gap-5 rounded-full px-6 py-2.5", !readOnly && "focusable", )} aria-label={name} aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} aria-valuetext={typeof unit === "string" ? `${value} ${unit}` : undefined} aria-readonly={readOnly || undefined} onKeyDown={readOnly ? undefined : onKeyDown} > <span aria-hidden className={cn(glyph, atMin && "opacity-30")}> − </span> <span className="t-readout min-w-[4ch] text-center"> {value} {unit != null ? ( <span className="text-[14px] text-foreground-faint"> {unit}</span> ) : null} </span> <span aria-hidden className={cn(glyph, atMax && "opacity-30")}> + </span> </div> </div> );}Usage
<Stepper label="Brightness" value={value} min={1} max={5} onChange={setValue}/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | Current value (controlled). |
onChange | (next: number) => void | — | Fires on swipe ◀ / ▶ (clamped). Omit for a read-only readout. |
min | number | — | Lower bound (clamps −). |
max | number | — | Upper bound (clamps +). |
step | number | 1 | Increment. |
label / unit | ReactNode | — | Caption / trailing unit. |
Slider
A continuous level control (volume, brightness, the quick controls). A native range tinted with accent-color; arrow keys / Neural-Band pinch-twist adjust it. Controlled via value + onChange.
Segmented
Pick one of a few options (a watchOS-style segmented control). Each segment is a D-pad-focusable radio; the selected one lifts with the accent. Keep it to 2–4 options.