GlassKit UI
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.

Pinch the field · pinch back closes

600 × 600 · live

Installation

npx @glasskit-ui/cli add compose-flow

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.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