Text Mask Media
Текст как окно: сквозь буквы видно фотографию или живое видео. Фото — background-clip: text, и снимок внутри букв едет за прокруткой (view()); видео клипом текста не вырезается, поэтому вычитающая маска — чёрный слой с белым текстом и mix-blend-mode: multiply поверх ролика: чёрное закрывает наглухо, глифы пропускают.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| text? | string | "СКВОЗЬ" | Текст-окно. Чем жирнее и крупнее, тем больше медиа видно. |
| media? | "photo" | "video" | "photo" | Что в буквах: снимок или видео. |
| photoSrc? | string | "https://motiva.pages.dev/img/1015-1600x… | Снимок для фото-режима (и постер для видео-режима). |
| videoSrc? | string | "https://motiva.pages.dev/video/smoke-12… | Ролик для видео-режима. |
| fontSize? | string | "clamp(3rem, 16cqw, 8rem)" | Кегль: любое CSS-значение font-size. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/text-mask-media.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.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
const css = `/* ФОТО-режим: снимок вырезан по глифам. Заголовок и есть окно в картинку */
.tmm-photo {
margin: 0;
color: transparent;
background-size: cover;
background-position: 50% 50%;
-webkit-background-clip: text;
background-clip: text;
}
@supports (animation-timeline: view()) {
/* фон внутри букв едет за прокруткой — окно «живое», а не наклейка.
база (50% 50%) объявлена ВЫШЕ, вне @supports: без поддержки просто статичное окно */
.tmm-photo {
animation: tmm-drift linear both;
animation-timeline: view();
}
@keyframes tmm-drift {
from { background-position: 50% 28%; }
to { background-position: 50% 72%; }
}
}
/* ВИДЕО-режим: вычитающая маска. Слой поверх видео чёрный с белым текстом,
multiply умножает: чёрное закрывает видео наглухо, белые глифы пропускают его.
Чистые #000/#fff тут — операнды формулы, как у difference, а не палитра */
.tmm-vid {
position: relative;
overflow: clip;
border-radius: 18px;
background: #000;
}
.tmm-vid > video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
/* ролик-фактура обычно тёмный, а читаемость букв = яркость видео в них.
Фильтр СТАТИЧЕН (не анимируется) — это постоянная цена композитора, не перерисовка */
filter: brightness(1.75) contrast(1.05);
}
.tmm-cover {
position: relative;
display: grid;
place-items: center;
min-height: inherit;
background: #000;
color: #fff;
mix-blend-mode: multiply;
}
.tmm-cover > h2 { margin: 0; }
@media (prefers-reduced-motion: reduce) {
.tmm-photo { animation: none; }
}
html[data-shot] .tmm-photo { animation: none; }
`;
export interface TextMaskMediaProps {
/** Текст-окно. Чем жирнее и крупнее, тем больше медиа видно. */
text?: string;
/** Что в буквах: снимок или видео. */
media?: "photo" | "video";
/** Снимок для фото-режима (и постер для видео-режима). */
photoSrc?: string;
/** Ролик для видео-режима. */
videoSrc?: string;
/** Кегль: любое CSS-значение font-size. */
fontSize?: string;
}
/**
* Текст как окно: сквозь буквы видно фотографию или живое видео.
*
* Два режима — две разные техники, и обе без скриптов по существу:
*
* - **фото** — `background-clip: text`: снимок вырезан по глифам, а внутри букв
* он ещё и едет за прокруткой (`background-position` на `animation-timeline:
* view()`) — окно живое, а не наклейка;
* - **видео** — `background-clip` с видео не умеет, поэтому вычитающая маска:
* поверх ролика лежит ЧЁРНЫЙ слой с БЕЛЫМ текстом и `mix-blend-mode: multiply` —
* чёрное умножением закрывает видео наглухо, белые глифы пропускают его.
* Чистые #000/#fff здесь операнды формулы (как у difference), а не палитра;
* приёму нужна тёмная сцена — вокруг маски честный чёрный.
*
* Видео стартует ИЗ ЭФФЕКТА и только когда движение уместно: `prefers-reduced-motion`
* и `data-shot` оставляют постер — атрибутом `autoPlay` этого не добиться, он
* сработал бы до первой проверки.
*/
export function TextMaskMedia({
text = "СКВОЗЬ",
media = "photo",
photoSrc = "https://motiva.pages.dev/img/1015-1600x1000.jpg",
videoSrc = "https://motiva.pages.dev/video/smoke-1280x720.mp4",
fontSize = "clamp(3rem, 16cqw, 8rem)",
}: TextMaskMediaProps = {}) {
const video = useRef<HTMLVideoElement>(null);
useEffect(() => {
const v = video.current;
if (!v) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return;
v.play().catch(() => {});
return () => v.pause();
}, [media]);
const font: CSSProperties = {
font: `900 ${fontSize}/1 system-ui, sans-serif`,
letterSpacing: "-0.03em",
textAlign: "center",
};
if (media === "video") {
return (
<div className="tmm-vid" style={{ minHeight: "min(46cqh, 320px)" }}>
<style href="motiva-text-mask-media" precedence="medium">{css}</style>
<video ref={video} src={videoSrc} poster={photoSrc} muted loop playsInline preload="metadata" />
<div className="tmm-cover" style={{ minHeight: "min(46cqh, 320px)" }}>
<h2 style={font}>{text}</h2>
</div>
</div>
);
}
return (
<h2 className="tmm-photo" style={{ ...font, backgroundImage: `url(${photoSrc})` }}>
<style href="motiva-text-mask-media" precedence="medium">{css}</style>
{text}
</h2>
);
}