Primitives
Compass
A heading rose: North stays world-aligned while a fixed top marker shows where you face. World-anchored, never mirrored under RTL.
Installation
npx @glasskit-ui/cli add compassInstall 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/lib/geo.ts/** * Bearing math for world-anchored components (DirectionArrow, Compass). * Pure and dependency-free so the spatial logic is unit-testable and vendors * cleanly alongside the components that import it. */export type LatLon = { lat: number; lon: number };/** Normalize any angle in degrees to [0, 360). */export function normalizeDeg(deg: number): number { return ((deg % 360) + 360) % 360;}/** * Initial great-circle bearing from `from` to `to`, in degrees clockwise from * true north, [0, 360). The standard forward-azimuth formula — accurate for * the on-foot distances a heads-up display cares about. */export function bearingBetween(from: LatLon, to: LatLon): number { const φ1 = (from.lat * Math.PI) / 180; const φ2 = (to.lat * Math.PI) / 180; const Δλ = ((to.lon - from.lon) * Math.PI) / 180; const y = Math.sin(Δλ) * Math.cos(φ2); const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ); return normalizeDeg((Math.atan2(y, x) * 180) / Math.PI);}/** * Screen-relative bearing: where `absolute` (degrees from north) lands once * the wearer's `heading` is subtracted — i.e. the direction to draw on a * display whose "up" is wherever the wearer faces. */export function relativeBearing(absolute: number, heading: number): number { return normalizeDeg(absolute - heading);}// components/glasskit/compass.tsx"use client";import type { ReactNode } from "react";import { useDeviceOrientation } from "@glasskit-ui/react";import { cn } from "../lib/utils";import { normalizeDeg } from "../lib/geo";const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];/** Nearest cardinal/intercardinal label for a heading in degrees. */export function cardinal(deg: number): string { return DIRS[Math.round(normalizeDeg(deg) / 45) % 8]!;}/** * <Compass> — a heading rose that keeps North pointing at real north while a * fixed top marker shows where you face. Self-connects to * useDeviceOrientation; pass `heading` to control it instead (the prop always * wins, e.g. for demos or your own sensor fusion). * * WORLD-ANCHORED — never mirror under RTL. The rose counter-rotates via the SVG * `transform` attribute (absolute), so the spatial mapping is preserved in any * writing direction. */export function Compass({ heading, label, className,}: { /** Controlled heading in degrees. Omit to read the live head orientation. */ heading?: number; label?: ReactNode; className?: string;}) { // Always subscribed (rules of hooks); the controlled prop wins afterwards. // Server render and first client render both see alpha === null → 0, so // the live mode is hydration-safe. const live = useDeviceOrientation(); const deg = normalizeDeg(heading ?? live.alpha ?? 0); return ( <div className={cn("flex flex-col items-center gap-[18px]", className)} > <svg viewBox="0 0 100 100" className="gk-compass__dial" role="img" aria-label={`Heading ${Math.round(deg)} degrees ${cardinal(deg)}`} > <circle cx="50" cy="50" r="45" className="gk-compass__ring" /> <circle cx="50" cy="50" r="36" className="gk-compass__inner" /> {/* fixed bearing marker on the outer rim = the direction you face */} <path d="M50 3 L46 12 L54 12 Z" className="gk-compass__marker" /> {/* the rose counter-rotates so cardinals stay world-aligned; they sit on an inner ring, clear of the rim marker so nothing overlaps */} <g transform={`rotate(${-deg} 50 50)`}> <text x="50" y="22" className="gk-compass__n"> N </text> <text x="78" y="51" className="gk-compass__tick"> E </text> <text x="50" y="80" className="gk-compass__tick"> S </text> <text x="22" y="51" className="gk-compass__tick"> W </text> </g> {/* fixed center readout (does not rotate) */} <text x="50" y="47" className="gk-compass__deg"> {Math.round(deg)}° </text> <text x="50" y="61" className="gk-compass__card"> {cardinal(deg)} </text> </svg> {label != null ? ( <span className="t-caption tabular-nums text-muted-foreground"> {label} </span> ) : null} </div> );}Usage
// self-wired: follows useDeviceOrientation<Compass />// or controlled (demos, your own sensor fusion)<Compass heading={290} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
heading | number | — | Controlled heading in degrees. Omit to read the live head orientation. |
label | ReactNode | — | Optional caption. |
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.
DirectionArrow
Points toward a real-world bearing. World-anchored: it rotates via an SVG transform attribute and is never mirrored under RTL, since a flipped arrow points the wrong way.