← каталог
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
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
/**
* Image Sequence Scroll — vanilla JS/GSAP+Lenis (без React). Скролл мотает кадры на canvas
* (техника Apple). Передай urls кадров ИЛИ свою drawFrame(ctx, w, h, frame, total).
*
* <div data-isq-wrap style="height:300vh">
* <div style="position:sticky;top:0;height:100vh"><canvas data-isq-canvas></canvas></div>
* </div>
* <script type="module">
* import { ImageSequenceScroll } from './image-sequence-scroll.vanilla.js';
* const seq = new ImageSequenceScroll(document.querySelector('[data-isq-wrap]'), {
* urls: Array.from({length: 80}, (_, i) => `/frames/${String(i).padStart(3,'0')}.jpg`),
* });
* // seq.destroy()
* </script>
*/
export class ImageSequenceScroll {
constructor(wrap, { urls = null, frames = 80, drawFrame = null } = {}) {
this.wrap = wrap;
this.canvas = wrap.querySelector("[data-isq-canvas]");
this.ctx = this.canvas?.getContext("2d");
if (!this.ctx) return;
this.frames = urls ? urls.length : frames;
this.frame = -1;
this.images = null;
this.drawCustom = drawFrame;
if (urls) {
this.images = urls.map((u) => {
const img = new Image();
img.src = u;
return img;
});
this.images[0]?.decode?.().then(() => this.render(this.frame < 0 ? 0 : this.frame));
}
this.resize = () => {
const r = this.canvas.getBoundingClientRect();
this.canvas.width = Math.round(r.width * Math.min(2, window.devicePixelRatio));
this.canvas.height = Math.round(r.height * Math.min(2, window.devicePixelRatio));
const f = Math.max(0, this.frame);
this.frame = -1;
this.render(f);
};
this.resize();
window.addEventListener("resize", this.resize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (document.documentElement.hasAttribute("data-shot") || reduce) {
this.render(this.frames - 1);
return;
}
this.render(0);
gsap.registerPlugin(ScrollTrigger);
this.lenis = new Lenis({ duration: 1.1 });
this.lenis.on("scroll", ScrollTrigger.update);
this.rafFn = (t) => this.lenis.raf(t * 1000);
gsap.ticker.add(this.rafFn);
gsap.ticker.lagSmoothing(0);
this.st = ScrollTrigger.create({
trigger: wrap,
start: "top top",
end: "bottom bottom",
scrub: true,
onUpdate: (self) => this.render(Math.round(self.progress * (this.frames - 1))),
});
}
/* Рисуем только на смене кадра — как настоящая секвенция. */
render(f) {
if (f === this.frame || !this.ctx) return;
this.frame = f;
const { canvas, ctx } = this;
if (this.images) {
const img = this.images[f];
if (img?.complete && img.naturalWidth) {
// cover-вписывание кадра в canvas
const s = Math.max(canvas.width / img.naturalWidth, canvas.height / img.naturalHeight);
const w = img.naturalWidth * s;
const h = img.naturalHeight * s;
ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
}
} else if (this.drawCustom) {
this.drawCustom(ctx, canvas.width, canvas.height, f, this.frames);
}
}
destroy() {
this.st?.kill();
window.removeEventListener("resize", this.resize);
if (this.rafFn) gsap.ticker.remove(this.rafFn);
this.lenis?.destroy();
}
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); техника — Apple product pagesv1.0.0