Skip to content
UI Kit
Accent color

Beams

Light that travels along grid lines, falls as rays or runs along curves.

One of 6 components in Beams, particles and motion

3 variants

Variants

Grid

Pulses run along a fine grid in both directions and gather strength as the stage scrolls up.

Rays

Only vertical beams without a drawn grid, they gather strength with the scroll.

Waves

A fan of curves that meet behind the headline, light runs from left to right.

Details

What Beams brings along, read from the registry and the source.

Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/beams.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The BeamsProps interface from components/ui/beams.tsx, with the comments from the code.

  • 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.

Accessibility

Read from the source of the component.

  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 237 lines
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>
  );
}

Frequent questions

How do I install Beams?

With aiw add @ai-website/beams. The command puts components/ui/beams.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Beams have?

3 variants, Grid, Rays and Waves. You pick one with the variant prop, for example variant="rays".

Can an AI assistant build with Beams?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Beams with aiw_components and install it with aiw_add @ai-website/beams. Name the variant in your prompt, for example variant="grid", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard