GlassKit UI
Primitives

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.

Ferry Building
Distance320m
Toward the Ferry Building

Installation

npx @glasskit-ui/cli add direction-arrow

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/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("flex flex-col items-center gap-[18px]", 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="t-caption text-muted-foreground">{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

PropTypeDefaultDescription
bearingnumberControlled screen angle in degrees (0 = up, clockwise). Always wins.
target{ lat, lon }Self-connects: live GPS + head orientation aim the needle at this coordinate.
labelReactNodeOptional caption.

When to use

DirectionArrow and MapView are two different jobs, not a light vs heavy version of one thing:

  • DirectionArrow points at one target and nothing else: a friend, your car, the gate, an off-screen Pin, the last 50 m to a door. A single arrow that swings to stay locked on as you turn your head. Zero requests, sensor-only (useGeolocation plus useDeviceOrientation), instant.
  • MapView gives spatial context: the route, the cross-streets, where you are among options. A real Leaflet basemap (about 42KB plus tiles).

If the wearer only needs a heading, the arrow beats a map: it is lighter, glanceable, and reads as guidance floating in the world. Reach for MapView the moment "which way?" becomes "show me the route."

On this page