Paper Fold
Печатный лист складывается гармошкой и раскрывается обратно. Снимок на бумаге не рвётся, потому что развёртка идёт по длине листа, а не по экрану — ровно как у настоящего сложенного плаката: бумага сохраняет длину, а ширина в кадре сжимается сама. Геометрия считается каждый кадр: лист идёт полосами, каждая следующая повёрнута на угол сгиба в другую сторону, нормали пересчитываются по получившимся граням — отсюда чередование света и тени, ради которого приём и существует. Линия сгиба слегка гуляет по вертикали, поэтому лист выглядит бумагой, а не чертежом.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/paper-fold.jsonзависимости: three@^0.185.1 · @types/three@^0.185.0
Что нужно в проекте до установки
Файл
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 { createPaperFold, type PaperFold as Engine, type PaperFoldOptions } from "@/lib/paper-fold-engine";
export type PaperFoldProps = Omit<PaperFoldOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
const ROOM =
"radial-gradient(110% 90% at 50% 12%, oklch(0.26 0.015 265) 0%, oklch(0.16 0.012 262) 55%, oklch(0.1 0.008 258) 100%)";
/**
* Paper Fold — печатный лист складывается гармошкой и раскрывается обратно.
* Снимок на бумаге не рвётся: развёртка идёт по длине листа, как у настоящего
* сложенного плаката. Курсор поворачивает лист.
*
* Сцена — `createPaperFold` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function PaperFold({ backdrop = ROOM, ...options }: PaperFoldProps = {}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const engine = useRef<Engine | null>(null);
const first = useRef(options);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const animate = !(
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
engine.current = createPaperFold(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { folds, fold, breathe, tilt, wave, paper, contrast } = options;
useEffect(() => {
engine.current?.set({ folds, fold, breathe, tilt, wave, paper, contrast });
}, [folds, fold, breathe, tilt, wave, paper, contrast]);
return (
<div
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
...(backdrop === false ? null : { background: backdrop }),
}}
>
<canvas ref={canvasRef} aria-hidden="true" style={{ display: "block", width: "100%", height: "100%" }} />
</div>
);
}
import * as THREE from "three";
/**
* Paper Fold — движок на голом three.js (без React). Печатный лист складывается
* гармошкой и раскрывается обратно: снимок на нём не рвётся, потому что развёртка
* идёт по ДЛИНЕ БУМАГИ, а не по экрану — как у настоящего сложенного плаката.
*
* Геометрия считается на процессоре каждый кадр: лист идёт полосами, каждая следующая
* поворачивается на угол сгиба в другую сторону, а положение набирается шагами по
* этому ходу. Нормали пересчитываются по получившимся граням, поэтому соседние полосы
* сами дают чередование света и тени — ради него приём и существует.
*
* `createPaperFold(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface PaperFoldOptions {
/** Снимок на бумаге (любой URL). Пустая строка — чистый лист. */
image?: string;
/** Сколько полос в гармошке. */
folds?: number;
/** Глубина сгиба: 0 — раскрытый лист, 1 — сложен полностью. */
fold?: number;
/** Дышит ли гармошка сама. */
breathe?: number;
/** Насколько лист ведётся на курсор. */
tilt?: number;
/** Волна по вертикали: сгиб идёт не идеально прямой линией. */
wave?: number;
/** Тон бумаги. */
paper?: "white" | "cream" | "graphite";
/** Жёсткость света: выше — резче переход между полосами. */
contrast?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface PaperFold {
set(next: Partial<PaperFoldOptions>): void;
destroy(): void;
}
const DEFAULTS = {
image: "https://motiva.pages.dev/img/1069-1200x1200.jpg",
folds: 9,
fold: 0.55,
breathe: 0.6,
tilt: 0.6,
wave: 0.35,
paper: "white" as "white" | "cream" | "graphite",
contrast: 0.6,
animate: true,
};
type Config = typeof DEFAULTS;
/** Полос по вертикали: по ним идёт волна сгиба. */
const ROWS = 26;
/** Высота листа в мире. */
const H = 3.2;
const PAPERS: Record<"white" | "cream" | "graphite", [number, number, number]> = {
white: [0.95, 0.95, 0.97],
cream: [0.96, 0.92, 0.83],
graphite: [0.3, 0.31, 0.35],
};
const VERT = /* glsl */ `
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vView;
void main() {
vUv = uv;
vec4 world = modelMatrix * vec4(position, 1.0);
vNormal = normalize(mat3(modelMatrix) * normal);
vView = normalize(cameraPosition - world.xyz);
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uMap;
uniform float uHasMap;
uniform vec2 uUvFit;
uniform vec3 uPaper;
uniform float uContrast;
uniform vec3 uLight;
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vView;
void main() {
vec3 n = normalize(vNormal);
// лист двусторонний: изнанка освещена своей же нормалью, а не чужой
if (!gl_FrontFacing) n = -n;
vec3 base = uPaper;
// снимок вписан «по покрытию»: квадратная картинка на широком листе иначе растянута
if (uHasMap > 0.5) base *= texture2D(uMap, (vUv - 0.5) * uUvFit + 0.5).rgb * 1.3;
float key = max(0.0, dot(n, normalize(uLight)));
float fill = max(0.0, dot(n, normalize(vec3(0.5, -0.2, 0.7)))) * 0.22;
// жёсткость перехода и есть «бумажность»: мягкий градиент читается тканью
float shade = pow(key, mix(1.6, 0.55, uContrast)) * mix(0.75, 1.15, uContrast);
float sheen = pow(max(0.0, dot(n, normalize(vView))), 6.0) * 0.12;
vec3 col = base * (0.22 + shade + fill) + sheen;
gl_FragColor = vec4(col, 1.0);
}
`;
export function createPaperFold(canvas: HTMLCanvasElement, options: PaperFoldOptions = {}): PaperFold {
const cfg: Config = { ...DEFAULTS, ...options };
const animate = cfg.animate;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 30);
camera.position.set(0, 0, 6.2);
const loader = new THREE.TextureLoader();
loader.crossOrigin = "anonymous";
const map = cfg.image ? loader.load(cfg.image, (t) => fitMap(t)) : null;
if (map) {
map.colorSpace = THREE.SRGBColorSpace;
map.wrapS = map.wrapT = THREE.ClampToEdgeWrapping;
}
const material = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FRAG,
uniforms: {
uMap: { value: map },
uHasMap: { value: map ? 1 : 0 },
uUvFit: { value: new THREE.Vector2(1, 1) },
uPaper: { value: new THREE.Vector3(...PAPERS[cfg.paper]) },
uContrast: { value: cfg.contrast },
uLight: { value: new THREE.Vector3(-0.55, 0.62, 0.55).normalize() },
},
side: THREE.DoubleSide,
});
/** Снимок вписывается в пропорцию листа, а не растягивается по ней. */
function fitMap(t: THREE.Texture): void {
const img = t.image as { width?: number; height?: number } | undefined;
if (!img?.width || !img?.height) return;
const ia = img.width / img.height;
const sa = 4.6 / H;
const fit = material.uniforms.uUvFit!.value as THREE.Vector2;
if (sa > ia) fit.set(1, ia / sa);
else fit.set(sa / ia, 1);
}
let cols = Math.max(2, Math.round(cfg.folds));
let geometry = new THREE.BufferGeometry();
const sheet = new THREE.Mesh(geometry, material);
scene.add(sheet);
/**
* Сетка листа. Геометрия НЕИНДЕКСИРОВАННАЯ: у каждой полосы свои вершины на сгибе.
* С общими вершинами `computeVertexNormals` усредняет нормали соседних полос,
* сгиб сглаживается, и вместо гармошки получается волнистая юбка — ровно это
* и было в первом заходе.
*/
const corners = { pos: new Float32Array(0), cols: 0 };
function build(): void {
const quads = cols * ROWS;
const pos = new Float32Array(quads * 6 * 3);
const uv = new Float32Array(quads * 6 * 2);
let k = 0;
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < cols; c++) {
const u0 = c / cols;
const u1 = (c + 1) / cols;
const v0 = 1 - r / ROWS;
const v1 = 1 - (r + 1) / ROWS;
// развёртка по ДЛИНЕ бумаги: снимок не рвётся на сгибах
const quad = [
[u0, v0],
[u0, v1],
[u1, v0],
[u1, v0],
[u0, v1],
[u1, v1],
];
for (const [a, b] of quad) {
uv[k * 2] = a!;
uv[k * 2 + 1] = b!;
k++;
}
}
}
geometry.dispose();
geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(pos, 3));
geometry.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
sheet.geometry = geometry;
corners.pos = new Float32Array((cols + 1) * (ROWS + 1) * 3);
corners.cols = cols;
}
build();
/**
* Укладка гармошки. Идём по полосам слева направо, накапливая ход: каждая следующая
* полоса повёрнута на угол сгиба в ДРУГУЮ сторону. Так лист и складывается —
* длина бумаги сохраняется, а ширина в кадре сжимается сама.
*/
function shape(time: number): void {
const attr = geometry.getAttribute("position") as THREE.BufferAttribute;
const arr = attr.array as Float32Array;
const grid = corners.pos;
const breathe = cfg.breathe > 0.01 ? (Math.sin(time * cfg.breathe * 0.9) * 0.5 + 0.5) * 0.55 : 0;
const amount = Math.min(1, Math.max(0, cfg.fold + breathe * cfg.breathe));
const angle = amount * 1.32;
const panel = 4.6 / cols;
for (let r = 0; r <= ROWS; r++) {
const v = r / ROWS;
const y = (0.5 - v) * H;
// Волна по вертикали ведёт линию сгиба, но НЕ трогает угол: когда волна меняла
// угол, у каждой строки получалась своя ширина, и лист расходился трапецией-юбкой.
const phase = Math.sin(v * 3.1 + time * 0.7) * cfg.wave;
let x = 0;
let z = 0;
let dir = -1;
for (let c = 0; c <= cols; c++) {
const i = (r * (cols + 1) + c) * 3;
grid[i] = x;
grid[i + 1] = y;
grid[i + 2] = z;
const a = angle * dir;
x += Math.cos(a) * panel;
z += Math.sin(a) * panel + phase * 0.045 * dir;
dir = -dir;
}
// центрируем полосу: иначе сложенный лист уезжает влево
const half = x / 2;
for (let c = 0; c <= cols; c++) {
const j = (r * (cols + 1) + c) * 3;
grid[j] = grid[j]! - half;
}
}
// раскладываем углы по треугольникам — по два на полосу-клетку
let k = 0;
const put = (r: number, c: number) => {
const i = (r * (cols + 1) + c) * 3;
arr[k * 3] = grid[i]!;
arr[k * 3 + 1] = grid[i + 1]!;
arr[k * 3 + 2] = grid[i + 2]!;
k++;
};
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < cols; c++) {
put(r, c);
put(r + 1, c);
put(r, c + 1);
put(r, c + 1);
put(r + 1, c);
put(r + 1, c + 1);
}
}
attr.needsUpdate = true;
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
}
function resize(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
resize();
const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
const onPointerMove = (e: PointerEvent) => {
if (!animate) return;
const r = canvas.getBoundingClientRect();
pointer.tx = (e.clientX - r.left) / r.width - 0.5;
pointer.ty = 0.5 - (e.clientY - r.top) / r.height;
};
const onLeave = () => {
pointer.tx = 0;
pointer.ty = 0;
};
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerleave", onLeave);
let raf = 0;
let last = 0;
let time = animate ? 0 : 1.9;
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
time += dt;
const want = Math.max(2, Math.round(cfg.folds));
if (want !== cols) {
cols = want;
build();
}
pointer.x += (pointer.tx - pointer.x) * 0.06;
pointer.y += (pointer.ty - pointer.y) * 0.06;
sheet.rotation.set(pointer.y * cfg.tilt * 0.6, pointer.x * cfg.tilt * 0.9 + Math.sin(time * 0.35) * 0.12, 0);
shape(time);
(material.uniforms.uPaper!.value as THREE.Vector3).set(...PAPERS[cfg.paper]);
material.uniforms.uContrast!.value = cfg.contrast;
material.uniforms.uHasMap!.value = map ? 1 : 0;
renderer.render(scene, camera);
}
const loop = (now: number) => {
frame(now);
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || !animate) return;
last = 0;
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(() => {
resize();
frame();
});
ro.observe(canvas);
const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
io.observe(canvas);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
const onLost = (e: Event) => {
e.preventDefault();
stop();
};
canvas.addEventListener("webglcontextlost", onLost);
frame();
start();
return {
set(next) {
// undefined в пропсах React — «не задано», а не «сбросить»
const patch = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined)) as Partial<PaperFoldOptions>;
Object.assign(cfg, patch);
if (!animate) frame();
},
destroy() {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerleave", onLeave);
canvas.removeEventListener("webglcontextlost", onLost);
geometry.dispose();
material.dispose();
map?.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}