Zum Inhalt springen
UI Kit
Accent

Lichtstrahlen

Licht, das über Rasterlinien wandert, als Strahl fällt oder Kurven entlangläuft.

Eine von 6 Components in Beams, Partikel und Bewegung

3 Variants

Variants

Raster

Impulse laufen in beide Richtungen über ein feines Raster und werden kräftiger, während die Bühne hochscrollt.

Strahlen

Nur senkrechte Strahlen ohne gezeichnetes Raster, sie werden mit dem Scrollen kräftiger.

Wellen

Ein Fächer aus Kurven, der sich hinter der Überschrift trifft, Licht läuft von links nach rechts.

Details

Was Lichtstrahlen mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle BeamsProps aus components/ui/beams.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    BeamsVariant

    gridrayswaves

    grid: pulses travel along the lines of a fine grid, both directions. rays: vertical beams fall along hidden lines, no grid drawn. waves: light runs along a fan of soft curves from left to right.

  • celloptional
    number

    Grid cell in px (grid and rays), default 56

  • countoptional
    number

    Number of beams, default 9

  • speedoptional
    number

    Average seconds for one beam to cross, default 7

  • clearCenteroptional
    boolean

    Dims the beams behind the middle so a headline stays calm, default true

  • seedoptional
    number

    Same seed, same picture

  • scrollRevealoptional
    boolean

    The beams gather from a quiet third to full strength while the stage scrolls up to the middle of the window. 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, 237 Zeilen
components/ui/beams.tsx
'use client';

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

export type BeamsVariant = 'grid' | 'rays' | 'waves';

export interface BeamsProps {
  /**
   * grid: pulses travel along the lines of a fine grid, both directions.
   * rays: vertical beams fall along hidden lines, no grid drawn.
   * waves: light runs along a fan of soft curves from left to right.
   */
  variant?: BeamsVariant;
  /** Grid cell in px (grid and rays), default 56 */
  cell?: number;
  /** Number of beams, default 9 */
  count?: number;
  /** Average seconds for one beam to cross, default 7 */
  speed?: number;
  /** Dims the beams behind the middle so a headline stays calm, default true */
  clearCenter?: boolean;
  /** Same seed, same picture */
  seed?: number;
  /** The beams gather from a quiet third to full strength while the stage scrolls up to the middle of the window. Default false. */
  scrollReveal?: boolean;
  className?: string;
}

const CSS = `
@keyframes kitfx-beams-y { from { transform: translate3d(0, -100%, 0); } to { transform: translate3d(0, var(--kitfx-to), 0); } }
@keyframes kitfx-beams-x { from { transform: translate3d(-100%, 0, 0); } to { transform: translate3d(var(--kitfx-to), 0, 0); } }
@keyframes kitfx-beams-dash { from { stroke-dashoffset: 16; } to { stroke-dashoffset: -100; } }
.kitfx-beams * { animation-play-state: var(--kitfx-play, running); }
.kitfx-beams[data-scroll] { opacity: calc(0.3 + 0.7 * min(1, var(--kitfx-p, 0.5) * 2)); transition: opacity .3s linear; }
@media (prefers-reduced-motion: reduce) {
  .kitfx-beams[data-scroll] { opacity: 1; }
  .kitfx-beams * { animation: none !important; }
  .kitfx-beams [data-still-y] { transform: translate3d(0, var(--kitfx-still), 0); }
  .kitfx-beams [data-still-x] { transform: translate3d(var(--kitfx-still), 0, 0); }
  .kitfx-beams [data-still-dash] { stroke-dashoffset: var(--kitfx-still); }
}
`;

/** Small seeded generator, the same numbers on server and client */
function seeded(seed: number) {
  let a = seed >>> 0 || 1;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const LINE = 'color-mix(in oklch, var(--foreground) 7%, transparent)';
const HEAD = 'color-mix(in oklab, var(--primary) 90%, var(--foreground))';

/**
 * Scroll pass. While `on`, writes --kitfx-p on the element: 0 when its top
 * meets the bottom of the window, 1 when its bottom leaves at the top. Never
 * with reduced motion, the CSS then falls back to the resting value.
 */
function useScrollPass(ref: { current: HTMLElement | null }, on: boolean) {
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const p = Math.min(1, Math.max(0, (window.innerHeight - r.top) / (r.height + window.innerHeight)));
      el.style.setProperty('--kitfx-p', p.toFixed(4));
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      el.style.removeProperty('--kitfx-p');
    };
  }, [ref, on]);
}

/**
 * Beams of light that travel along a line grid, fall as rays or run along
 * curves. Fills its parent (parent `relative isolate overflow-hidden`,
 * content after it with `relative`). Decorative, pauses out of view, a
 * still picture with reduced motion.
 */
export function Beams({ variant = 'grid', cell = 56, count = 9, speed = 7, seed = 7, clearCenter = true, scrollReveal = false, className }: BeamsProps) {
  const ref = useRef<HTMLDivElement>(null);
  useScrollPass(ref, scrollReveal);
  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(([e]) => el.style.setProperty('--kitfx-play', e.isIntersecting ? 'running' : 'paused'));
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const rnd = seeded(seed);
  const edge =
    variant === 'waves'
      ? 'linear-gradient(to right, transparent, #000 18%, #000 82%, transparent)'
      : 'radial-gradient(ellipse 75% 70% at 50% 45%, #000 30%, transparent 80%)';
  const fadeMask = clearCenter ? `${edge}, radial-gradient(ellipse var(--kitfx-clear, 36% 34%) at 50% 50%, rgb(0 0 0 / 0.1) 35%, #000 100%)` : edge;

  return (
    <div ref={ref} aria-hidden data-clip-ok data-scroll={scrollReveal ? '' : undefined} className={cn('kitfx-beams pointer-events-none absolute inset-0 overflow-hidden max-sm:[--kitfx-clear:62%_40%]', className)} style={{ maskImage: fadeMask, WebkitMaskImage: fadeMask, maskComposite: 'intersect', WebkitMaskComposite: 'source-in' }}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      {variant === 'waves' ? <Waves count={count} speed={speed} rnd={rnd} /> : <GridBeams variant={variant} cell={cell} count={count} speed={speed} rnd={rnd} />}
    </div>
  );
}

function GridBeams({ variant, cell, count, speed, rnd }: { variant: BeamsVariant; cell: number; count: number; speed: number; rnd: () => number }) {
  const half = round(cell / 2);
  // Lines run through the center, so beams on k * cell from the middle sit exactly on them
  const used = new Set<string>();
  const beams = Array.from({ length: count }, (_, i) => {
    const vertical = variant === 'rays' || i % 3 !== 2;
    let k = 0;
    for (let n = 0; n < 12; n++) {
      k = Math.round((rnd() - 0.5) * (vertical ? 18 : 8));
      if (!used.has(`${vertical}${k}`)) break;
    }
    used.add(`${vertical}${k}`);
    const len = round(variant === 'rays' ? 18 + rnd() * 16 : 12 + rnd() * 10);
    const dur = round(speed * (0.75 + rnd() * 0.6), 1);
    const delay = round(-rnd() * dur * 2, 1);
    const reverse = variant === 'grid' && rnd() > 0.6;
    return { vertical, k, len, dur, delay, reverse, still: round(20 + rnd() * 60) };
  });

  return (
    <>
      {variant === 'grid' && (
        <div
          className="absolute inset-0"
          style={{
            backgroundImage: `linear-gradient(to right, ${LINE} 1px, transparent 1px), linear-gradient(to bottom, ${LINE} 1px, transparent 1px)`,
            backgroundSize: `${cell}px ${cell}px`,
            backgroundPosition: `calc(50% + ${half}px) calc(50% + ${half}px)`,
          }}
        />
      )}
      {beams.map((b, i) => {
        const to = `${round((100 / b.len) * 100)}%`;
        const pos = `calc(50% + ${b.k * cell - 2}px)`;
        const anim = `${b.vertical ? 'kitfx-beams-y' : 'kitfx-beams-x'} ${b.dur}s linear ${b.delay}s infinite${b.reverse ? ' reverse' : ''}`;
        const still = `${round(((b.still / 100) * 100) / b.len * 100 - 100)}%`;
        const dir = b.vertical ? (b.reverse ? 'to top' : 'to bottom') : b.reverse ? 'to left' : 'to right';
        return (
          <div key={i} className={cn('absolute overflow-hidden', b.vertical ? 'inset-y-0 w-[5px]' : 'inset-x-0 h-[5px]')} style={b.vertical ? { left: pos } : { top: pos }}>
            <div
              {...(b.vertical ? { 'data-still-y': '' } : { 'data-still-x': '' })}
              className={cn('absolute', b.vertical ? 'inset-x-0 top-0' : 'inset-y-0 left-0')}
              style={{
                [b.vertical ? 'height' : 'width']: `${b.len}%`,
                ['--kitfx-to' as string]: to,
                ['--kitfx-still' as string]: still,
                transform: `translate3d(${b.vertical ? `0, ${still}` : `${still}, 0`}, 0)`,
                animation: anim,
              }}
            >
              {/* soft halo and the hairline core, brightest at the head */}
              <span
                className={cn('absolute opacity-60 blur-[1.5px] dark:opacity-90', b.vertical ? 'inset-y-0 left-px w-[3px]' : 'inset-x-0 top-px h-[3px]')}
                style={{ background: `linear-gradient(${dir}, transparent 10%, color-mix(in oklch, ${HEAD} 45%, transparent) 70%, ${HEAD})` }}
              />
              <span
                className={cn('absolute', b.vertical ? 'inset-y-0 left-[2px] w-px' : 'inset-x-0 top-[2px] h-px')}
                style={{ background: `linear-gradient(${dir}, transparent, color-mix(in oklch, ${HEAD} 60%, transparent) 55%, ${HEAD} 94%, var(--foreground))` }}
              />
            </div>
          </div>
        );
      })}
    </>
  );
}

function Waves({ count, speed, rnd }: { count: number; speed: number; rnd: () => number }) {
  const lines = Math.max(5, Math.min(count, 12));
  const W = 1200;
  const H = 600;
  const paths = Array.from({ length: lines }, (_, i) => {
    const t = i / (lines - 1);
    // A fan: the lines meet in the middle band and open toward both edges
    const spread = (t - 0.5) * 2;
    const y0 = round(H / 2 + spread * 250);
    const y1 = round(H / 2 + spread * 46);
    const y2 = round(H / 2 + spread * 230);
    const d = `M -20 ${y0} C ${W * 0.22} ${y0}, ${W * 0.3} ${y1}, ${W / 2} ${y1} S ${W * 0.78} ${y2}, ${W + 20} ${y2}`;
    const dur = round(speed * (0.8 + rnd() * 0.5), 1);
    return { d, dur, delay: round(-rnd() * dur, 1), still: round(-(10 + rnd() * 70)) };
  });
  return (
    <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid slice" className="absolute inset-0 h-full w-full" fill="none">
      {paths.map((p, i) => (
        <path key={`l${i}`} d={p.d} stroke="var(--foreground)" className="opacity-[0.13] dark:opacity-[0.1]" strokeWidth={1} vectorEffect="non-scaling-stroke" />
      ))}
      {paths.map((p, i) => (
        <g key={`b${i}`}>
          <path
            data-still-dash=""
            d={p.d}
            pathLength={100}
            stroke={HEAD}
            strokeWidth={4}
            strokeLinecap="round"
            strokeDasharray="9 300"
            className="opacity-40 blur-[3px] dark:opacity-60"
            style={{ ['--kitfx-still' as string]: p.still, strokeDashoffset: p.still, animation: `kitfx-beams-dash ${p.dur}s linear ${p.delay}s infinite` }}
          />
          <path
            data-still-dash=""
            d={p.d}
            pathLength={100}
            stroke={HEAD}
            strokeWidth={1.5}
            strokeLinecap="round"
            strokeDasharray="9 300"
            vectorEffect="non-scaling-stroke"
            style={{ ['--kitfx-still' as string]: p.still, strokeDashoffset: p.still, animation: `kitfx-beams-dash ${p.dur}s linear ${p.delay}s infinite` }}
          />
        </g>
      ))}
    </svg>
  );
}

Häufige Fragen

Wie installiere ich Lichtstrahlen?

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

Welche Varianten hat Lichtstrahlen?

3 Varianten, Raster, Strahlen und Wellen. Du wählst sie mit der Prop variant, zum Beispiel variant="rays".

Kann ein KI-Assistent Lichtstrahlen einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Lichtstrahlen mit aiw_components und installieren sie mit aiw_add @ai-website/beams. Nenn im Prompt die Variante, etwa variant="grid", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert