GlassKit UI
Primitives

ComposeFlow

The working text-entry recipe: a focusable field that opens a picker of choices when activated; choosing writes back and returns. The picker rides history, so the back gesture closes it. It stands in for the on-device dictation the platform does not expose yet.

Pinch the field · pinch back closes

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.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/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("flex flex-col items-center gap-1 text-center", className)}>      {eyebrow != null ? (        <span className="t-caption uppercase tracking-[0.16em] text-primary">          {eyebrow}        </span>      ) : null}      <h2 className="t-title">{children}</h2>    </div>  );}
// components/glasskit/quick-reply-chips.tsximport { cn } from "../lib/utils";/** * <QuickReplyChips> — tappable canned replies (the comms job; there is no * keyboard on the lens — text is voice). Each chip is D-pad-focusable. Keep the * set short and the labels glanceable. */export function QuickReplyChips({  options,  onSelect,  className,}: {  options: string[];  onSelect?: (reply: string) => void;  className?: string;}) {  return (    <div className={cn("flex flex-wrap justify-center gap-2", className)}>      {options.map((o, i) => (        <button          key={`${i}-${o}`}          type="button"          onClick={onSelect ? () => onSelect(o) : undefined}          className="focusable press-scale t-body surface rounded-full py-3.5 px-[22px]"        >          {o}        </button>      ))}    </div>  );}
// 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 { Heading } from "./heading";import { QuickReplyChips } from "./quick-reply-chips";/** * <ComposeFlow> — the working text-entry recipe for a platform with no * keyboard or microphone: a focusable field 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 the ComposeFlow docs), swap the picker for the system flow and the * field API doesn't change. */export function ComposeFlow({  label,  value,  placeholder = "Pinch to enter text",  options,  pickerTitle = "Choose",  icon,  onChange,  className,}: {  /** Field label. */  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;  /** Field trailing glyph. */  icon?: ReactNode;  onChange?: (value: string) => void;  className?: string;}) {  const [open, setOpen] = useState(false);  const field = useRef<HTMLButtonElement>(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) {      field.current?.focus();    }    wasOpen.current = open;  }, [open]);  const choose = (v: string) => {    onChange?.(v);    history.back(); // popstate closes the picker — history stays balanced  };  const filled = value != null && value !== "";  return (    <div className={cn("contents", className)}>      {open ? (        <FocusScope restoreFocus={false}>          <Heading>{pickerTitle}</Heading>          <QuickReplyChips options={options} onSelect={choose} />        </FocusScope>      ) : (        <button          ref={field}          type="button"          onClick={openPicker}          className="focusable gk-composefield surface flex w-full items-center gap-[14px] rounded-lens px-5 py-4 text-start"        >          <span className="flex min-w-0 flex-1 flex-col gap-[3px] text-start">            {label != null ? (              <span className="t-caption uppercase tracking-[0.1em] text-foreground-faint">                {label}              </span>            ) : null}            <span              className={cn(                "t-body",                filled ? "text-foreground" : "text-foreground-faint",              )}            >              {filled ? value : placeholder}            </span>          </span>          {icon != null ? (            <span className="[&_.gk-icon]:size-[26px] [&_.gk-icon]:text-accent-active">              {icon}            </span>          ) : null}        </button>      )}    </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

PropTypeDefaultDescription
labelReactNodeField label.
valuestring | nullCurrent value. Controlled, so pair with onChange.
placeholderReactNode"Pinch to enter text"Empty-field hint.
optionsstring[]The choices the picker offers.
pickerTitleReactNode"Choose"Heading on the picker view.
iconReactNodeField trailing glyph.
onChange(value: string) => voidFires with the chosen option.
classNamestringExtra classes.

When to use

ComposeFlow is the working answer for text on the lens: a picker for an enumerable set of answers, opened from a field activation. There is no keyboard or microphone API, so for anything outside an enumerable set there is no on-device text path today.

On this page