Speed Smear
Смаз скорости: пока страница летит, за снимком тянутся призрачные копии — чем быстрее прокрутка, тем длиннее шлейф; страница встала — смаз стянулся. Никакого filter: blur (он перерисовывал бы кадр на каждом пикселе) — шлейф это две полупрозрачные копии на translate, растрированы один раз, дальше работает композитор. Скорость — производная прокрутки: десяток строк JS с засыпающим циклом.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| src? | string | "https://motiva.pages.dev/img/1015-1600x… | Снимок. |
| strength? | number | 1.6 | Сила смаза: на сколько px разъезжаются призраки на каждый px/кадр скорости. |
| ratio? | string | "16 / 10" | Пропорции кадра. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/speed-smear.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.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
const css = `.sms {
--sm-v: 0;
position: relative;
margin: 0;
width: 100%;
border-radius: var(--ds-radius, 14px);
/* шлейф живёт ВНУТРИ рамки кадра: клип, не скролл-контейнер */
overflow: clip;
}
.sms img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
max-width: none;
}
.sms-base {
position: relative;
/* лёгкий противоход самого кадра — снимок «подрессорен», а не приклеен */
translate: 0 calc(var(--sm-v) * var(--sm-k) * -0.3px);
}
/* шлейф: копии растрированы один раз, движение — только композитор */
.sms-ghost {
position: absolute;
inset: 0;
}
.sms-g1 {
opacity: 0.45;
translate: 0 calc(var(--sm-v) * var(--sm-k) * 1px);
}
.sms-g2 {
opacity: 0.22;
translate: 0 calc(var(--sm-v) * var(--sm-k) * 2.2px);
}
`;
export interface SpeedSmearProps {
/** Снимок. */
src?: string;
/** Сила смаза: на сколько px разъезжаются призраки на каждый px/кадр скорости. */
strength?: number;
/** Пропорции кадра. */
ratio?: string;
}
/**
* Смаз скорости: пока страница летит, за снимком тянутся призрачные копии —
* чем быстрее прокрутка, тем длиннее шлейф; страница встала — смаз мгновенно
* стягивается. Движение читается ФИЗИЧЕСКИ, как размытие в кино.
*
* Никакого `filter: blur` — настоящий блюр перерисовывал бы кадр на каждом
* пикселе прокрутки. Шлейф — две полупрозрачные КОПИИ снимка, разъезжающиеся
* `translate` по скорости: копии растрированы один раз, дальше работает только
* композитор.
*
* Скорость — производная прокрутки, в CSS её не взять: десяток строк JS
* по образцу `scroll-vars-loop` — слушатель только будит цикл, цикл гасит
* скорость затуханием и ЗАСЫПАЕТ, когда шлейф стянулся. В покое — ноль кадров.
* `prefers-reduced-motion` и `data-shot` не заводят слушатель вовсе.
*/
export function SpeedSmear({
src = "https://motiva.pages.dev/img/1015-1600x1000.jpg",
strength = 1.6,
ratio = "16 / 10",
}: SpeedSmearProps = {}) {
const host = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = host.current;
if (!el) return;
if (
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
return;
let raf = 0;
let lastY = window.scrollY;
let impulse = 0;
let v = 0;
const loop = () => {
// импульс копится В ОБРАБОТЧИКЕ по каждому событию, цикл его только тратит.
// Считать дельту в кадре нельзя: после сна первый кадр видел сдвиг за всё
// время простоя — шлейф прыгал скачком; а обнуление lastY на пробуждении
// съедало импульс в ноль. По-событийная дельта чинит оба конца
v = v * 0.82 + impulse * 0.35;
impulse = 0;
el.style.setProperty("--sm-v", v.toFixed(2));
if (Math.abs(v) > 0.08) {
raf = requestAnimationFrame(loop);
} else {
v = 0;
el.style.setProperty("--sm-v", "0");
raf = 0;
}
};
const onScroll = () => {
const y = window.scrollY;
impulse += y - lastY;
lastY = y;
if (!raf) raf = requestAnimationFrame(loop);
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", onScroll);
};
}, []);
return (
<figure
ref={host}
className="sms"
style={{ "--sm-k": strength, aspectRatio: ratio } as CSSProperties}
>
<style href="motiva-speed-smear" precedence="medium">{css}</style>
<img className="sms-ghost sms-g2" src={src} alt="" aria-hidden="true" loading="lazy" />
<img className="sms-ghost sms-g1" src={src} alt="" aria-hidden="true" loading="lazy" />
<img className="sms-base" src={src} alt="" loading="lazy" />
</figure>
);
}