Components
DirectionArrow
Points toward a real-world bearing. World-anchored: it rotates via an SVG transform attribute and is never mirrored under RTL — a flipped arrow points the wrong way.
Installation
npx @glasskit-ui/cli add direction-arrowInstall 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/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/direction-arrow.tsx"use client";import type { ReactNode } from "react";import { useDeviceOrientation, useGeolocation } from "@glasskit-ui/react";import { cn } from "../lib/utils";import { bearingBetween, normalizeDeg, relativeBearing, type LatLon,} from "../lib/geo";/** * <DirectionArrow> — points toward a real-world direction. Three modes: * - `bearing` — controlled: you computed the screen angle yourself * - `target` — self-connects: live GPS + head orientation aim the needle * at a {lat, lon} (bearing wins if both are given) * - neither — points up until a sensor mode is chosen * * WORLD-ANCHORED — never mirror under RTL (safety: a flipped arrow points the * wrong way). The needle rotates via the SVG `transform` *attribute* (absolute, * unaffected by `dir`), not an inline style and not a logical transform. */export function DirectionArrow({ bearing, target, label, className,}: { /** Controlled screen angle in degrees (0 = up, clockwise). Always wins. */ bearing?: number; /** Aim at a world coordinate using live geolocation + head orientation. */ target?: LatLon; /** Optional caption (e.g. street name). */ label?: ReactNode; className?: string;}) { // Always subscribed (rules of hooks); precedence is bearing > target > 0. // Sensors start null on the server and first client render → deterministic // 0° until real data arrives, so live mode is hydration-safe. const { position } = useGeolocation(); const { alpha } = useDeviceOrientation(); const live = target && position ? relativeBearing(bearingBetween(position, target), alpha ?? 0) : 0; const deg = normalizeDeg(bearing ?? live); return ( <div className={cn("gk-direction", className)}> <svg viewBox="0 0 100 100" className="gk-direction__dial" role="img" aria-label={`Bearing ${Math.round(deg)} degrees`} > <circle cx="50" cy="50" r="40" className="gk-direction__ring" /> <g transform={`rotate(${deg} 50 50)`}> <path d="M50 22 L67 70 L50 60 L33 70 Z" className="gk-direction__needle" /> </g> </svg> {label != null ? ( <span className="gk-direction__label t-caption">{label}</span> ) : null} </div> );}Usage
// self-wired: live geolocation + head orientation<DirectionArrow target={{ lat: 37.7749, lon: -122.4194 }} label="Market St" />// or controlled, if you computed the angle yourself<DirectionArrow bearing={bearing} label="Market St" />Props
Prop
Type
Compass
A heading rose: North stays world-aligned while a fixed top marker shows where you face. World-anchored — never mirrored under RTL.
Pin
A world-anchored waypoint marker (ring + dot, name + distance above) placed at a projected screen point. You project: derive x from the target's relative bearing (lib/geo) — the platform gives heading + GPS, not 3D pose. Never mirrored under RTL.