← каталог
Animated Beam
Луч между двумя DOM-узлами: путь меряется по раскладке, по нему бежит световой импульс. Для диаграмм интеграций «источники → хаб → приёмник».
cssautointermediate~1 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| containerRef | RefObject<HTMLElement | null> | — | Контейнер диаграммы: position: relative и общий offset-предок узлов. |
| fromRef | RefObject<HTMLElement | null> | — | Узел-источник. |
| toRef | RefObject<HTMLElement | null> | — | Узел-приёмник. |
| curvature? | number | 40 | Изгиб дуги, px (плюс — вверх, минус — вниз, 0 — прямая). |
| reverse? | boolean | false | Пустить импульс в обратную сторону. |
| duration? | number | 4 | Секунд на пролёт импульса. |
| delay? | number | 0 | Задержка старта, с. |
| hue? | number | 300 | Тон импульса, градусы oklch. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/animated-beam.jsonЧто нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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