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

Text Mask Media

Текст как окно: сквозь буквы видно фотографию или живое видео. Фото — background-clip: text, и снимок внутри букв едет за прокруткой (view()); видео клипом текста не вырезается, поэтому вычитающая маска — чёрный слой с белым текстом и mix-blend-mode: multiply поверх ролика: чёрное закрывает наглухо, глифы пропускают.

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

Пропсы

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

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