Zum Inhalt springen
UI Kit
Accent

Globus

Ein Punkte-Globus in echtem CSS-3D mit erfundenem Land, Bögen, die sich zwischen Orten zeichnen, und pulsierenden Pins.

Eine von 3 Components in Daten-Animationen

3 Variants

Variants

Bögen

Routen heben sich nacheinander von der Oberfläche, zeichnen sich und verblassen, während sich der Globus in 48 Sekunden einmal dreht.

Pins

Orte mit weichem Puls, versetzt, damit sie nie im Gleichtakt schlagen. Zeig auf den Globus, dann neigt er sich dir zu.

Ruhiger Globus

Nur Punkte, weniger davon und eine langsamere Drehung. Ein ruhiges Bild hinter einer Headline oder einem Kontaktbereich.

Details

Was Globus mitbringt, aus Registry und Quellcode gelesen.

Bereich
Visuals
Variants
3
Version
1.0.0 · seit 1.0.0
Datei
components/ui/globe.tsx
Bringt mit
utilskitmotion-scene
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Props

Die Schnittstelle GlobeProps aus components/ui/globe.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von SceneMotionProps an.

  • arcsoptional
    GlobeArc[]

    Dotted arcs between two places that light up one after another

  • pinsoptional
    GlobePoint[]

    Places with a soft pulse

  • dotsoptional
    number

    Density of the dot grid. Default 1500. Sea dots are thinned to a third.

  • seedoptional
    number

    Seed of the invented land

  • durationoptional
    number

    Seconds per turn at speed 1. Default 50.

  • rotateoptional
    number

    Start angle in degrees, also the still picture. Default -20.

  • tiltoptional
    number

    Tilt of the axis towards the viewer in degrees. Default 16.

  • sizeoptional
    number

    Largest size in px, it shrinks with its container. Default 520.

  • interactiveoptional
    boolean

    Leans a few degrees towards a mouse pointer. Default true.

  • labeloptional
    string

    Accessible description. Without it the globe is decorative.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle label.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 258 Zeilen
components/ui/globe.tsx
'use client';

import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion, round } from '@/lib/kit';
import { PlayToggle, SCENE_CSS, keyframes, rand, useSceneId, useScenePlay, type SceneMotionProps } from '@/components/ui/motion-scene';

/*
 * A dotted globe. Every dot is a point on a sphere, projected flat and moved
 * with transform only; dots on the far side fade out with opacity. The land
 * is invented from a few seeded blobs, no map data of anyone. Arcs are
 * dotted great circles lifted off the surface that light up one after
 * another. The server renders the globe at its start angle, so without
 * JavaScript and with reduced motion it stands still and complete.
 */

export interface GlobePoint {
  /** Latitude in degrees, north is positive */
  lat: number;
  /** Longitude in degrees, east is positive */
  lon: number;
}

export interface GlobeArc {
  from: GlobePoint;
  to: GlobePoint;
}

export interface GlobeProps extends SceneMotionProps {
  /** Dotted arcs between two places that light up one after another */
  arcs?: GlobeArc[];
  /** Places with a soft pulse */
  pins?: GlobePoint[];
  /** Density of the dot grid. Default 1500. Sea dots are thinned to a third. */
  dots?: number;
  /** Seed of the invented land */
  seed?: number;
  /** Seconds per turn at speed 1. Default 50. */
  duration?: number;
  /** Start angle in degrees, also the still picture. Default -20. */
  rotate?: number;
  /** Tilt of the axis towards the viewer in degrees. Default 16. */
  tilt?: number;
  /** Largest size in px, it shrinks with its container. Default 520. */
  size?: number;
  /** Leans a few degrees towards a mouse pointer. Default true. */
  interactive?: boolean;
  /** Accessible description. Without it the globe is decorative. */
  label?: string;
  className?: string;
}

const R = 40; // sphere radius in percent of the size
const DEG = Math.PI / 180;
type V3 = [number, number, number];

const vec = (lat: number, lon: number): V3 => [Math.cos(lat * DEG) * Math.sin(lon * DEG), Math.sin(lat * DEG), Math.cos(lat * DEG) * Math.cos(lon * DEG)];

/** Rotate around the vertical axis by a, then tilt around the horizontal axis by b. Returns screen x, y (y down) and depth z. */
function project(p: V3, a: number, b: number): V3 {
  const ca = Math.cos(a);
  const sa = Math.sin(a);
  const x1 = p[0] * ca + p[2] * sa;
  const z1 = -p[0] * sa + p[2] * ca;
  const cb = Math.cos(b);
  const sb = Math.sin(b);
  const y2 = p[1] * cb - z1 * sb;
  const z2 = p[1] * sb + z1 * cb;
  return [x1, -y2, z2];
}

function landBlobs(seed: number) {
  return Array.from({ length: 9 }, (_, i) => ({ c: vec(-50 + rand(seed, i * 3 + 1) * 112, -180 + rand(seed, i * 3 + 2) * 360), r: (15 + rand(seed, i * 3 + 3) * 22) * DEG }));
}

function slerp(a: V3, b: V3, t: number): V3 {
  const d = Math.min(1, Math.max(-1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2]));
  const o = Math.acos(d);
  if (o < 1e-4) return a;
  const s = Math.sin(o);
  const k1 = Math.sin((1 - t) * o) / s;
  const k2 = Math.sin(t * o) / s;
  return [a[0] * k1 + b[0] * k2, a[1] * k1 + b[1] * k2, a[2] * k1 + b[2] * k2];
}

interface Dot {
  p: V3;
  kind: 'land' | 'sea' | 'arc' | 'pin';
  arc?: number;
  step?: number;
}

const place = (q: V3) => ({
  transform: `translate(${round(q[0] * R, 2)}cqw, ${round(q[1] * R, 2)}cqw)`,
  opacity: q[2] <= -0.04 ? 0 : round(Math.min(1, 0.2 + q[2] * 1.15), 3),
});

/**
 * A slowly turning dotted globe with arcs and pins.
 *   <Globe arcs={[{ from: { lat: 52.5, lon: 13.4 }, to: { lat: 40.7, lon: -74 } }]} pins={[…]} />
 */
export function Globe({ arcs = [], pins = [], dots = 1500, seed = 3, duration = 50, rotate = -20, tilt = 16, size = 520, interactive = true, speed = 1, autoPlay = 'in-view', controls = true, label, className }: GlobeProps) {
  const sid = useSceneId('kvgl');
  const play = useScenePlay<HTMLDivElement>(autoPlay);
  const layer = useRef<HTMLDivElement>(null);

  const blobs = landBlobs(seed);
  const golden = Math.PI * (3 - Math.sqrt(5));
  const all: Dot[] = [];
  for (let i = 0; i < dots; i++) {
    const y = 1 - (2 * (i + 0.5)) / dots;
    const rr = Math.sqrt(1 - y * y);
    const phi = i * golden;
    const p: V3 = [rr * Math.sin(phi), y, rr * Math.cos(phi)];
    const near = Math.min(...blobs.map((b) => Math.acos(Math.min(1, p[0] * b.c[0] + p[1] * b.c[1] + p[2] * b.c[2])) / b.r)) + (rand(seed + 11, i) - 0.5) * 0.3;
    if (near < 1 && Math.abs(y) < 0.94) all.push({ p, kind: 'land' });
    else if (i % 3 === 0) all.push({ p, kind: 'sea' });
  }
  arcs.forEach((a, k) => {
    const A = vec(a.from.lat, a.from.lon);
    const B = vec(a.to.lat, a.to.lon);
    const n = 26;
    for (let s = 0; s <= n; s++) {
      const t = s / n;
      const q = slerp(A, B, t);
      const lift = 1 + 0.2 * Math.sin(Math.PI * t);
      all.push({ p: [q[0] * lift, q[1] * lift, q[2] * lift], kind: 'arc', arc: k, step: t });
    }
  });
  pins.forEach((pin) => all.push({ p: vec(pin.lat, pin.lon), kind: 'pin' }));

  const a0 = rotate * DEG;
  const b0 = tilt * DEG;

  useEffect(() => {
    const el = layer.current;
    const root = play.ref.current;
    if (!el || !root || prefersReducedMotion()) return;
    const nodes = Array.from(el.children) as HTMLElement[];
    let angle = a0;
    let tb = b0;
    let tx = 0;
    let target = { x: 0, y: 0 };
    let last = 0;
    let frame = 0;
    const tick = (now: number) => {
      frame = requestAnimationFrame(tick);
      if (root.dataset.kv !== 'on') {
        last = 0;
        return;
      }
      const dt = last ? Math.min(64, now - last) / 1000 : 0;
      last = now;
      angle += ((2 * Math.PI) / Math.max(4, duration)) * Math.max(0.05, speed) * dt;
      tx += (target.x * 0.35 - tx) * Math.min(1, dt * 4);
      tb += (b0 - target.y * 0.25 - tb) * Math.min(1, dt * 4);
      for (let i = 0; i < all.length && i < nodes.length; i++) {
        const s = place(project(all[i].p, angle + tx, tb));
        nodes[i].style.transform = s.transform;
        nodes[i].style.opacity = String(s.opacity);
      }
    };
    frame = requestAnimationFrame(tick);
    const move = (e: PointerEvent) => {
      if (!interactive || e.pointerType !== 'mouse') return;
      const r = root.getBoundingClientRect();
      target = { x: (e.clientX - r.left) / r.width - 0.5, y: (e.clientY - r.top) / r.height - 0.5 };
    };
    const leave = () => {
      target = { x: 0, y: 0 };
    };
    root.addEventListener('pointermove', move);
    root.addEventListener('pointerleave', leave);
    return () => {
      cancelAnimationFrame(frame);
      root.removeEventListener('pointermove', move);
      root.removeEventListener('pointerleave', leave);
    };
    // The dot list follows from these props
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dots, seed, duration, speed, rotate, tilt, interactive, arcs.length, pins.length]);

  const gap = 2.4 / Math.max(0.1, speed);
  const cycle = round(Math.max(1, arcs.length) * gap + 2 / Math.max(0.1, speed), 2);
  const share = ((2.6 / Math.max(0.1, speed)) / cycle) * 100;
  const css = `${SCENE_CSS}
${keyframes(`${sid}-arc`, [[0, 'opacity:.16;transform:scale(.6)'], [share * 0.06, 'opacity:.16;transform:scale(.6)'], [share * 0.18, 'opacity:1;transform:scale(1.3)'], [share * 0.55, 'opacity:1;transform:scale(1)'], [share, 'opacity:.16;transform:scale(.6)'], [100, 'opacity:.16;transform:scale(.6)']])}
${keyframes(`${sid}-pulse`, [[0, 'opacity:.75;transform:scale(1)'], [70, 'opacity:0;transform:scale(3.2)'], [100, 'opacity:0;transform:scale(3.2)']])}
.${sid}:not([data-kv="off"]) .${sid}-arc { animation: ${sid}-arc ${cycle}s linear var(--d) infinite both; }
.${sid}:not([data-kv="off"]) .${sid}-pulse { animation: ${sid}-pulse 2.8s ease-out var(--d) infinite both; }
.${sid} .${sid}-d { position:absolute; left:50%; top:50%; }
.${sid} .${sid}-d > i { position:absolute; border-radius:999px; }
`;

  let pinIndex = 0;
  return (
    <div className={cn('relative mx-auto w-full', className)} style={{ maxWidth: size }}>
      <div
        ref={play.ref}
        data-kv={play.state}
        role={label ? 'img' : undefined}
        aria-label={label}
        aria-hidden={label ? undefined : true}
        className={cn(sid, 'relative aspect-square w-full [container-type:inline-size]')}
      >
        <style dangerouslySetInnerHTML={{ __html: css }} />
        <div className="absolute rounded-full" style={{ inset: `${round(50 - R * 1.24, 2)}%`, background: 'radial-gradient(closest-side, color-mix(in oklab, var(--primary) 20%, transparent) 70%, color-mix(in oklab, var(--primary) 6%, transparent) 85%, transparent)' }} />
        <div
          className="absolute rounded-full"
          style={{
            inset: `${50 - R}%`,
            background: 'radial-gradient(circle at 34% 28%, color-mix(in oklab, var(--primary) 14%, var(--surface-2)), color-mix(in oklab, var(--primary) 5%, var(--background)) 64%, var(--background) 100%)',
            boxShadow: 'inset 0 0 0 1px color-mix(in oklab, var(--foreground) 9%, transparent)',
          }}
        />
        <div ref={layer} className="absolute inset-0">
          {all.map((d, i) => {
            const s = place(project(d.p, a0, b0));
            if (d.kind === 'pin') {
              const k = pinIndex++;
              return (
                <span key={i} className={`${sid}-d`} style={s}>
                  <i className={`${sid}-pulse`} style={{ width: '3.6cqw', height: '3.6cqw', left: '-1.8cqw', top: '-1.8cqw', border: '0.32cqw solid var(--primary)', opacity: 0, '--d': `${round(k * 0.5, 2)}s` } as React.CSSProperties} />
                  <i style={{ width: '1.9cqw', height: '1.9cqw', left: '-0.95cqw', top: '-0.95cqw', background: 'var(--primary)', boxShadow: '0 0 0 0.5cqw color-mix(in oklab, var(--primary) 28%, transparent), 0 0 3cqw color-mix(in oklab, var(--primary) 70%, transparent)' }} />
                </span>
              );
            }
            const w = d.kind === 'land' ? 0.82 : d.kind === 'arc' ? 0.95 : 0.42;
            const bg = d.kind === 'land' ? 'color-mix(in oklab, var(--primary) 50%, var(--foreground))' : d.kind === 'arc' ? 'var(--primary)' : 'color-mix(in oklab, var(--foreground) 24%, transparent)';
            return (
              <span key={i} className={`${sid}-d`} style={s}>
                <i
                  className={d.kind === 'arc' ? `${sid}-arc` : undefined}
                  style={{
                    width: `${w}cqw`,
                    height: `${w}cqw`,
                    left: `${round(-w / 2, 3)}cqw`,
                    top: `${round(-w / 2, 3)}cqw`,
                    background: bg,
                    ...(d.kind === 'arc' ? ({ boxShadow: '0 0 1.2cqw color-mix(in oklab, var(--primary) 75%, transparent)', '--d': `${round((d.arc ?? 0) * gap + (d.step ?? 0) * 0.9, 2)}s` } as React.CSSProperties) : null),
                  }}
                />
              </span>
            );
          })}
        </div>
        <div
          className="pointer-events-none absolute rounded-full"
          style={{ inset: `${round(50 - R - 0.3, 2)}%`, background: 'radial-gradient(circle at 30% 24%, color-mix(in oklab, #fff 8%, transparent), transparent 44%), radial-gradient(circle at 50% 50%, transparent 64%, color-mix(in oklab, var(--background) 50%, transparent) 100%)' }}
        />
      </div>
      {controls && (
        <div className="absolute bottom-0 right-0">
          <PlayToggle inline state={play.state} reduced={play.reduced} onToggle={play.toggle} />
        </div>
      )}
    </div>
  );
}

Häufige Fragen

Wie installiere ich Globus?

Mit aiw add @ai-website/globe. Der Befehl legt die Datei components/ui/globe.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind motion-scene. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Globus?

3 Varianten, Bögen, Pins und Ruhiger Globus.

Kann ein KI-Assistent Globus einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Globus mit aiw_components und installieren sie mit aiw_add @ai-website/globe.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert