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

Image Sequence Scroll

Секвенция кадров по скроллу (техника Apple): прокрутка мотает «видео» на canvas. Два режима: настоящие картинки (предзагруженная JPEG-секвенция, drawImage с cover) — свои дизайн-ассеты подставляются одним массивом адресов; и процедурный рассвет без единого файла.

gsaplenisscrollmorphadvanced~30 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
frames?string[]Кадры-КАРТИНКИ (формат «["https://motiva.pages.dev/img/seq/device-000.jpg", …]»). Дай сюда свои отрендеренные кадры (Blender/AE/Cinema 4D → JPEG-секвенция) — и прокрутка замотает их, как у Apple. Без пропа рисуется процедурный «рассвет».
caption?stringПодпись под секвенцией.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/image-sequence-scroll.json

зависимости: gsap@^3.15.0 · lenis@^1.3.25

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

const FRAMES = 80;

export interface ImageSequenceScrollProps {
  /**
   * Кадры-КАРТИНКИ (формат «["https://motiva.pages.dev/img/seq/device-000.jpg", …]»). Дай сюда свои
   * отрендеренные кадры (Blender/AE/Cinema 4D → JPEG-секвенция) — и прокрутка
   * замотает их, как у Apple. Без пропа рисуется процедурный «рассвет».
   */
  frames?: string[];
  /** Подпись под секвенцией. */
  caption?: string;
}

/** Линейная интерполяция цвета в RGB (для неба по «времени суток»). */
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const rgb = (a: [number, number, number], b: [number, number, number], t: number) =>
  `rgb(${Math.round(lerp(a[0], b[0], t))}, ${Math.round(lerp(a[1], b[1], t))}, ${Math.round(lerp(a[2], b[2], t))})`;

/**
 * Один «кадр» секвенции — рисуется процедурно (рассвет: небо, солнце, звёзды, горы).
 * В реальном проекте замени на ctx.drawImage(предзагруженный кадр i) — каркас тот же.
 */
function drawFrame(ctx: CanvasRenderingContext2D, w: number, h: number, frame: number) {
  const t = frame / (FRAMES - 1);
  const sky = ctx.createLinearGradient(0, 0, 0, h);
  sky.addColorStop(0, rgb([12, 10, 34], [96, 68, 173], t));
  sky.addColorStop(0.6, rgb([26, 20, 58], [214, 108, 132], t));
  sky.addColorStop(1, rgb([40, 30, 70], [250, 190, 88], t));
  ctx.fillStyle = sky;
  ctx.fillRect(0, 0, w, h);

  // звёзды гаснут к рассвету (детерминированный псевдорандом от индекса)
  ctx.globalAlpha = Math.max(0, 1 - t * 1.6);
  ctx.fillStyle = "#e8e8f5";
  for (let i = 0; i < 70; i++) {
    const sx = ((i * 137.5) % 360) / 360;
    const sy = ((i * 73.3) % 200) / 200;
    ctx.fillRect(sx * w, sy * h * 0.55, 1.6, 1.6);
  }
  ctx.globalAlpha = 1;

  // солнце поднимается по дуге
  const sunX = w * (0.22 + 0.56 * t);
  const sunY = h * (0.95 - 0.62 * Math.sin(t * Math.PI * 0.5));
  const glow = ctx.createRadialGradient(sunX, sunY, 4, sunX, sunY, h * 0.3);
  glow.addColorStop(0, "rgba(255, 240, 200, 0.95)");
  glow.addColorStop(0.3, `rgba(251, 191, 36, ${0.25 + 0.4 * t})`);
  glow.addColorStop(1, "rgba(251, 191, 36, 0)");
  ctx.fillStyle = glow;
  ctx.fillRect(0, 0, w, h);
  ctx.beginPath();
  ctx.arc(sunX, sunY, 10 + 14 * t, 0, Math.PI * 2);
  ctx.fillStyle = "#fff3d0";
  ctx.fill();

  // горы
  ctx.fillStyle = "#12101c";
  ctx.beginPath();
  ctx.moveTo(0, h);
  ctx.lineTo(0, h * 0.72);
  ctx.lineTo(w * 0.28, h * 0.5);
  ctx.lineTo(w * 0.52, h * 0.78);
  ctx.lineTo(w * 0.74, h * 0.42);
  ctx.lineTo(w, h * 0.7);
  ctx.lineTo(w, h);
  ctx.closePath();
  ctx.fill();

}

/**
 * Image-sequence по скроллу (техника Apple): прокрутка мотает кадры «видео» на canvas.
 *
 * Два режима, каркас один:
 * - `frames` задан — кадры это НАСТОЯЩИЕ картинки: предзагружаются, рисуются drawImage
 *   c cover-вписыванием. Подмена на свои дизайн-ассеты = просто другой массив адресов;
 * - без `frames` кадры рисуются процедурно (рассвет) — нулевые ассеты, видно каркас.
 *
 * Пин через sticky. Уважает reduced-motion / data-shot (финальный кадр статично).
 */
export function ImageSequenceScroll({ frames, caption }: ImageSequenceScrollProps = {}) {
  const root = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const framesRef = useRef(frames);
  framesRef.current = frames;

  useEffect(() => {
    const el = root.current;
    const canvas = canvasRef.current;
    if (!el || !canvas) return;
    const ctx2d = canvas.getContext("2d");
    if (!ctx2d) return;

    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const frozen = document.documentElement.hasAttribute("data-shot") || reduce;

    const srcs = framesRef.current;
    const total = srcs?.length ? srcs.length : FRAMES;

    // режим картинок: предзагрузка; кадр, чья картинка ещё едет, дорисуется по load
    const imgs = (srcs ?? []).map((src) => {
      const im = new Image();
      im.decoding = "async";
      im.src = src;
      return im;
    });

    const drawCover = (im: HTMLImageElement) => {
      const cw = canvas.width;
      const ch = canvas.height;
      const s = Math.max(cw / im.naturalWidth, ch / im.naturalHeight);
      const dw = im.naturalWidth * s;
      const dh = im.naturalHeight * s;
      ctx2d.drawImage(im, (cw - dw) / 2, (ch - dh) / 2, dw, dh);
    };

    let frame = -1;
    const render = (f: number) => {
      if (f === frame) return; // рисуем только на смене кадра — как настоящая секвенция
      frame = f;
      if (imgs.length) {
        const im = imgs[f]!;
        if (im.complete && im.naturalWidth) drawCover(im);
        else
          im.addEventListener(
            "load",
            () => {
              if (frame === f) drawCover(im);
            },
            { once: true },
          );
      } else {
        drawFrame(ctx2d, canvas.width, canvas.height, f);
      }
      // счётчик кадра поверх любого режима (наглядность scrub-а)
      ctx2d.fillStyle = "rgba(232, 232, 245, 0.85)";
      ctx2d.font = "12px ui-monospace, monospace";
      ctx2d.textAlign = "right";
      ctx2d.fillText(`frame ${String(f + 1).padStart(3, "0")} / ${total}`, canvas.width - 14, 22);
    };
    const resize = () => {
      const r = canvas.getBoundingClientRect();
      canvas.width = Math.round(r.width * Math.min(2, window.devicePixelRatio));
      canvas.height = Math.round(r.height * Math.min(2, window.devicePixelRatio));
      const f = frame;
      frame = -1;
      render(Math.max(0, f));
    };
    resize();
    window.addEventListener("resize", resize);

    if (frozen) {
      render(total - 1);
      return () => window.removeEventListener("resize", resize);
    }
    render(0);

    gsap.registerPlugin(ScrollTrigger);
    const lenis = new Lenis({ duration: 1.1 });
    lenis.on("scroll", ScrollTrigger.update);
    const rafFn = (time: number) => lenis.raf(time * 1000);
    gsap.ticker.add(rafFn);
    gsap.ticker.lagSmoothing(0);

    const st = ScrollTrigger.create({
      trigger: el.querySelector(".isq-wrap"),
      start: "top top",
      end: "bottom bottom",
      scrub: true,
      onUpdate: (self) => render(Math.round(self.progress * (total - 1))),
    });

    return () => {
      st.kill();
      window.removeEventListener("resize", resize);
      gsap.ticker.remove(rafFn);
      lenis.destroy();
    };
  }, []);

  return (
    <div ref={root} style={{ width: "100vw", overflowX: "clip", color: "oklch(0.92 0.01 265)" }}>
      <div className="isq-wrap" style={{ height: "300vh" }}>
        <div style={{ position: "sticky", top: 0, height: "100vh", overflow: "hidden" }}>
          <canvas ref={canvasRef} style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} />
          <div
            style={{
              position: "absolute",
              left: 16,
              top: 14,
              fontFamily: "ui-monospace, monospace",
              fontSize: "0.75rem",
              opacity: 0.75,
            }}
          >
            скролл мотает кадры ↓
          </div>
        </div>
      </div>
      <section style={{ height: "30vh", display: "grid", placeItems: "center", opacity: 0.55 }}>
        <span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}>
          {caption ?? (frames?.length ? `секвенция отснята — ${frames.length} кадров` : "рассвет отснят — 80 кадров")}
        </span>
      </section>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); техника — Apple product pagesv1.0.0