GlassKit UI
Components

Compass

A heading rose: North stays world-aligned while a fixed top marker shows where you face. World-anchored — never mirrored under RTL.

NESW0°NHeading

Live head orientation

600 × 600 · live

Installation

npx @glasskit-ui/cli add compass

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/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("gk-compass", 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="44" className="gk-compass__ring" />        {/* fixed marker at the top = the direction you face */}        <path d="M50 5 L45 15 L55 15 Z" className="gk-compass__marker" />        {/* the rose counter-rotates so cardinals stay world-aligned */}        <g transform={`rotate(${-deg} 50 50)`}>          <text x="50" y="14" className="gk-compass__n">            N          </text>          <text x="86" y="51" className="gk-compass__tick">            E          </text>          <text x="50" y="88" className="gk-compass__tick">            S          </text>          <text x="14" y="51" className="gk-compass__tick">            W          </text>        </g>        {/* big fixed center readout (does not rotate) */}        <text x="50" y="48" className="gk-compass__deg">          {Math.round(deg)}°        </text>        <text x="50" y="60" className="gk-compass__card">          {cardinal(deg)}        </text>      </svg>      {label != null ? (        <span className="gk-compass__label t-caption">{label}</span>      ) : null}    </div>  );}

Usage

// self-wired: follows useDeviceOrientation<Compass />// or controlled (demos, your own sensor fusion)<Compass heading={290} />

Props

Prop

Type