Motiva
Мои коллекции
← каталог

Animated Beam

Луч между двумя DOM-узлами: путь меряется по раскладке, по нему бежит световой импульс. Для диаграмм интеграций «источники → хаб → приёмник».

cssautointermediate~1 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
containerRefRefObject<HTMLElement | null>Контейнер диаграммы: position: relative и общий offset-предок узлов.
fromRefRefObject<HTMLElement | null>Узел-источник.
toRefRefObject<HTMLElement | null>Узел-приёмник.
curvature?number40Изгиб дуги, px (плюс — вверх, минус — вниз, 0 — прямая).
reverse?booleanfalseПустить импульс в обратную сторону.
duration?number4Секунд на пролёт импульса.
delay?number0Задержка старта, с.
hue?number300Тон импульса, градусы oklch.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/animated-beam.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef } from "react";
import type { CSSProperties, RefObject } from "react";

export interface AnimatedBeamProps {
  /** Контейнер диаграммы: position: relative и общий offset-предок узлов. */
  containerRef: RefObject<HTMLElement | null>;
  /** Узел-источник. */
  fromRef: RefObject<HTMLElement | null>;
  /** Узел-приёмник. */
  toRef: RefObject<HTMLElement | null>;
  /** Изгиб дуги, px (плюс — вверх, минус — вниз, 0 — прямая). */
  curvature?: number;
  /** Пустить импульс в обратную сторону. */
  reverse?: boolean;
  /** Секунд на пролёт импульса. */
  duration?: number;
  /** Задержка старта, с. */
  delay?: number;
  /** Тон импульса, градусы oklch. */
  hue?: number;
}

/**
 * Центр элемента в координатах контейнера — суммой offset-цепочки, а НЕ через
 * getBoundingClientRect: рамка на экране учитывает транформы (узел, который
 * анимируется, дал бы дрожащие концы луча), раскладка — нет.
 */
function centerIn(el: HTMLElement, root: HTMLElement) {
  let x = el.offsetWidth / 2;
  let y = el.offsetHeight / 2;
  let n: HTMLElement | null = el;
  while (n && n !== root) {
    x += n.offsetLeft;
    y += n.offsetTop;
    n = n.offsetParent as HTMLElement | null;
  }
  return { x, y };
}

/**
 * Луч между двумя реальными DOM-узлами: путь меряется по раскладке и перестраивается
 * ResizeObserver'ом, по нему бежит световой импульс. Импульс — это dash-паттерн:
 * штрих короче пути, анимируется только stroke-dashoffset (одно свойство, композитор).
 * Reduced-motion / data-shot — импульс скрыт, статичная линия связи остаётся.
 */
export function AnimatedBeam({
  containerRef,
  fromRef,
  toRef,
  curvature = 40,
  reverse = false,
  duration = 4,
  delay = 0,
  hue = 300,
}: AnimatedBeamProps) {
  const svgRef = useRef<SVGSVGElement>(null);
  const baseRef = useRef<SVGPathElement>(null);
  const pulseRef = useRef<SVGPathElement>(null);
  const glowRef = useRef<SVGPathElement>(null);

  useEffect(() => {
    const root = containerRef.current;
    const svg = svgRef.current;
    if (!root || !svg) return;

    const measure = () => {
      const from = fromRef.current;
      const to = toRef.current;
      const base = baseRef.current;
      const pulse = pulseRef.current;
      const glow = glowRef.current;
      if (!from || !to || !base || !pulse || !glow) return;
      const a = centerIn(reverse ? to : from, root);
      const b = centerIn(reverse ? from : to, root);
      const mx = (a.x + b.x) / 2;
      const my = (a.y + b.y) / 2 - curvature;
      const d = `M ${a.x} ${a.y} Q ${mx} ${my} ${b.x} ${b.y}`;
      base.setAttribute("d", d);
      pulse.setAttribute("d", d);
      glow.setAttribute("d", d);
      const len = base.getTotalLength();
      const p = Math.min(len * 0.4, 80);
      for (const el of [pulse, glow]) {
        el.style.setProperty("--ab-p", `${p}`);
        el.style.setProperty("--ab-l", `${len}`);
        el.style.strokeDasharray = `${p} ${len}`;
      }
    };

    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(root);
    if (fromRef.current) ro.observe(fromRef.current);
    if (toRef.current) ro.observe(toRef.current);
    return () => ro.disconnect();
  }, [containerRef, fromRef, toRef, curvature, reverse]);

  const bright = `oklch(0.78 0.16 ${hue})`;
  const dim = `oklch(0.78 0.16 ${hue} / 0.28)`;
  return (
    <svg
      ref={svgRef}
      aria-hidden="true"
      style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none", overflow: "visible" }}
    >
      <style>{`
        .ab-pulse {
          animation: ab-run var(--ab-dur) linear infinite;
          animation-delay: var(--ab-delay);
          /* старт за кромкой пути: до первого кадра анимации импульса не видно */
          stroke-dashoffset: calc(var(--ab-p, 0) * 1px);
        }
        @keyframes ab-run {
          from { stroke-dashoffset: calc(var(--ab-p) * 1px); }
          to   { stroke-dashoffset: calc(var(--ab-l) * -1px); }
        }
        @media (prefers-reduced-motion: reduce) { .ab-pulse { display: none; } }
      `}</style>
      <path ref={baseRef} fill="none" stroke="oklch(0.55 0.02 270 / 0.4)" strokeWidth="1.5" />
      <path
        ref={glowRef}
        className="ab-pulse"
        fill="none"
        stroke={dim}
        strokeWidth="5"
        strokeLinecap="round"
        style={{ "--ab-dur": `${duration}s`, "--ab-delay": `${delay}s` } as CSSProperties}
      />
      <path
        ref={pulseRef}
        className="ab-pulse"
        fill="none"
        stroke={bright}
        strokeWidth="2"
        strokeLinecap="round"
        style={{ "--ab-dur": `${duration}s`, "--ab-delay": `${delay}s` } as CSSProperties}
      />
    </svg>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Magic UI Animated Beam (MIT)v1.0.0