Clip Wipe
Кадр раскрывается вайпом по вырезу и одновременно съезжает с зума. Прокрутка тут только спусковой крючок: приём проигрывается один раз своим временем и своей кривой, а не тянется вместе с колесом.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | ClipWipeItem[] | DEFAULT_ITEMS | |
| duration? | number | 1.1 | Длительность вайпа, с. |
| zoom? | number | 1.3 | Во сколько раз картинка крупнее в начале. |
| replay? | boolean | true | Кнопка «проиграть заново» под списком. |
ClipWipeItem — что внутри
- title: string
- text: string
- img: string
- dir?: ClipWipeDir
Установка
npx shadcn@latest add https://motiva.pages.dev/r/clip-wipe.jsonзависимости: gsap@^3.15.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 gsap from "gsap";
const css = `/* Вайп по вырезу: проигрывается ОДИН раз при входе в кадр, своим временем. */
.clw {
width: 100%;
font-family: var(--ds-font, system-ui, -apple-system, sans-serif);
color: var(--ds-fg, oklch(0.92 0.01 265));
}
.clw-list {
display: grid;
gap: var(--ds-6, 32px);
max-width: 640px;
margin: 0 auto;
padding: 0;
list-style: none;
}
.clw-item {
position: relative;
border-radius: var(--ds-radius, 14px);
border: 1px solid var(--ds-border, oklch(0.3 0.02 270));
background: var(--ds-surface, oklch(0.17 0.02 270));
/* клип режет по своей коробке — рамка и радиус остаются на месте */
overflow: hidden;
}
/* Кадр картинки: он и есть клипуемый элемент. Своя коробка нужна потому, что
картинка внутри ещё и масштабируется — два трансформа на одном узле спорят. */
.clw-frame {
position: relative;
display: block;
overflow: hidden;
}
.clw-img {
display: block;
width: 100%;
height: 190px;
object-fit: cover;
will-change: transform;
}
.clw-body {
display: grid;
gap: 4px;
padding: 14px 16px 16px;
}
.clw-title {
font-size: var(--ds-text-md, 1rem);
font-weight: 640;
letter-spacing: var(--ds-tracking-tight, -0.02em);
}
.clw-dir {
font-family: var(--ds-font-mono, ui-monospace, Menlo, monospace);
font-size: 0.72rem;
color: var(--ds-accent, oklch(0.72 0.16 300));
}
.clw-text {
margin: 0;
font-size: var(--ds-text-sm, 0.85rem);
line-height: 1.5;
color: var(--ds-muted, oklch(0.7 0.02 265));
}
.clw-replay {
display: block;
margin: var(--ds-4, 18px) auto 0;
padding: 7px 14px;
border: 1px solid var(--ds-border, oklch(0.3 0.02 270));
border-radius: 999px;
background: transparent;
color: var(--ds-muted, oklch(0.7 0.02 265));
font-family: inherit;
font-size: var(--ds-text-sm, 0.82rem);
cursor: pointer;
}
.clw-replay:hover {
color: var(--ds-fg, oklch(0.92 0.01 265));
border-color: var(--ds-accent, oklch(0.72 0.16 300));
}
`;
export type ClipWipeDir = "left" | "right" | "top" | "bottom" | "center" | "circle";
export interface ClipWipeItem {
title: string;
text: string;
img: string;
dir?: ClipWipeDir;
}
export interface ClipWipeProps {
items?: ClipWipeItem[];
/** Длительность вайпа, с. */
duration?: number;
/** Во сколько раз картинка крупнее в начале. */
zoom?: number;
/** Кнопка «проиграть заново» под списком. */
replay?: boolean;
}
const DEFAULT_ITEMS: ClipWipeItem[] = [
{ title: "Слева направо", text: "Вырез уходит вправо, картинка одновременно съезжает с зума.", img: "https://motiva.pages.dev/img/1015-900x700.jpg", dir: "left" },
{ title: "Справа налево", text: "То же самое зеркально: остаток инсета снимается с левой кромки.", img: "https://motiva.pages.dev/img/1043-900x675.jpg", dir: "right" },
{ title: "Из центра", text: "Инсет снимается со всех четырёх сторон сразу — кадр раскрывается коробкой.", img: "https://motiva.pages.dev/img/1074-900x675.jpg", dir: "center" },
{ title: "Кругом", text: "Круглый вырез растёт из середины до 72% — единственная форма не на inset().", img: "https://motiva.pages.dev/img/1057-900x675.jpg", dir: "circle" },
];
/**
* Вайп по вырезу: кадр раскрывается формой клипа и одновременно съезжает с зума.
*
* Отличие от `clip-reveal` принципиальное: там прогресс ПРИВЯЗАН к прокрутке и идёт вместе
* с ней, здесь прокрутка только СПУСКОВОЙ КРЮЧОК — дальше анимация живёт своим временем
* и своей кривой, один раз. Поэтому и ощущается иначе: у привязанного к прокрутке приёма
* скорость задаёт колесо, у этого — `power4.inOut`.
*
* Спусковой крючок — `IntersectionObserver`, а не ScrollTrigger. В источнике стоял он,
* но ScrollTrigger держит цикл кадров живым и в ПОКОЕ: замер даёт 75 вызовов rAF
* за 0.6 с на неподвижной странице, и так все 3.6 с. Наблюдатель в покое не стоит ничего,
* а нужен он ровно на один раз. Порог тот же, что был у `start: "top 82%"`: нижняя кромка
* корня поднята на 18%, поэтому пересечение начинается, когда верх блока прошёл 82% кадра.
*
* Строка `clip-path` собирается ИЗ ЧИСЛА, а не интерполируется как строка: GSAP плохо
* тянет `inset()` со смешанными `%`/`0`, и два таких кадра он подменяет рывком в конце
* вместо перехода. Анимируется один скаляр `p` (0 — скрыто, 1 — раскрыто), а форма
* пересобирается на каждом кадре — так любое направление плавное гарантированно.
*/
const clipAt = (dir: ClipWipeDir, p: number): string => {
const h = `${(100 - p * 100).toFixed(2)}%`; // «остаток» инсета
switch (dir) {
case "right": return `inset(0% 0% 0% ${h})`;
case "top": return `inset(0% 0% ${h} 0%)`;
case "bottom": return `inset(${h} 0% 0% 0%)`;
case "center": { const e = `${((1 - p) * 50).toFixed(2)}%`; return `inset(${e} ${e} ${e} ${e})`; }
case "circle": return `circle(${(p * 72).toFixed(2)}% at 50% 50%)`;
default: return `inset(0% ${h} 0% 0%)`; // left
}
};
export function ClipWipe({ items = DEFAULT_ITEMS, duration = 1.1, zoom = 1.3, replay = true }: ClipWipeProps) {
const rootRef = useRef<HTMLDivElement>(null);
const playAll = useRef<() => void>(() => {});
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const reduced =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const fire = new Map<Element, () => void>();
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
io.unobserve(e.target); // один раз, как `once` у источника
fire.get(e.target)?.();
}
},
{ rootMargin: "0px 0px -18% 0px" },
);
const ctx = gsap.context(() => {
const plays: Array<() => void> = [];
root.querySelectorAll<HTMLElement>("[data-clip]").forEach((frame) => {
const dir = (frame.dataset.clip || "left") as ClipWipeDir;
const img = frame.querySelector<HTMLImageElement>("img");
const o = { p: 0 }; // один живущий объект прогресса на элемент
const setP = (p: number) => { frame.style.clipPath = clipAt(dir, p); };
const play = () => {
gsap.killTweensOf(o);
if (img) gsap.killTweensOf(img);
o.p = 0;
setP(0);
if (img) gsap.set(img, { scale: zoom });
gsap.to(o, { p: 1, duration, ease: "power4.inOut", onUpdate: () => setP(o.p) });
if (img) gsap.to(img, { scale: 1, duration: duration + 0.3, ease: "power3.out" });
};
plays.push(play);
if (reduced) {
setP(1);
if (img) gsap.set(img, { scale: 1 });
} else {
setP(0);
if (img) gsap.set(img, { scale: zoom });
io.observe(frame); // прокрутка — только крючок, дальше анимация живёт сама
fire.set(frame, play);
}
});
playAll.current = () => plays.forEach((p) => p());
}, root);
return () => {
io.disconnect();
ctx.revert();
};
}, [duration, zoom, items]);
return (
<div className="clw" ref={rootRef}>
<style href="motiva-clip-wipe" precedence="medium">{css}</style>
<ul className="clw-list">
{items.map((it) => (
<li className="clw-item" key={it.title}>
<span className="clw-frame" data-clip={it.dir ?? "left"}>
<img className="clw-img" src={it.img} alt="" loading="lazy" decoding="async" />
</span>
<div className="clw-body">
<span className="clw-title">
{it.title} <span className="clw-dir">{it.dir ?? "left"}</span>
</span>
<p className="clw-text">{it.text}</p>
</div>
</li>
))}
</ul>
{replay ? (
<button type="button" className="clw-replay" onClick={() => playAll.current()}>
↻ проиграть заново
</button>
) : null}
</div>
);
}