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

Zoom
2 ×
Swipe ◀ / ▶ to adjust

Installation

npx @glasskit-ui/cli add stepper

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

PropTypeDefaultDescription
valuenumberCurrent value (controlled).
onChange(next: number) => voidFires on swipe ◀ / ▶ (clamped). Omit for a read-only readout.
minnumberLower bound (clamps −).
maxnumberUpper bound (clamps +).
stepnumber1Increment.
label / unitReactNodeCaption / trailing unit.

On this page