Каталог / Заголовок, проявляющийся из размытия
Заголовок, проявляющийся из размытия
Крупный заголовок появляется из сильного размытия: слова или буквы по очереди становятся чёткими, слегка поднимаясь на своё место.
Демо
Как устроено
Текст режется на слова (или буквы, слова при этом не ломаются переносом). Каждый кусочек стоит на месте заранее, но размыт фильтром blur, прозрачен и чуть смещён вниз. По таймеру кусочки по очереди получают класс is-on, и CSS-переход убирает размытие, прозрачность и смещение. Степень размытия и длительность перехода задаются CSS-переменными на контейнере, поэтому у дочерних элементов нет inline-стилей и строгий CSP не мешает. Весь заголовок дополнительно мягко всплывает через Web Animations API. Без DOM mount ничего не делает. При prefers-reduced-motion (или data-motion=off на html) текст сразу показывается чётким.
Параметры
| Название | Ключ | Тип | По умолчанию | Значения |
|---|---|---|---|---|
| Текст заголовка | title | string | "Единая платформа для всей команды" | до 160 символов |
| Как проявлять | mode | select | "word" | По словам, По буквам |
| Шаг между кусочками | stagger | number | 90 | 10…400 мс |
| Длительность проявления | duration | number | 600 | 100…3000 мс |
| Задержка старта | startDelay | number | 200 | 0…3000 мс |
| Степень размытия | blur | number | 14 | 2…40 px |
| Цвет текста | textColor | color | "var(--zg-text-1)" | |
| Анимация | motion | select | "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;