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

Keyhole Reveal

Сплошная шторка поверх содержимого с вырезанным окном: прокрутка раздвигает окно, пока шторка не исчезнет. Дырка живёт в форме clip-path-полигона — один элемент, одно свойство.

cssscrollrevealintermediate0reduced-motion ✓
↕ демо длиннее кадра — прокрутите его
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
children?ReactNode
color?string"var(--ds-accent, oklch(0.72 0.16 300))"Цвет шторки. По умолчанию — акцент дизайн-системы.
start?number18Полуширина окошка в начале, % от кадра: 6 — щёлка, 42 — почти всё открыто.
className?string
style?CSSProperties

Установка

npx shadcn@latest add https://motiva.pages.dev/r/keyhole-reveal.json
Что нужно в проекте до установки
  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 { useId, type CSSProperties, type ReactNode } from "react";

export interface KeyholeRevealProps {
  children?: ReactNode;
  /** Цвет шторки. По умолчанию — акцент дизайн-системы. */
  color?: string;
  /** Полуширина окошка в начале, % от кадра: 6 — щёлка, 42 — почти всё открыто. */
  start?: number;
  className?: string;
  style?: CSSProperties;
}

/**
 * Шторка с «замочной скважиной»: сплошной слой поверх содержимого, в котором вырезано
 * окно. Прокрутка раздвигает окно, пока от шторки не остаётся ничего.
 *
 * Приём целиком в ФОРМЕ полигона clip-path. Дырка в сплошной заливке делается так:
 * внешний прямоугольник обходится в одну сторону, внутренний — в обратную, и оба контура
 * сшиваются «швом» нулевой ширины по левому краю. Внутренний контур и становится дыркой.
 * Поэтому нужен ОДИН элемент и ОДНО анимируемое свойство: ни второго слоя с
 * mix-blend-mode, ни маски-картинки.
 *
 * Порядок точек (10 штук) менять нельзя — от него зависит, что окажется дыркой:
 * левый край вниз, шов на x = 0, внутренний контур, шов обратно, правый край вверх.
 * Число точек в обоих кадрах ОДИНАКОВОЕ, иначе браузеру нечего интерполировать.
 *
 * Прогресс ведёт CSS-таймлайн, а не скрипт. Так вышло не из вкусовых соображений:
 * у записи нет состояния между кадрами, ей нужна ровно доля прокрутки, — а скриптовый
 * вариант держит цикл кадров живым и в покое (замер с ScrollTrigger: 75 вызовов rAF
 * за 0.6 с при неподвижной странице, и так все 3.6 с). Здесь в покое не работает ничего.
 * Попутно снялась и вторая беда скриптового пути: GSAP не интерполирует две строки
 * polygon(...), а МЕНЯЕТ их одним скачком в конце (замер по времени: 32% на 200, 600
 * и 1200 мс и сразу 0/100% на 2000). Браузер два полигона с равным числом точек
 * интерполирует сам.
 *
 * Именно scroll(), а не view(): шторка position: fixed и по экрану не едет, поэтому её
 * собственное «прохождение через кадр» всегда одинаково — нужен прогресс САМОЙ прокрутки.
 *
 * Начальное состояние объявлено СНАРУЖИ @supports: где прокруточных таймлайнов нет
 * и при prefers-reduced-motion окно сразу во весь кадр, то есть шторки не видно вовсе.
 * Обратный порядок (спрятать снаружи, открывать внутри) навсегда закрыл бы содержимое
 * тем, кто анимаций не получает.
 */
export function KeyholeReveal({
  children,
  color = "var(--ds-accent, oklch(0.72 0.16 300))",
  start = 18,
  className,
  style,
}: KeyholeRevealProps) {
  const id = useId().replace(/[^a-zA-Z0-9]/g, "");
  const a = `${(50 - start).toFixed(2)}%`;
  const b = `${(50 + start).toFixed(2)}%`;
  const open =
    "polygon(0% 0%, 0% 100%, 0% 100%, 0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 100%, 100% 100%, 100% 0%)";

  return (
    <div className={className} style={{ position: "relative", ...style }} data-keyhole={id}>
      <style>{`
        [data-keyhole="${id}"] > .kh-curtain {
          position: fixed;
          inset: 0;
          z-index: 1;
          pointer-events: none;
          background: ${color};
          /* окно во весь кадр: шторки не видно — безопасное состояние по умолчанию */
          clip-path: ${open};
        }

        @keyframes kh-open-${id} {
          from { clip-path: polygon(0% 0%, 0% 100%, ${a} 100%, ${a} ${a}, ${b} ${a}, ${b} ${b}, ${a} ${b}, ${a} 100%, 100% 100%, 100% 0%); }
          to   { clip-path: ${open}; }
        }

        @supports (animation-timeline: scroll()) {
          @media (prefers-reduced-motion: no-preference) {
            [data-keyhole="${id}"] > .kh-curtain {
              animation: kh-open-${id} linear both;
              animation-timeline: scroll();
            }
          }
        }

        /* статичный режим для снимков: шторки нет */
        [data-shot] [data-keyhole="${id}"] > .kh-curtain { animation: none; }
      `}</style>
      {children}
      <div className="kh-curtain" aria-hidden="true" />
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: адаптация идеи пена «ScrollTrigger keyhole animation» (Natalia, https://codepen.io/natszafraniec/pen/oNJxgLz, MIT); код Motivav1.0.0