Каталог / Кольцо с бегущей дугой и набор текста
Кольцо с бегущей дугой и набор текста
Заголовок набирается по буквам, затем прорисовывается светящееся кольцо, по которому бежит дуга, а внутри появляется подпись.
Демо
Как устроено
Заголовок режется на слова и буквы (слова не ломаются), буквы проявляются по таймеру, последние две подсвечены акцентным цветом. Кольцо это SVG с pathLength=100: трек прорисовывается через stroke-dashoffset, дуга это отдельный круг со stroke-dasharray, который крутится Web Animations API. Свечение задаётся CSS-переменной интенсивности и drop-shadow. Стили кладутся через constructed stylesheet, цвета и размеры через CSSOM, поэтому inline style и style-теги не нужны и строгий CSP не мешает. Без DOM mount ничего не делает. При prefers-reduced-motion (или data-motion=off на html) сразу показывается финальное состояние без вращения.
Параметры
| Название | Ключ | Тип | По умолчанию | Значения |
|---|---|---|---|---|
| Заголовок | title | string | "Non-Custodial Composable Option Protocol" | до 120 символов |
| Подпись в кольце | label | string | "Cross-Chain Gateway Protocol" | до 48 символов |
| Скорость набора | typeSpeed | number | 45 | 10…200 мс/буква |
| Задержка старта | startDelay | number | 200 | 0…3000 мс |
| Появление кольца | ringDelay | number | 900 | 0…5000 мс |
| Прорисовка кольца | ringDraw | number | 900 | 100…4000 мс |
| Оборот дуги | arcDuration | number | 2400 | 600…10000 мс |
| Длина дуги | arcLength | number | 22 | 5…60 % |
| Размер кольца | size | number | 220 | 120…360 px |
| Интенсивность свечения | intensity | number | 0.7 | 0…1 |
| Цвет кольца | ringColor | color | "var(--zg-text-1)" | |
| Цвет дуги | arcColor | color | "var(--zg-brand)" | |
| Цвет текста | textColor | color | "var(--zg-text-1)" | |
| Цвет активных букв | accentColor | color | "var(--zg-brand)" | |
| Повторять | loop | boolean | false | |
| Анимация | motion | select | "auto" | По настройке системы, Всегда включена, Без движения |
Код
export const css = `
.rcp-rat{--rcp-i:.7;--rcp-size:220px;--rcp-ring:var(--zg-text-1,#fff);--rcp-arc:var(--zg-brand,#4ade80);--rcp-text:var(--zg-text-1,currentColor);--rcp-accent:var(--zg-brand,#4ade80);position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(16px,4vw,36px);box-sizing:border-box;width:100%;padding:24px;color:var(--rcp-text);font-family:var(--zg-font,inherit);text-align:center}
.rcp-rat__ring{position:relative;margin:0;width:var(--rcp-size);max-width:70%;aspect-ratio:1/1}
.rcp-rat__svg{display:block;width:100%;height:100%;overflow:visible}
.rcp-rat__svg circle{fill:none}
.rcp-rat [hidden]{display:none}
.rcp-rat__glow{stroke:var(--rcp-arc);stroke-width:8;opacity:calc(var(--rcp-i)*.55);filter:blur(calc(var(--rcp-i)*7px))}
.rcp-rat__track{stroke:var(--rcp-ring);stroke-width:3;filter:drop-shadow(0 0 calc(var(--rcp-i)*8px) var(--rcp-ring))}
.rcp-rat__dash{stroke:var(--rcp-ring);stroke-width:1;stroke-dasharray:6 9;opacity:.45}
.rcp-rat__spin{transform-origin:100px 100px}
.rcp-rat__arc{stroke:var(--rcp-arc);stroke-width:4;stroke-linecap:round;filter:drop-shadow(0 0 calc(var(--rcp-i)*10px) var(--rcp-arc))}
.rcp-rat__label{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:0 22%;font-size:calc(var(--rcp-size)*.07);line-height:1.25;letter-spacing:.01em;overflow-wrap:anywhere}
.rcp-rat__title{margin:0;max-width:30ch;font-size:clamp(20px,3.2vw,34px);font-weight:500;line-height:1.18;text-wrap:balance}
.rcp-rat__w{display:inline-block;max-width:100%;overflow-wrap:anywhere}
.rcp-rat__ch{opacity:0;transition:opacity .12s linear,color .3s ease}
.rcp-rat__ch.is-on{opacity:1}
.rcp-rat__ch.is-hot{color:var(--rcp-accent)}
.rcp-rat[data-rcp-motion="reduce"] .rcp-rat__ch{transition:none}
`;
export const defaults = {
title: 'Non-Custodial Composable Option Protocol',
label: 'Cross-Chain Gateway Protocol',
typeSpeed: 45,
startDelay: 200,
ringDelay: 900,
ringDraw: 900,
arcDuration: 2400,
arcLength: 22,
size: 220,
intensity: 0.7,
ringColor: 'var(--zg-text-1)',
arcColor: 'var(--zg-brand)',
textColor: 'var(--zg-text-1)',
accentColor: 'var(--zg-brand)',
loop: false,
motion: 'auto',
};
const NS = 'http://www.w3.org/2000/svg';
const noop = () => {};
const sheets = new WeakMap();
const num = (v, min, max, d) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : d);
const str = (v, d, max = 200) => (typeof v === 'string' ? v.slice(0, max) : d);
const color = (v, d) => (typeof v === 'string' && v.trim() && v.length <= 80 && !/[;{}<>]/.test(v) ? v.trim() : d);
function normalize(options) {
const o = options && typeof options === 'object' ? options : {};
const d = defaults;
return {
title: str(o.title, d.title, 120),
label: str(o.label, d.label, 48),
typeSpeed: num(o.typeSpeed, 10, 200, d.typeSpeed),
startDelay: num(o.startDelay, 0, 3000, d.startDelay),
ringDelay: num(o.ringDelay, 0, 5000, d.ringDelay),
ringDraw: num(o.ringDraw, 100, 4000, d.ringDraw),
arcDuration: num(o.arcDuration, 600, 10000, d.arcDuration),
arcLength: num(o.arcLength, 5, 60, d.arcLength),
size: num(o.size, 120, 360, d.size),
intensity: num(o.intensity, 0, 1, d.intensity),
ringColor: color(o.ringColor, d.ringColor),
arcColor: color(o.arcColor, d.arcColor),
textColor: color(o.textColor, d.textColor),
accentColor: color(o.accentColor, d.accentColor),
loop: o.loop === true,
motion: o.motion === 'full' || o.motion === 'reduce' ? o.motion : 'auto',
};
}
function acquireSheet(root) {
if (!root || !('adoptedStyleSheets' in root) || typeof CSSStyleSheet === 'undefined') return () => {};
let rec = sheets.get(root);
try {
if (!rec) {
const sheet = new CSSStyleSheet();
sheet.replaceSync(css);
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
rec = { sheet, n: 0 };
sheets.set(root, rec);
}
} catch {
return () => {};
}
rec.n += 1;
return () => {
rec.n -= 1;
if (rec.n === 0) {
root.adoptedStyleSheets = root.adoptedStyleSheets.filter((s) => s !== rec.sheet);
sheets.delete(root);
}
};
}
function reducedMotion(doc, mode) {
if (mode === 'reduce') return true;
if (mode === 'full') return false;
const win = doc.defaultView;
if (doc.documentElement && doc.documentElement.getAttribute('data-motion') === 'off') return true;
return !!(win && typeof win.matchMedia === 'function' && win.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
function svgEl(doc, name, cls, attrs) {
const n = doc.createElementNS(NS, name);
if (cls) n.setAttribute('class', cls);
for (const k of Object.keys(attrs || {})) n.setAttribute(k, String(attrs[k]));
return n;
}
function buildTitle(doc, text) {
const h = doc.createElement('h2');
h.className = 'rcp-rat__title';
h.setAttribute('aria-label', text.replace(/\s+/g, ' '));
const chars = [];
const lines = text.split('\n');
lines.forEach((line, li) => {
if (li > 0) h.appendChild(doc.createElement('br'));
line.split(/\s+/).filter(Boolean).forEach((word, wi, arr) => {
const w = doc.createElement('span');
w.className = 'rcp-rat__w';
w.setAttribute('aria-hidden', 'true');
for (const ch of Array.from(word)) {
const c = doc.createElement('span');
c.className = 'rcp-rat__ch';
c.textContent = ch;
w.appendChild(c);
chars.push(c);
}
h.appendChild(w);
if (wi < arr.length - 1) h.appendChild(doc.createTextNode(' '));
});
});
return { h, chars };
}
/**
* Mounts the effect into el and returns destroy(). Safe without a DOM (returns a no-op).
* @param {HTMLElement} el
* @param {Partial<typeof defaults>} [options]
* @returns {() => void}
*/
export function mount(el, options) {
if (typeof document === 'undefined' || !el || typeof el.appendChild !== 'function' || !el.ownerDocument) return noop;
const doc = el.ownerDocument;
const o = normalize(options);
const still = reducedMotion(doc, o.motion);
const root = el.isConnected ? el.getRootNode() : doc;
const release = acquireSheet(root);
const timers = new Set();
const anims = [];
const later = (fn, ms) => {
const id = setTimeout(() => {
timers.delete(id);
fn();
}, ms);
timers.add(id);
};
el.classList.add('rcp-rat');
el.setAttribute('data-rcp-motion', still ? 'reduce' : 'full');
const set = (k, v) => el.style.setProperty(k, v);
set('--rcp-i', String(o.intensity));
set('--rcp-size', `${o.size}px`);
set('--rcp-ring', o.ringColor);
set('--rcp-arc', o.arcColor);
set('--rcp-text', o.textColor);
set('--rcp-accent', o.accentColor);
const ring = doc.createElement('figure');
ring.className = 'rcp-rat__ring';
const svg = svgEl(doc, 'svg', 'rcp-rat__svg', { viewBox: '0 0 200 200', 'aria-hidden': 'true', focusable: 'false' });
const base = svgEl(doc, 'g', '', { transform: 'rotate(-90 100 100)' });
const circ = (cls, r, extra) => svgEl(doc, 'circle', cls, { cx: 100, cy: 100, r, ...extra });
const glow = circ('rcp-rat__glow', 90, { pathLength: 100 });
const track = circ('rcp-rat__track', 90, { pathLength: 100, 'stroke-dasharray': '100 100' });
const dash = circ('rcp-rat__dash', 78, {});
const spin = svgEl(doc, 'g', 'rcp-rat__spin', {});
const arc = circ('rcp-rat__arc', 90, { pathLength: 100, 'stroke-dasharray': `${o.arcLength} ${100 - o.arcLength}` });
spin.appendChild(arc);
base.append(glow, track, dash, spin);
svg.appendChild(base);
const label = doc.createElement('figcaption');
label.className = 'rcp-rat__label';
label.textContent = o.label;
ring.append(svg, label);
const { h: title, chars } = buildTitle(doc, o.title);
el.append(ring, title);
const canAnimate = !still && typeof ring.animate === 'function';
const play = (node, frames, opts) => {
const a = node.animate(frames, opts);
anims.push(a);
return a;
};
const hideRing = () => {
track.setAttribute('stroke-dashoffset', '100');
glow.setAttribute('stroke-dashoffset', '100');
dash.setAttribute('opacity', '0');
arc.setAttribute('opacity', '0');
label.hidden = true;
};
const showRing = () => {
track.setAttribute('stroke-dashoffset', '0');
glow.setAttribute('stroke-dashoffset', '0');
dash.removeAttribute('opacity');
arc.removeAttribute('opacity');
label.hidden = false;
};
const reset = () => {
for (const t of timers) clearTimeout(t);
timers.clear();
while (anims.length) anims.pop().cancel();
for (const c of chars) c.classList.remove('is-on', 'is-hot');
hideRing();
};
const startRing = () => {
ring.hidden = false;
label.hidden = false;
arc.removeAttribute('opacity');
dash.removeAttribute('opacity');
const ease = 'cubic-bezier(.2,.7,.2,1)';
const drawn = { duration: o.ringDraw, easing: ease, fill: 'forwards' };
track.setAttribute('stroke-dashoffset', '0');
glow.setAttribute('stroke-dashoffset', '0');
play(track, [{ strokeDashoffset: '100' }, { strokeDashoffset: '0' }], drawn);
play(glow, [{ strokeDashoffset: '100' }, { strokeDashoffset: '0' }], drawn);
play(dash, [{ opacity: 0 }, { opacity: 0.45 }], { duration: o.ringDraw, fill: 'backwards' });
play(label, [{ opacity: 0 }, { opacity: 1 }], { duration: o.ringDraw * 0.6, delay: o.ringDraw * 0.4, fill: 'backwards' });
play(arc, [{ opacity: 0 }, { opacity: 1 }], { duration: o.ringDraw * 0.5, delay: o.ringDraw * 0.5, fill: 'backwards' });
play(spin, [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], {
duration: o.arcDuration,
iterations: Infinity,
delay: o.ringDraw * 0.5,
});
};
const run = () => {
reset();
if (!canAnimate) {
for (const c of chars) c.classList.add('is-on');
showRing();
return;
}
ring.hidden = true;
let i = 0;
const tick = () => {
const cur = chars[i];
if (cur) {
cur.classList.add('is-on', 'is-hot');
const old = chars[i - 2];
if (old) old.classList.remove('is-hot');
}
i += 1;
if (i < chars.length) later(tick, o.typeSpeed);
else {
later(() => {
for (const c of chars) c.classList.remove('is-hot');
}, o.typeSpeed * 3);
}
};
later(tick, o.startDelay);
later(startRing, o.startDelay + o.ringDelay);
if (o.loop) {
const total = o.startDelay + Math.max(o.ringDelay + o.ringDraw, chars.length * o.typeSpeed) + 3000;
later(run, total);
}
};
run();
return () => {
for (const t of timers) clearTimeout(t);
timers.clear();
while (anims.length) anims.pop().cancel();
ring.remove();
title.remove();
el.classList.remove('rcp-rat');
el.removeAttribute('data-rcp-motion');
for (const k of ['--rcp-i', '--rcp-size', '--rcp-ring', '--rcp-arc', '--rcp-text', '--rcp-accent']) el.style.removeProperty(k);
release();
};
}
export default mount;