Motiva
Мои коллекции
← каталог

Speed Smear

Смаз скорости: пока страница летит, за снимком тянутся призрачные копии — чем быстрее прокрутка, тем длиннее шлейф; страница встала — смаз стянулся. Никакого filter: blur (он перерисовывал бы кадр на каждом пикселе) — шлейф это две полупрозрачные копии на translate, растрированы один раз, дальше работает композитор. Скорость — производная прокрутки: десяток строк JS с засыпающим циклом.

cssscrollintermediatereduced-motion ✓
↗
↕ демо длиннее кадра — прокрутите его
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
src?string"https://motiva.pages.dev/img/1015-1600x…Снимок.
strength?number1.6Сила смаза: на сколько px разъезжаются призраки на каждый px/кадр скорости.
ratio?string"16 / 10"Пропорции кадра.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/speed-smear.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — cuberto.com (моушен-смаз медиа при прокрутке), код не заимствованv1.0.0