Clip Reveal
Блок проявляется через раскрывающийся вырез, пока входит в кадр: снизу вверх, из середины, шторкой или по диагонали. Прогресс ведёт прокруточный таймлайн — ни скрипта, ни наблюдателя, в покое не работает ничего.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | ClipRevealItem[] | DEFAULT_ITEMS |
ClipRevealItem — что внутри
- title: string
- text: string
- img?: string
- shape?: ClipRevealShape— Форма выреза: снизу вверх · из середины · шторкой от краёв · по диагонали.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/clip-reveal.jsonЧто нужно в проекте до установки
Файл
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.
Экспорт
Код
/* Появление через клип: форма выреза раскрывается, пока блок входит в кадр.
Прогресс ведёт САМ БРАУЗЕР по прокрутке (`animation-timeline: view()`), а не скрипт.
Разница не вкусовая: скриптовый вариант держит цикл кадров живым и в покое, а тут
в покое не работает ничего — таймлайн у анимации не временной, а прокруточный.
Порядок объявлений — главная грабля записи. ОТКРЫТОЕ состояние стоит СНАРУЖИ
`@supports`, закрытое — только внутри. Наоборот было бы нельзя: там, где
прокруточных таймлайнов нет (и при `prefers-reduced-motion`), содержимое осталось бы
закрытым НАВСЕГДА — эффект превратился бы в «текста нет». */
.clr {
width: 100%;
font-family: var(--ds-font, system-ui, -apple-system, sans-serif);
color: var(--ds-fg, oklch(0.92 0.01 265));
}
.clr-list {
display: grid;
gap: var(--ds-6, 32px);
max-width: 640px;
margin: 0 auto;
padding: 0;
list-style: none;
}
/* Безопасное умолчание: открыто. */
.clr-item {
position: relative;
clip-path: inset(0 0 0 0);
border-radius: var(--ds-radius, 14px);
overflow: hidden;
background: var(--ds-surface, oklch(0.17 0.02 270));
border: 1px solid var(--ds-border, oklch(0.3 0.02 270));
}
.clr-img {
display: block;
width: 100%;
height: 190px;
object-fit: cover;
}
.clr-body {
display: grid;
gap: 4px;
padding: 14px 16px 16px;
}
.clr-title {
font-size: var(--ds-text-md, 1rem);
font-weight: 640;
letter-spacing: var(--ds-tracking-tight, -0.02em);
}
.clr-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));
}
.clr-shape {
font-family: var(--ds-font-mono, ui-monospace, Menlo, monospace);
font-size: 0.72rem;
color: var(--ds-accent, oklch(0.72 0.16 300));
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.clr-item {
/* субъект таймлайна — сам блок: «его собственное прохождение через кадр».
Именно view(), а не scroll(): нужен прогресс ЭТОГО блока, а не страницы. */
view-timeline-name: --clr-view;
view-timeline-axis: block;
animation: clr-up linear both;
animation-timeline: --clr-view;
/* Диапазон меряется по COVER, а не по ENTRY: entry заканчивается, когда блок
ЦЕЛИКОМ вошёл в кадр, то есть у нижней кромки — раскрытие успевало отработать
внизу экрана и до центра доезжало уже открытым. cover 48% — это момент, когда
центр блока совпадает с центром кадра: приём срабатывает там, куда смотрят. */
animation-range: var(--clr-from, cover 18%) var(--clr-to, cover 48%);
}
.clr-item[data-shape="center"] {
animation-name: clr-center;
}
.clr-item[data-shape="curtain"] {
animation-name: clr-curtain;
}
.clr-item[data-shape="diagonal"] {
animation-name: clr-diagonal;
}
}
}
/* В каждой паре кадров ОДНА И ТА ЖЕ функция и одинаковое число точек — иначе
интерполировать нечего и переход выглядит как рывок в конце. */
@keyframes clr-up {
from {
clip-path: inset(100% 0 0 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
@keyframes clr-center {
from {
clip-path: inset(50% 0 50% 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
@keyframes clr-curtain {
from {
clip-path: inset(0 50% 0 50%);
}
to {
clip-path: inset(0 0 0 0);
}
}
@keyframes clr-diagonal {
from {
clip-path: polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%);
}
to {
clip-path: polygon(0% 0%, 140% 0%, 100% 100%, 0% 100%);
}
}
html[data-shot] .clr-item {
animation: none;
clip-path: inset(0 0 0 0);
}
const css = `/* Появление через клип: форма выреза раскрывается, пока блок входит в кадр.
Прогресс ведёт САМ БРАУЗЕР по прокрутке (\`animation-timeline: view()\`), а не скрипт.
Разница не вкусовая: скриптовый вариант держит цикл кадров живым и в покое, а тут
в покое не работает ничего — таймлайн у анимации не временной, а прокруточный.
Порядок объявлений — главная грабля записи. ОТКРЫТОЕ состояние стоит СНАРУЖИ
\`@supports\`, закрытое — только внутри. Наоборот было бы нельзя: там, где
прокруточных таймлайнов нет (и при \`prefers-reduced-motion\`), содержимое осталось бы
закрытым НАВСЕГДА — эффект превратился бы в «текста нет». */
.clr {
width: 100%;
font-family: var(--ds-font, system-ui, -apple-system, sans-serif);
color: var(--ds-fg, oklch(0.92 0.01 265));
}
.clr-list {
display: grid;
gap: var(--ds-6, 32px);
max-width: 640px;
margin: 0 auto;
padding: 0;
list-style: none;
}
/* Безопасное умолчание: открыто. */
.clr-item {
position: relative;
clip-path: inset(0 0 0 0);
border-radius: var(--ds-radius, 14px);
overflow: hidden;
background: var(--ds-surface, oklch(0.17 0.02 270));
border: 1px solid var(--ds-border, oklch(0.3 0.02 270));
}
.clr-img {
display: block;
width: 100%;
height: 190px;
object-fit: cover;
}
.clr-body {
display: grid;
gap: 4px;
padding: 14px 16px 16px;
}
.clr-title {
font-size: var(--ds-text-md, 1rem);
font-weight: 640;
letter-spacing: var(--ds-tracking-tight, -0.02em);
}
.clr-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));
}
.clr-shape {
font-family: var(--ds-font-mono, ui-monospace, Menlo, monospace);
font-size: 0.72rem;
color: var(--ds-accent, oklch(0.72 0.16 300));
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.clr-item {
/* субъект таймлайна — сам блок: «его собственное прохождение через кадр».
Именно view(), а не scroll(): нужен прогресс ЭТОГО блока, а не страницы. */
view-timeline-name: --clr-view;
view-timeline-axis: block;
animation: clr-up linear both;
animation-timeline: --clr-view;
/* Диапазон меряется по COVER, а не по ENTRY: entry заканчивается, когда блок
ЦЕЛИКОМ вошёл в кадр, то есть у нижней кромки — раскрытие успевало отработать
внизу экрана и до центра доезжало уже открытым. cover 48% — это момент, когда
центр блока совпадает с центром кадра: приём срабатывает там, куда смотрят. */
animation-range: var(--clr-from, cover 18%) var(--clr-to, cover 48%);
}
.clr-item[data-shape="center"] {
animation-name: clr-center;
}
.clr-item[data-shape="curtain"] {
animation-name: clr-curtain;
}
.clr-item[data-shape="diagonal"] {
animation-name: clr-diagonal;
}
}
}
/* В каждой паре кадров ОДНА И ТА ЖЕ функция и одинаковое число точек — иначе
интерполировать нечего и переход выглядит как рывок в конце. */
@keyframes clr-up {
from {
clip-path: inset(100% 0 0 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
@keyframes clr-center {
from {
clip-path: inset(50% 0 50% 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
@keyframes clr-curtain {
from {
clip-path: inset(0 50% 0 50%);
}
to {
clip-path: inset(0 0 0 0);
}
}
@keyframes clr-diagonal {
from {
clip-path: polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%);
}
to {
clip-path: polygon(0% 0%, 140% 0%, 100% 100%, 0% 100%);
}
}
html[data-shot] .clr-item {
animation: none;
clip-path: inset(0 0 0 0);
}
`;
export type ClipRevealShape = "up" | "center" | "curtain" | "diagonal";
export interface ClipRevealItem {
title: string;
text: string;
img?: string;
/** Форма выреза: снизу вверх · из середины · шторкой от краёв · по диагонали. */
shape?: ClipRevealShape;
}
export interface ClipRevealProps {
items?: ClipRevealItem[];
}
const DEFAULT_ITEMS: ClipRevealItem[] = [
{ title: "Снизу вверх", text: "Классический вайп: вырез растёт от нижней кромки. Читается как «поднялось из-под линии».", img: "https://motiva.pages.dev/img/1015-900x700.jpg", shape: "up" },
{ title: "Из середины", text: "Вырез раскрывается в обе стороны от центра. Спокойнее вайпа: у кадра нет ведущего края.", img: "https://motiva.pages.dev/img/1043-900x675.jpg", shape: "center" },
{ title: "Шторкой", text: "То же самое, но по горизонтали — вырез расходится от вертикальной оси к краям.", img: "https://motiva.pages.dev/img/1074-900x675.jpg", shape: "curtain" },
{ title: "По диагонали", text: "Полигон из четырёх точек: правая кромка идёт наклонно. Число точек в обоих кадрах одинаковое, иначе интерполировать нечего.", img: "https://motiva.pages.dev/img/1057-900x675.jpg", shape: "diagonal" },
];
/**
* Появление через клип: форма выреза раскрывается, пока блок входит в кадр.
*
* Ноль JS и ноль наблюдателей: прогресс берётся из `animation-timeline: view()`.
* Открытое состояние объявлено СНАРУЖИ `@supports` — см. разбор в CSS: обратный
* порядок навсегда прятал бы содержимое там, где таймлайнов нет.
*/
export function ClipReveal({ items = DEFAULT_ITEMS }: ClipRevealProps) {
return (
<div className="clr">
<style href="motiva-clip-reveal" precedence="medium">{css}</style>
<ul className="clr-list">
{items.map((it) => (
<li className="clr-item" key={it.title} data-shape={it.shape ?? "up"}>
{it.img ? <img className="clr-img" src={it.img} alt="" loading="lazy" decoding="async" /> : null}
<div className="clr-body">
<span className="clr-title">
{it.title} <span className="clr-shape">{it.shape ?? "up"}</span>
</span>
<p className="clr-text">{it.text}</p>
</div>
</li>
))}
</ul>
</div>
);
}