Word Fade Scroll
Абзац проявляется по словам вслед за прокруткой (приём длинных манифестов Apple/Linear): один именованный view()-таймлайн абзаца, у каждого слова свой отрезок пути через animation-range инлайн-переменными от индекса. Ноль скриптов, переносы строк не важны.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| dim? | number | 0.22 | Прозрачность непрочитанного слова (0—1). |
| spread? | number | 1.6 | Насколько окна соседних слов перекрываются (в «словах»): 1 — встык, больше — мягче. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/word-fade-scroll.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
const css = `.wfs {
margin: 0;
font-weight: 700;
line-height: 1.4;
letter-spacing: var(--ds-tracking-tight, -0.02em);
color: var(--ds-fg, oklch(0.95 0.01 265));
/* один именованный таймлайн НА АБЗАЦ: слова-потомки находят его по имени.
Свой view() у каждого слова не годится: у слов одной строки таймлайны
совпадают, и строка вспыхивала бы целиком */
view-timeline: --wfs block;
}
/* база: слово видно целиком — без поддержки таймлайнов, под reduce и data-shot
абзац просто стоит прочитанным (правило «база вне @supports») */
@supports (animation-timeline: view()) {
@media not (prefers-reduced-motion: reduce) {
.wfs-w {
opacity: var(--wfs-dim, 0.22);
animation: wfs-in linear both;
animation-timeline: --wfs;
/* отрезок пути слова задан ИНДЕКСОМ при рендере (инлайн-переменные):
никакой зависимости от строк и переносов — порядок чтения и есть порядок */
animation-range: cover var(--wfs-r0, 10%) cover var(--wfs-r1, 60%);
}
@keyframes wfs-in {
to { opacity: 1; }
}
}
}
html[data-shot] .wfs-w {
animation: none;
opacity: 1;
}
`;
export interface WordFadeScrollProps {
/** Абзац; разбивается по пробелам. */
text?: string;
/** Прозрачность непрочитанного слова (0—1). */
dim?: number;
/** Насколько окна соседних слов перекрываются (в «словах»): 1 — встык, больше — мягче. */
spread?: number;
}
const TEXT =
"Хороший текст читают в темпе читателя, а не в темпе автора. Эта строка проявляется " +
"по мере прокрутки: слово за словом, как взгляд идёт по абзацу. Отмотайте назад — " +
"и прочитанное честно погаснет, потому что прогресс принадлежит странице, а не таймеру.";
/**
* Абзац проявляется ПО СЛОВАМ вслед за прокруткой — приём длинных манифестов
* Apple и Linear. Ноль скриптов: у абзаца один именованный view()-таймлайн,
* а каждое слово получает свой отрезок пути через `animation-range`
* инлайн-переменными, посчитанными от ИНДЕКСА слова при рендере.
*
* Разность приёма с соседями: `highlight-lines` ведёт маркер по СТРОКАМ
* (отрезки зависят от переносов и меряются после раскладки), `blur-mask-reveal`
* проявляет строки из расфокуса на GSAP+Lenis. Здесь гранула — слово, отрезки
* чисто арифметические (переносы не важны), и всё это CSS без единого байта JS.
*/
export function WordFadeScroll({ text = TEXT, dim = 0.22, spread = 1.6 }: WordFadeScrollProps) {
const words = text.trim().split(/\s+/);
const n = Math.max(words.length, 1);
// окно чтения: 12%–78% пути cover — края пути абзац проводит у кромок кадра,
// где читать его никто не будет
const start = 12;
const span = 66;
return (
<p className="wfs" style={{ ["--wfs-dim" as string]: dim }}>
<style href="motiva-word-fade-scroll" precedence="medium">{css}</style>
{words.map((w, i) => {
const r0 = start + (span * i) / n;
const r1 = Math.min(start + (span * (i + spread)) / n, start + span);
return (
<span
key={`${w}-${i}`}
className="wfs-w"
style={{
["--wfs-r0" as string]: `${r0.toFixed(2)}%`,
["--wfs-r1" as string]: `${r1.toFixed(2)}%`,
}}
>
{w}
{i < words.length - 1 ? " " : ""}
</span>
);
})}
</p>
);
}