GlassKit UI
Components

Deck

A horizontal paged flow (wizard / onboarding). Controlled via index; shows one page with step dots beneath. Pages advance on pinch / D-pad — never scroll.

Step 1

Connect band

Swipe the band (or press Next) to advance

600 × 600 · live

Installation

npx @glasskit-ui/cli add deck

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/progress.tsximport type { ReactNode } from "react";import { cn, stringLabel } from "../lib/utils";/** * <Progress> — emitted progress, two shapes: *   - "linear"  a continuous bar (also covers countdown: feed a decreasing *               value and a time label). Uses a native <progress>, so the *               dynamic fill needs no inline style. *   - "step"    discrete step-of-N dots (wizard / pinch-advance). * * `value` is clamped to [0, max]. For "step", value = completed steps. */export function Progress({  value,  max = 100,  variant = "linear",  label,  className,}: {  value: number;  max?: number;  variant?: "linear" | "step";  /** Optional caption shown with the bar (e.g. "3 of 5", "0:42 left"). */  label?: ReactNode;  className?: string;}) {  const clamped = Math.max(0, Math.min(value, max));  if (variant === "step") {    // Steps must be a sane integer count — a fractional or negative max    // would make Array.from misrender the dots.    const steps = Math.max(0, Math.floor(max));    return (      <div        className={cn("gk-steps", className)}        role="progressbar"        aria-valuenow={clamped}        aria-valuemin={0}        aria-valuemax={steps}        aria-label={stringLabel(label)}      >        {Array.from({ length: steps }, (_, i) => (          <span            key={i}            className={cn("gk-step", i < clamped && "gk-step--on")}          />        ))}      </div>    );  }  return (    <div className={cn("gk-progress", className)}>      <progress        className="gk-progress__el"        value={clamped}        max={max}        aria-label={stringLabel(label)}      />      {label != null ? (        <div className="gk-progress__meta t-caption">{label}</div>      ) : null}    </div>  );}
// components/glasskit/deck.tsx"use client";import { Children, useEffect, useRef, useState, type ReactNode } from "react";import { seedFocus, useNeuralBand } from "@glasskit-ui/react";import { cn } from "../lib/utils";import { Progress } from "./progress";/** * <Deck> — a horizontal paged flow (wizard / onboarding). Shows one page at a * time with step dots beneath (reusing Progress). Self-connects to * useNeuralBand: a wristband `swipe` advances to the next page (clamped, no * wrap). Pass `index` to control paging yourself — a controlled Deck never * self-advances; swipes surface through `onIndexChange` instead. * One nav model per screen: pages advance on pinch / D-pad, never scroll. * * Platform note (2026-06): the Display delivers web apps only arrow/Enter * keys + history-back — it does not (yet) dispatch custom gesture events, so * the `neuralband` CustomEvent is a forward-compat seam you (or a future OS) * dispatch. For paging that works on-device today, drive `index` from a * focusable control (Button, Stepper) or your own ArrowLeft/ArrowRight * handling. */export function Deck({  index,  defaultIndex = 0,  onIndexChange,  children,  className,}: {  /** Controlled page. Omit for uncontrolled (Neural Band swipes advance). */  index?: number;  /** Starting page when uncontrolled. */  defaultIndex?: number;  /** Fires with the next page on every swipe (both modes). */  onIndexChange?: (index: number) => void;  /** One node per page. Falsy children (`{cond && <Page/>}`) are dropped, which   *  changes the page count — render every page and gate inside it instead. */  children: ReactNode;  className?: string;}) {  const pages = Children.toArray(children);  const count = pages.length;  const controlled = index != null;  const [internal, setInternal] = useState(defaultIndex);  const current = Math.max(    0,    Math.min(controlled ? index : internal, count - 1),  );  // useNeuralBand is a one-shot (the gesture clears on the next microtask),  // so an effect keyed on the gesture alone fires exactly once per physical  // swipe — consecutive identical swipes both advance. Everything else is  // read through a latest-values ref: with it in the deps, the re-render the  // advance itself causes would re-run the effect while the gesture string is  // still set and double-advance.  const gesture = useNeuralBand();  const latest = useRef({ current, count, controlled, onIndexChange });  latest.current = { current, count, controlled, onIndexChange };  useEffect(() => {    if (gesture !== "swipe") return;    const { current, count, controlled, onIndexChange } = latest.current;    const next = Math.min(current + 1, count - 1);    if (next === current) return;    if (!controlled) setInternal(next);    onIndexChange?.(next);  }, [gesture]);  // If the focused element lived inside the outgoing page it unmounts on  // advance, stranding focus on <body> — reseed the D-pad ring. Only when  // orphaned: a focus that survived (e.g. an external Next button) keeps it.  const mounted = useRef(false);  useEffect(() => {    if (!mounted.current) {      mounted.current = true;      return;    }    const active = document.activeElement;    if (!active || active === document.body) seedFocus();  }, [current]);  return (    <div className={cn("gk-deck", className)}>      <div        className="gk-deck__stage"        role="group"        aria-label={`Page ${current + 1} of ${count}`}      >        {pages[current]}      </div>      {count > 1 ? (        <Progress variant="step" value={current + 1} max={count} />      ) : null}    </div>  );}

Usage

// self-wired: Neural Band swipes advance<Deck>  <OnboardConnect />  <OnboardCalibrate />  <OnboardReady /></Deck>// or controlled<Deck index={step} onIndexChange={setStep}>…</Deck>

Props

Prop

Type