← каталог
Keyhole Reveal
Сплошная шторка поверх содержимого с вырезанным окном: прокрутка раздвигает окно, пока шторка не исчезнет. Дырка живёт в форме clip-path-полигона — один элемент, одно свойство.
cssscrollrevealintermediate0reduced-motion ✓
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| children? | ReactNode | — | |
| color? | string | "var(--ds-accent, oklch(0.72 0.16 300))" | Цвет шторки. По умолчанию — акцент дизайн-системы. |
| start? | number | 18 | Полуширина окошка в начале, % от кадра: 6 — щёлка, 42 — почти всё открыто. |
| className? | string | — | |
| style? | CSSProperties | — |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/keyhole-reveal.jsonЧто нужно в проекте до установки
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 { 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