Zum Inhalt springen
UI Kit
Accent

Leuchtende Punkte

Ein Punktfeld, dessen Punkte in der Akzentfarbe aufleuchten, am Zeiger oder in einer wandernden Welle.

Eine von 6 Components in Backgrounds

3 Variants

Variants

Folgt dem Zeiger

Punkte am Zeiger leuchten. Ohne Zeiger wandert das Licht langsam von selbst.

Welle

Ein schräges Lichtband zieht über das Feld und läuft beim Scrollen weiter.

Kreise

Ruhige Ringe breiten sich aus der Mitte aus, wie ein Atemzug.

Details

Was Leuchtende Punkte mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Backgrounds
Bereich
Elements
Variants
3
Version
1.0.0 · seit 1.0.0
Datei
components/ui/dot-glow.tsx
Bringt mit
utilskit
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Props

Die Schnittstelle DotGlowProps aus components/ui/dot-glow.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • modeoptional
    DotGlowMode

    pointerwaveripple

    pointer: dots near the pointer light up (on touch screens the light wanders on its own). wave: a diagonal band of light sweeps across the field. ripple: rings of light spread from the middle.

  • gapoptional
    number

    Distance between dots in px, default 24

  • radiusoptional
    number

    Reach of the glow in px, default 170

  • speedoptional
    number

    1 is calm, 2 is twice as fast

  • clearCenteroptional
    boolean

    Dims the glow behind the middle so a headline stays readable, default true

  • fadeoptional
    'edges' | 'none'

    How far the field fades out toward the edges

  • scrollLinkoptional
    boolean

    The light also moves with the scroll: the wave travels on, the rings spread, the wandering light keeps going. Default false.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • 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, 200 Zeilen
components/ui/dot-glow.tsx
'use client';

import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';

export type DotGlowMode = 'pointer' | 'wave' | 'ripple';

export interface DotGlowProps {
  /**
   * pointer: dots near the pointer light up (on touch screens the light wanders on its own).
   * wave: a diagonal band of light sweeps across the field.
   * ripple: rings of light spread from the middle.
   */
  mode?: DotGlowMode;
  /** Distance between dots in px, default 24 */
  gap?: number;
  /** Reach of the glow in px, default 170 */
  radius?: number;
  /** 1 is calm, 2 is twice as fast */
  speed?: number;
  /** Dims the glow behind the middle so a headline stays readable, default true */
  clearCenter?: boolean;
  /** How far the field fades out toward the edges */
  fade?: 'edges' | 'none';
  /** The light also moves with the scroll: the wave travels on, the rings spread, the wandering light keeps going. Default false. */
  scrollLink?: boolean;
  className?: string;
}

interface Colors { dot: string; glow: string; dark: boolean }

/** Reads the current accent as a color string the canvas understands */
function readColors(el: HTMLElement): Colors {
  const probe = document.createElement('span');
  probe.style.cssText = 'position:absolute;visibility:hidden;color:var(--primary)';
  el.appendChild(probe);
  const glow = getComputedStyle(probe).color;
  probe.style.color = 'color-mix(in oklab, var(--primary) 75%, var(--foreground))';
  const dot = getComputedStyle(probe).color;
  probe.remove();
  return { dot, glow, dark: !!el.closest('.dark') };
}

/**
 * A dot pattern whose dots glow in the accent color, near the pointer or in
 * a moving wave. The plain dots are CSS and render on the server, a canvas
 * on top draws only the glowing ones. DPR aware, sized by ResizeObserver,
 * paused out of view and in hidden tabs, a still frame with reduced motion.
 * Fills its parent and listens to the pointer on the parent, so it never
 * blocks clicks.
 */
export function DotGlow({ mode = 'pointer', gap = 24, radius = 170, speed = 1, clearCenter = true, fade = 'edges', scrollLink = false, className }: DotGlowProps) {
  const wrap = useRef<HTMLDivElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const host = wrap.current;
    const cv = canvas.current;
    const ctx = cv?.getContext('2d');
    if (!host || !cv || !ctx) return;
    const reduced = prefersReducedMotion();
    const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
    let colors = readColors(host);
    let w = 0, h = 0, dpr = 1;
    let frame = 0;
    let visible = true;
    // Start mid-motion, so the first frame already shows the effect
    const t0 = performance.now() - 2600 / speed;
    // Pointer state: target and eased position, null while no pointer is over the parent
    let target: { x: number; y: number } | null = null;
    const pos = { x: 0, y: 0 };
    // Scroll adds up to five seconds of motion over a full pass
    let pass = 0;
    const onScroll = () => {
      const r = host.getBoundingClientRect();
      pass = Math.min(1, Math.max(0, (window.innerHeight - r.top) / (r.height + window.innerHeight)));
    };
    if (scrollLink && !reduced) {
      onScroll();
      window.addEventListener('scroll', onScroll, { passive: true });
    }

    const draw = (time: number) => {
      const t = (time / 1000) * speed + (reduced ? 2.6 : 0) + pass * 5;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      const narrow = w < 640;
      let px = w / 2, py = h * 0.45;
      if (mode === 'pointer') {
        const auto = { x: w / 2 + Math.sin(t * 0.45) * w * 0.3, y: h * 0.45 + Math.sin(t * 0.31 + 1) * h * 0.22 };
        const goal = target ?? (reduced ? { x: w / 2, y: h * 0.42 } : auto);
        pos.x += (goal.x - pos.x) * (target ? 0.16 : 0.05);
        pos.y += (goal.y - pos.y) * (target ? 0.16 : 0.05);
        px = pos.x;
        py = pos.y;
        // a soft halo under the dots
        const halo = ctx.createRadialGradient(px, py, 0, px, py, radius * 1.3);
        halo.addColorStop(0, colors.glow);
        halo.addColorStop(1, 'transparent');
        ctx.globalAlpha = colors.dark ? 0.1 : 0;
        ctx.fillStyle = halo;
        ctx.fillRect(px - radius * 1.3, py - radius * 1.3, radius * 2.6, radius * 2.6);
      }
      const ringR = ((t * 160) % (Math.hypot(w, h) * 0.6 + radius)) - radius / 2;
      const band = ((t * 170 + (w + h) * 0.3) % (w * 1.8 + radius * 4)) - radius * 2;
      ctx.fillStyle = colors.dot;
      for (let y = gap / 2; y < h; y += gap) {
        for (let x = gap / 2; x < w; x += gap) {
          let v = 0;
          if (mode === 'pointer') {
            const d = Math.hypot(x - px, y - py);
            v = d < radius ? (1 - d / radius) ** 2 : 0;
          } else if (mode === 'wave') {
            const u = (x + y * 0.8) - band;
            v = Math.exp(-((u / (radius * 0.7)) ** 2)) * (0.6 + 0.4 * Math.sin(x * 0.015 + t));
          } else {
            const d = Math.hypot(x - w / 2, y - h / 2);
            const a = Math.exp(-(((d - ringR) / (radius * 0.28)) ** 2));
            const b = Math.exp(-(((d - ringR + radius * 1.6) / (radius * 0.28)) ** 2)) * 0.55;
            v = Math.max(a, b) * Math.max(0, 1 - d / (Math.hypot(w, h) * 0.55));
          }
          // On phones the copy fills most of the width, so the quiet middle grows with it
          if (clearCenter) v *= 1 - (narrow ? 0.88 : 0.7) * Math.exp(-(((x - w / 2) / (w * (narrow ? 0.46 : 0.24))) ** 2 + ((y - h / 2) / (h * (narrow ? 0.3 : 0.26))) ** 2));
          if (v < 0.04) continue;
          ctx.globalAlpha = Math.min(1, v * 1.1);
          ctx.beginPath();
          ctx.arc(x, y, 1 + v * 1.25, 0, Math.PI * 2);
          ctx.fill();
        }
      }
      ctx.globalAlpha = 1;
    };

    const loop = (time: number) => {
      draw(time - t0);
      frame = requestAnimationFrame(loop);
    };
    const start = () => {
      if (reduced) { draw(0); return; }
      if (!frame && visible && !document.hidden) frame = requestAnimationFrame(loop);
    };
    const stop = () => { cancelAnimationFrame(frame); frame = 0; };

    const resize = () => {
      const r = host.getBoundingClientRect();
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      w = Math.max(1, Math.round(r.width));
      h = Math.max(1, Math.round(r.height));
      cv.width = Math.round(w * dpr);
      cv.height = Math.round(h * dpr);
      if (!pos.x) { pos.x = w / 2; pos.y = h * 0.42; }
      if (reduced || !frame) draw(reduced ? 0 : performance.now() - t0);
    };
    const ro = new ResizeObserver(resize);
    ro.observe(host);
    resize();

    const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) start(); else stop(); });
    io.observe(host);
    const onVis = () => (document.hidden ? stop() : start());
    document.addEventListener('visibilitychange', onVis);
    // Theme or accent switches change the colors
    const mo = new MutationObserver(() => { colors = readColors(host); if (reduced) start(); });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-accent', 'style'] });

    const parent = host.parentElement;
    const move = (e: PointerEvent) => {
      if (e.pointerType === 'touch' || !finePointer) return;
      const r = host.getBoundingClientRect();
      target = { x: e.clientX - r.left, y: e.clientY - r.top };
    };
    const leave = () => { target = null; };
    if (mode === 'pointer' && parent && !reduced) {
      parent.addEventListener('pointermove', move);
      parent.addEventListener('pointerleave', leave);
    }
    start();
    return () => {
      stop();
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener('visibilitychange', onVis);
      window.removeEventListener('scroll', onScroll);
      parent?.removeEventListener('pointermove', move);
      parent?.removeEventListener('pointerleave', leave);
    };
  }, [mode, gap, radius, speed, clearCenter, scrollLink]);

  const mask = fade === 'edges' ? 'radial-gradient(ellipse 78% 74% at 50% 46%, #000 35%, transparent 82%)' : undefined;
  return (
    <div ref={wrap} aria-hidden className={cn('pointer-events-none absolute inset-0 overflow-hidden', className)} style={mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined}>
      <div
        className="absolute inset-0 [--kitfx-dot:color-mix(in_oklch,var(--foreground)_15%,transparent)] dark:[--kitfx-dot:color-mix(in_oklch,var(--foreground)_17%,transparent)]"
        style={{ backgroundImage: 'radial-gradient(circle, var(--kitfx-dot) 1px, transparent 1.5px)', backgroundSize: `${gap}px ${gap}px` }}
      />
      <canvas ref={canvas} className="absolute inset-0 size-full" />
    </div>
  );
}

Häufige Fragen

Wie installiere ich Leuchtende Punkte?

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

Welche Varianten hat Leuchtende Punkte?

3 Varianten, Folgt dem Zeiger, Welle und Kreise.

Kann ein KI-Assistent Leuchtende Punkte einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert