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

Clip Wipe

Кадр раскрывается вайпом по вырезу и одновременно съезжает с зума. Прокрутка тут только спусковой крючок: приём проигрывается один раз своим временем и своей кривой, а не тянется вместе с колесом.

gsapscrollclickrevealintermediate~23 КБreduced-motion ✓
↗
↕ демо длиннее кадра — прокрутите его

Пропсы

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

ПропТипПо умолчаниюЧто это
items?ClipWipeItem[]DEFAULT_ITEMS
duration?number1.1Длительность вайпа, с.
zoom?number1.3Во сколько раз картинка крупнее в начале.
replay?booleantrueКнопка «проиграть заново» под списком.
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

Что нужно в проекте до установки
  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 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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0