Components
ComposeFlow
The working text-entry recipe: a TextField that opens a picker of choices when activated; choosing writes back and returns. The picker rides history, so the back gesture closes it. The seam system dictation would replace.
Installation
npx @glasskit-ui/cli add compose-flowInstall the SDK (it provides GlassViewport, useDpad and the stylesheet), then copy these files into your project:
npm install @glasskit-ui/react// components/lib/utils.tsexport type ClassValue = string | number | null | undefined | false;/** * Join truthy class names. Dependency-free on purpose: the lens components * style via bespoke semantic classes (no conflicting Tailwind utilities to * de-dupe), so this needs no clsx/tailwind-merge and resolves from anywhere * the registry is vendored. */export function cn(...inputs: ClassValue[]): string { return inputs.filter(Boolean).join(" ");}/** * 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/text-field.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";/** * <TextField> — a text entry surface. There is no keyboard on the lens, so this * is a D-pad-focusable field that shows the current value (or a placeholder) * and a trailing affordance (a mic glyph). Pure display + `onActivate` — you * own the capture flow it opens. * * Platform note (2026-06): web apps get no microphone (no getUserMedia) and * no system text-input API, so on-device capture means your own picker UI * (e.g. a <List> of choices) or text relayed from the phone. The mic glyph is * a familiar affordance, not a promise of dictation. */export function TextField({ label, value, placeholder = "Pinch to enter text", icon, onActivate, className,}: { label?: ReactNode; value?: ReactNode; placeholder?: ReactNode; /** Trailing glyph — typically a mic <GlowIcon>. */ icon?: ReactNode; onActivate?: () => void; className?: string;}) { const filled = value != null && value !== ""; return ( <button type="button" onClick={onActivate} className={cn("focusable gk-textfield", className)} > <span className="gk-textfield__main"> {label != null ? ( <span className="gk-textfield__label t-caption">{label}</span> ) : null} <span className={cn( "gk-textfield__value t-body", !filled && "gk-textfield__value--empty", )} > {filled ? value : placeholder} </span> </span> {icon != null ? <span className="gk-textfield__icon">{icon}</span> : null} </button> );}// components/glasskit/heading.tsximport type { ReactNode } from "react";import { cn } from "../lib/utils";/** * <Heading> — a screen/section title with an optional eyebrow label above it. * Pure display. Use sparingly — one heading per view keeps the glance cheap. */export function Heading({ children, eyebrow, className,}: { children: ReactNode; /** Small tracked label above the title. */ eyebrow?: ReactNode; className?: string;}) { return ( <div className={cn("gk-heading", className)}> {eyebrow != null ? ( <span className="gk-heading__eyebrow t-caption">{eyebrow}</span> ) : null} <h2 className="gk-heading__title t-title">{children}</h2> </div> );}// components/glasskit/list.tsx"use client";import { useEffect, useRef, type ReactNode } from "react";import { cn } from "../lib/utils";/** * <List> — a vertical stack of focusable rows (watchOS list spirit) that fills * the stage and scrolls (D-pad scrollIntoView). A position indicator on the * inline-end edge tracks scroll — sized to the content and hidden when the list * fits. Compose with <ListRow>. */export function List({ children, className,}: { children: ReactNode; className?: string;}) { const scrollRef = useRef<HTMLDivElement>(null); const railRef = useRef<HTMLSpanElement>(null); const thumbRef = useRef<HTMLSpanElement>(null); useEffect(() => { const sc = scrollRef.current; const rail = railRef.current; const thumb = thumbRef.current; if (!sc || !rail || !thumb) return; const update = () => { const { scrollTop, scrollHeight, clientHeight } = sc; const overflow = scrollHeight - clientHeight; if (overflow <= 1) { rail.dataset.show = "false"; return; } rail.dataset.show = "true"; const trackH = rail.clientHeight; const thumbH = Math.max(trackH * (clientHeight / scrollHeight), 26); const pos = (scrollTop / overflow) * (trackH - thumbH); thumb.style.height = `${thumbH}px`; thumb.style.transform = `translateY(${pos}px)`; }; update(); sc.addEventListener("scroll", update, { passive: true }); const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(update) : null; ro?.observe(sc); return () => { sc.removeEventListener("scroll", update); ro?.disconnect(); }; }, []); return ( <div className={cn("gk-list", className)}> <div className="gk-list__scroll" ref={scrollRef}> {children} </div> <span className="gk-list__rail" ref={railRef} data-show="false" aria-hidden="true" > <span className="gk-list__bar" ref={thumbRef} /> </span> </div> );}/** * <ListRow> — a D-pad-focusable row: leading glyph, label, trailing value. * Renders a real <button> with the `focusable` class so `useDpad()` walks * it. Logical layout (leading = inline-start) for RTL safety. */export function ListRow({ children, leading, trailing, onClick, disabled, className,}: { /** The row label. */ children: ReactNode; /** Optional inline-start glyph — typically a <GlowIcon>. */ leading?: ReactNode; /** Optional inline-end value/affordance. */ trailing?: ReactNode; onClick?: () => void; disabled?: boolean; className?: string;}) { return ( <button type="button" disabled={disabled} onClick={onClick} className={cn("focusable gk-list-row t-body", className)} > {leading} <span className="gk-list-row__label">{children}</span> {trailing != null ? ( <span className="gk-list-row__trailing t-caption">{trailing}</span> ) : null} </button> );}// components/glasskit/compose-flow.tsx"use client";import { useEffect, useRef, useState, type ReactNode } from "react";import { FocusScope } from "@glasskit-ui/react";import { cn } from "../lib/utils";import { TextField } from "./text-field";import { Heading } from "./heading";import { List, ListRow } from "./list";/** * <ComposeFlow> — the working text-entry recipe for a platform with no * keyboard or microphone: a <TextField> that opens a picker of choices when * activated; choosing writes the value back and returns to the field. The * picker is a real back-gesture surface — opening pushes a history entry, so * a middle pinch (or Escape in desktop dev) closes it instead of leaving the * screen, inside or outside a <Navigator>. * * This is the seam system dictation would replace: if Meta ships a text-input * API (see /docs/wishlist), swap the picker for the system flow and the * field API doesn't change. */export function ComposeFlow({ label, value, placeholder, options, pickerTitle = "Choose", icon, onChange, className,}: { /** Field label (forwarded to <TextField>). */ label?: ReactNode; /** Current value. Controlled — pair with `onChange`. */ value?: string | null; placeholder?: ReactNode; /** The choices the picker offers. */ options: string[]; /** Heading on the picker view. */ pickerTitle?: ReactNode; /** TextField trailing glyph. */ icon?: ReactNode; onChange?: (value: string) => void; className?: string;}) { const [open, setOpen] = useState(false); const root = useRef<HTMLDivElement>(null); const wasOpen = useRef(false); const openPicker = () => { // Ride history so the system back gesture closes the picker. Carrying a // bumped gkNavDepth makes an enclosing <Navigator> treat the entry as // "not mine" and leave its stack alone. history.pushState( { ...history.state, gkCompose: true, gkNavDepth: (history.state?.gkNavDepth ?? 0) + 1, }, "", ); setOpen(true); }; useEffect(() => { if (!open) return; const onPop = () => setOpen(false); const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; // The picker owns this back — a Navigator's own Escape handler would // otherwise also call history.back() and pop two entries. Capture // phase runs first; stop the event there. e.stopImmediatePropagation(); history.back(); }; window.addEventListener("popstate", onPop); window.addEventListener("keydown", onKey, true); return () => { window.removeEventListener("popstate", onPop); window.removeEventListener("keydown", onKey, true); }; }, [open]); // Closing re-renders the field view with fresh DOM — put the ring back on // the field so the wearer continues where they left off. useEffect(() => { if (wasOpen.current && !open) { root.current?.querySelector<HTMLElement>(".gk-textfield")?.focus(); } wasOpen.current = open; }, [open]); const choose = (v: string) => { onChange?.(v); history.back(); // popstate closes the picker — history stays balanced }; return ( <div ref={root} className={cn("gk-compose", className)}> {open ? ( <FocusScope restoreFocus={false}> <Heading>{pickerTitle}</Heading> <List> {options.map((o) => ( <ListRow key={o} onClick={() => choose(o)}> {o} </ListRow> ))} </List> </FocusScope> ) : ( <TextField label={label} value={value ?? undefined} placeholder={placeholder} icon={icon} onActivate={openPicker} /> )} </div> );}Usage
const [reply, setReply] = useState<string | null>(null);<ComposeFlow label="Reply" value={reply} onChange={setReply} options={["On my way", "5 min", "Call me"]} pickerTitle="Quick replies"/>Props
Prop
Type
Viewfinder
Camera-POV chrome: bold corner brackets, optional zoom and REC badges. Web apps have no camera access — this is presentation scaffolding for a camera-style UI; recording is app state you set.
Dictation
The voice-to-text surface (no keyboard on the lens — text is voice or Neural-Band handwriting): a live waveform + the running transcript. You own the recognition and feed transcript.