ZiyaraGo

Каталог / Заголовок, проявляющийся из размытия

Заголовок, проявляющийся из размытия

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

Крупный заголовок появляется из сильного размытия: слова или буквы по очереди становятся чёткими, слегка поднимаясь на своё место.

#hero#heading#blur#reveal#text

Демо

Как устроено

Текст режется на слова (или буквы, слова при этом не ломаются переносом). Каждый кусочек стоит на месте заранее, но размыт фильтром blur, прозрачен и чуть смещён вниз. По таймеру кусочки по очереди получают класс is-on, и CSS-переход убирает размытие, прозрачность и смещение. Степень размытия и длительность перехода задаются CSS-переменными на контейнере, поэтому у дочерних элементов нет inline-стилей и строгий CSP не мешает. Весь заголовок дополнительно мягко всплывает через Web Animations API. Без DOM mount ничего не делает. При prefers-reduced-motion (или data-motion=off на html) текст сразу показывается чётким.

Параметры

НазваниеКлючТипПо умолчаниюЗначения
Текст заголовкаtitlestring"Единая платформа для всей команды"до 160 символов
Как проявлятьmodeselect"word"По словам, По буквам
Шаг между кусочкамиstaggernumber9010…400 мс
Длительность проявленияdurationnumber600100…3000 мс
Задержка стартаstartDelaynumber2000…3000 мс
Степень размытияblurnumber142…40 px
Цвет текстаtextColorcolor"var(--zg-text-1)"
Анимацияmotionselect"auto"По настройке системы, Всегда включена, Без движения

Код

export const css = `
.rcp-brh{--rcp-blur:14px;--rcp-dur:600ms;--rcp-text:var(--zg-text-1,#1a1f26);display:flex;align-items:center;justify-content:center;box-sizing:border-box;width:100%;padding:24px;color:var(--rcp-text);font-family:var(--zg-font,inherit);text-align:center}
.rcp-brh__title{margin:0;max-width:22ch;font-size:clamp(28px,5vw,56px);font-weight:600;line-height:1.12;letter-spacing:-.02em;text-wrap:balance}
.rcp-brh__w{display:inline-block;max-width:100%;overflow-wrap:anywhere}
.rcp-brh__u{display:inline-block;opacity:0;filter:blur(var(--rcp-blur));transform:translateY(.22em);transition:opacity var(--rcp-dur) ease,filter var(--rcp-dur) ease,transform var(--rcp-dur) cubic-bezier(.2,.7,.2,1)}
.rcp-brh__u.is-on{opacity:1;filter:blur(0);transform:none}
.rcp-brh[data-rcp-motion="reduce"] .rcp-brh__u{transition:none}
`;

export const defaults = {
  title: 'Единая платформа для всей команды',
  mode: 'word',
  stagger: 90,
  duration: 600,
  startDelay: 200,
  blur: 14,
  textColor: 'var(--zg-text-1)',
  motion: 'auto',
};

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, 160),
    mode: o.mode === 'letter' ? 'letter' : 'word',
    stagger: num(o.stagger, 10, 400, d.stagger),
    duration: num(o.duration, 100, 3000, d.duration),
    startDelay: num(o.startDelay, 0, 3000, d.startDelay),
    blur: num(o.blur, 2, 40, d.blur),
    textColor: color(o.textColor, d.textColor),
    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 buildTitle(doc, text, mode) {
  const h = doc.createElement('h2');
  h.className = 'rcp-brh__title';
  h.setAttribute('aria-label', text.replace(/\s+/g, ' '));
  const units = [];
  const words = text.split(/\s+/).filter(Boolean);
  words.forEach((word, wi) => {
    const w = doc.createElement('span');
    w.className = 'rcp-brh__w';
    w.setAttribute('aria-hidden', 'true');
    if (mode === 'letter') {
      for (const ch of Array.from(word)) {
        const u = doc.createElement('span');
        u.className = 'rcp-brh__u';
        u.textContent = ch;
        w.appendChild(u);
        units.push(u);
      }
    } else {
      const u = doc.createElement('span');
      u.className = 'rcp-brh__u';
      u.textContent = word;
      w.appendChild(u);
      units.push(u);
    }
    h.appendChild(w);
    if (wi < words.length - 1) h.appendChild(doc.createTextNode(' '));
  });
  return { h, units };
}

/**
 * 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-brh');
  el.setAttribute('data-rcp-motion', still ? 'reduce' : 'full');
  el.style.setProperty('--rcp-blur', `${o.blur}px`);
  el.style.setProperty('--rcp-dur', `${o.duration}ms`);
  el.style.setProperty('--rcp-text', o.textColor);

  const { h: title, units } = buildTitle(doc, o.title, o.mode);
  el.appendChild(title);

  const canAnimate = !still && typeof title.animate === 'function';
  if (canAnimate) {
    anims.push(
      title.animate([{ transform: 'translateY(12px)' }, { transform: 'none' }], {
        duration: o.duration + o.startDelay,
        easing: 'cubic-bezier(.2,.7,.2,1)',
        fill: 'backwards',
      }),
    );
    units.forEach((u, i) => later(() => u.classList.add('is-on'), o.startDelay + i * o.stagger));
  } else {
    for (const u of units) u.classList.add('is-on');
  }

  return () => {
    for (const t of timers) clearTimeout(t);
    timers.clear();
    while (anims.length) anims.pop().cancel();
    title.remove();
    el.classList.remove('rcp-brh');
    el.removeAttribute('data-rcp-motion');
    for (const k of ['--rcp-blur', '--rcp-dur', '--rcp-text']) el.style.removeProperty(k);
    release();
  };
}

export default mount;