ZiyaraGo

Каталог / Кольцо с бегущей дугой и набор текста

Кольцо с бегущей дугой и набор текста

ЭффектБесплатныйv1.0.0

Заголовок набирается по буквам, затем прорисовывается светящееся кольцо, по которому бежит дуга, а внутри появляется подпись.

#hero#ring#typing#glow#text

Демо

Как устроено

Заголовок режется на слова и буквы (слова не ломаются), буквы проявляются по таймеру, последние две подсвечены акцентным цветом. Кольцо это 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) сразу показывается финальное состояние без вращения.

Параметры

НазваниеКлючТипПо умолчаниюЗначения
Заголовокtitlestring"Non-Custodial Composable Option Protocol"до 120 символов
Подпись в кольцеlabelstring"Cross-Chain Gateway Protocol"до 48 символов
Скорость набораtypeSpeednumber4510…200 мс/буква
Задержка стартаstartDelaynumber2000…3000 мс
Появление кольцаringDelaynumber9000…5000 мс
Прорисовка кольцаringDrawnumber900100…4000 мс
Оборот дугиarcDurationnumber2400600…10000 мс
Длина дугиarcLengthnumber225…60 %
Размер кольцаsizenumber220120…360 px
Интенсивность свеченияintensitynumber0.70…1
Цвет кольцаringColorcolor"var(--zg-text-1)"
Цвет дугиarcColorcolor"var(--zg-brand)"
Цвет текстаtextColorcolor"var(--zg-text-1)"
Цвет активных буквaccentColorcolor"var(--zg-brand)"
Повторятьloopbooleanfalse
Анимацияmotionselect"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;