SVGFree

Text Mask Video

A real video that only shows through the letters of a heading: the text stays text, the video plays inside it, the block melts into a dark page.

CSSTextGradient

Updated

PLAY

Hover or click the scene to interact

This effect is free — the SVG category contains 30 effects total, including 3 free. Effect.Labs has 840 vanilla effects. Explore the category →

Usage examples

DJ · house, disco, edits — based in Lyon

VELMORAY

Next dates

Fri 14 NovNantes · club11 pm
Sat 22 NovLille · festival7 pm
Sat 6 DecGeneva · rooftop9 pm
Have a date in mind?

Clubs, festivals, private events. Reply within 48 h.

① DJ name at the top of a booking page

WhenA DJ's booking page: “VELMORAY” in very large type under the navigation, upcoming dates at the bottom left and a “Have a date in mind?” box with the booking button.
WhyThe name is the first thing a promoter reads; filled with footage from a set, it conveys the mood without adding a video player to the page. The block melts into the site's black: only the letters show.
Settings--tmv-size:70px, --tmv-page:transparent, --tmv-height:0px; demo clip in --tmv-c1:#ff5a36, --tmv-c2:#ff2e88, --tmv-c3:#ffb347, --tmv-c4:#8b3dff; heading as <h1 class="tmv-text">. In production: the src of a set excerpt, without data-tmv-demo.
G

Grimolte

12.4k followers · platform games

LIVE

HomeVideosLiveAbout
2 h 10 in

Sunday speedrun — every world in one go

1,284 viewers · chat open

② “LIVE” label on a streaming channel page

WhenThe “Grimolte” channel header during a stream: avatar, name, followers, and on the right a red dot followed by “LIVE” at 22 px, filled with the stream picture. Below, the tabs and the current stream card with its “Watch” button.
WhyA label that moves with the stream says “happening now” better than static red text. At this size the video is just a colour texture: the word stays readable.
Settings--tmv-size:22px; green and cyan hues --tmv-c1:#10b981, --tmv-c2:#22d3ee, --tmv-c3:#a3e635, --tmv-c4:#3b82f6; text as <p class="tmv-text">. In production the video can receive the live feed through srcObject.
Thursday film club November programme

Hover or focus a poster: the trailer plays inside its title.

Thursdays 8.30 pm · Room 2Free entry

③ Trailer inside the title of the hovered poster — film club

WhenA film club's November programme: three posters side by side. At rest each title shows a still from its film; on hover or keyboard focus of a poster, its trailer plays inside the letters and stops when you leave it.
WhyThree videos running at once would compete for attention; one at a time, under the pointer, gives a preview without opening a player. The posters are real links: keyboard focus triggers the same preview.
Settings--tmv-size:20px, three colour sets; videos without autoplay. The scene script calls play() then pause() on first display (still frame), play() on pointerenter/focus and pause() on pointerleave/blur. Under reduced motion the effect freezes the video again: the title keeps its still.

How it works

The .tmv-stage block stacks three layers: a fallback gradient, the <video class="tmv-video"> covering the whole block (object-fit:cover), then the .tmv-text heading written in white on black. The heading is blended with mix-blend-mode:multiply: white lets the video through untouched, black switches it off. The result: the video is only visible inside the letter shapes.

The whole block is then blended with the page in mix-blend-mode:screen, where black turns transparent. The rectangle around the letters vanishes on any dark background, even a photo, without copying the page colour. The tmv-light class swaps both modes for a light page (black text, white disappears).

You put your video in the HTML: remove data-tmv-demo and add src="your-video.mp4" (or WebM + MP4 <source> tags). While data-tmv-demo is present and no source is given, the script builds a local demo clip: an animated 480 × 270 canvas (colour blobs and light beams in --tmv-c1 to --tmv-c4) piped into the video with captureStream(). No file is loaded.

The fallback gradient fills the letters while the video has no frame yet (slow loading) and for good if it fails: missing file or rejected format, the script sets tmv-failed and hides the video, so the heading stays readable. As soon as a frame is shown, the tmv-playing class stops the gradient. Off screen the video pauses; an autoplaying one resumes when it scrolls back in.

Accessibility

  • Real text: the heading is a normal text element (<h2> by default, any block with the tmv-text class): read by screen readers, selectable, translated by the browser. The video has aria-hidden="true" and tabindex="-1": it is decorative and stays out of the keyboard path.
  • Reduced motion: under prefers-reduced-motion: reduce, the script lets the video show its first frame, then pauses it; the fallback gradient stops flowing. The setting is followed live. Without JavaScript only the gradient stops: an autoplay video would keep running.
  • More than 5 seconds of motion: a looping video that starts on its own must be stoppable (WCAG 2.2.2). If the heading stays on screen for long, add a real <button> that calls pause() on the video.
  • Legibility: the letters take the video's colours. A very dark clip gives dark letters on a dark page: pick a bright shot, a heavy font, or switch to the light variant.

Browser compatibility

mix-blend-mode (multiply and screen), HTML5 video, inset, IntersectionObserver; for the demo clip only: HTMLCanvasElement.captureStream() and srcObject. Measured in Chromium, Firefox and WebKit (Playwright): real MP4/WebM video in the letters, demo clip, missing video (gradient fallback), light variant, two copies, reduced motion, 390 px. The test WebKit refuses any autoplay without a gesture, even muted: playback was checked after a click; Safari does allow muted autoplay for videos with playsinline.

Chrome 87+✓ Full (measured)
Firefox 66+✓ Full (measured)
Safari 14.1+✓ Rendering measured in WebKit; autoplay not verified
Edge 87+✓ Full (Chromium engine)
Safari iOS 14.5+✓ WebKit engine, not verified on a real device
Chrome Android 87+✓ Full (Chromium engine)

Video that fails to load, unsupported format or autoplay blocked (Low Power Mode on iPhone): the letters keep the animated gradient or the first frame, and the heading stays readable. Without JavaScript your video plays normally; only the demo clip, the off-screen pause and the reduced-motion stop are missing.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="fx-0619-root">
  <div class="tmv-stage">
    <video class="tmv-video" data-tmv-demo autoplay muted loop playsinline aria-hidden="true" tabindex="-1"></video>
    <h2 class="tmv-text">PLAY</h2>
  </div>
</div>
CSS
.fx-0619-root,
.fx-0619-root *,
.fx-0619-root *::before,
.fx-0619-root *::after {
  box-sizing: border-box;
}

.fx-0619-root {
  /* Settings: text size, block background, colours of the fallback and the demo clip */
  --tmv-size: 4rem;
  --tmv-page: #0a0a0f;
  --tmv-height: 250px;
  --tmv-c1: #6366f1;
  --tmv-c2: #d946ef;
  --tmv-c3: #06b6d4;
  --tmv-c4: #f43f5e;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: var(--tmv-height);
  overflow: hidden;
  position: relative;
  background: var(--tmv-page);
  color: #fff;
  font-family: sans-serif;
}

/* The block melts into the page (screen mode: black disappears); under the video, a flowing gradient while it has no frame */
.fx-0619-root .tmv-stage {
  position: relative;
  max-width: 100%;
  overflow: hidden;
  background: #000;
  mix-blend-mode: screen;
}

.fx-0619-root .tmv-stage::before,
.fx-0619-root .tmv-video {
  position: absolute;
  inset: 2px;
  display: block;
  width: calc(100% - 4px);
  height: calc(100% - 4px);
  pointer-events: none;
}

.fx-0619-root .tmv-stage::before {
  content: "";
  background: linear-gradient(90deg, var(--tmv-c1), var(--tmv-c2), var(--tmv-c3), var(--tmv-c4), var(--tmv-c1)) 0 50% / 400% 100%;
  animation: tmvFlow 3s linear infinite;
}

.fx-0619-root .tmv-video {
  object-fit: cover;
}

/* White text on black, multiply mode: the video only stays visible inside the letters */
.fx-0619-root .tmv-text {
  position: relative;
  margin: 0;
  padding: .3em .45em;
  font-size: var(--tmv-size);
  font-weight: 900;
  line-height: 1;
  text-align: center;
  overflow-wrap: anywhere;
  color: #fff;
  background: #000;
  mix-blend-mode: multiply;
}

/* Light-page variant: modes swapped (white disappears) */
.fx-0619-root.tmv-light {
  --tmv-page: #fff;
}

.fx-0619-root.tmv-light .tmv-stage {
  background: #fff;
  mix-blend-mode: multiply;
}

.fx-0619-root.tmv-light .tmv-text {
  color: #000;
  background: #fff;
  mix-blend-mode: screen;
}

/* Video is playing: the fallback gradient stops; video failed: it is hidden and the gradient fills the text */
.fx-0619-root.tmv-playing .tmv-stage::before {
  animation: none;
}

.fx-0619-root.tmv-failed .tmv-video {
  visibility: hidden;
}

@keyframes tmvFlow {
  to {
    background-position: 400% 50%;
  }
}

@media (prefers-reduced-motion: reduce) {
  .fx-0619-root .tmv-stage::before {
    animation: none;
  }
}
JavaScript (fx-0619)
(function () {
  var reduce = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
  function reduced() { return !!(reduce && reduce.matches); }

  // Locally generated demo clip (no file): remove data-tmv-demo and give the video a src
  function demoClip(root, video) {
    var canvas = document.createElement('canvas');
    if (!canvas.captureStream) return null;
    canvas.width = 480;
    canvas.height = 270;
    var ctx = canvas.getContext('2d');
    var style = getComputedStyle(root);
    var colors = ['--tmv-c1', '--tmv-c2', '--tmv-c3', '--tmv-c4'].map(function (v, i) {
      return style.getPropertyValue(v).trim() || ['#6366f1', '#d946ef', '#06b6d4', '#f43f5e'][i];
    });
    var t = 0, last = 0, raf = 0;
    function draw() {
      var w = canvas.width, h = canvas.height;
      ctx.globalCompositeOperation = 'source-over';
      ctx.fillStyle = colors[0];
      ctx.fillRect(0, 0, w, h);
      for (var i = 1; i < 4; i++) {
        var x = w * (0.5 + 0.45 * Math.sin(t * (0.35 + i * 0.12) + i * 1.9));
        var y = h * (0.5 + 0.45 * Math.cos(t * (0.45 + i * 0.1) + i * 2.4));
        var g = ctx.createRadialGradient(x, y, 0, x, y, h * 0.95);
        g.addColorStop(0, colors[i]);
        g.addColorStop(1, 'rgba(0,0,0,0)');
        ctx.fillStyle = g;
        ctx.fillRect(0, 0, w, h);
      }
      ctx.globalCompositeOperation = 'lighter';
      for (var k = 0; k < 3; k++) {
        var bx = ((t * 70 + k * 190) % (w + 160)) - 80;
        var beam = ctx.createLinearGradient(bx - 50, 0, bx + 50, 0);
        beam.addColorStop(0, 'rgba(255,255,255,0)');
        beam.addColorStop(0.5, 'rgba(255,255,255,0.28)');
        beam.addColorStop(1, 'rgba(255,255,255,0)');
        ctx.fillStyle = beam;
        ctx.fillRect(bx - 50, 0, 100, h);
      }
    }
    function loop(now) {
      var dt = last ? Math.min(50, now - last) : 16;
      last = now;
      t += dt / 1000;
      draw();
      raf = requestAnimationFrame(loop);
    }
    draw();
    video.srcObject = canvas.captureStream(30);
    return {
      start: function () { if (!raf) { last = 0; raf = requestAnimationFrame(loop); } },
      stop: function () { cancelAnimationFrame(raf); raf = 0; },
      still: function () { draw(); }
    };
  }

  function init(root) {
    if (root.dataset.tmvReady) return;
    root.dataset.tmvReady = '1';
    var video = root.querySelector('.tmv-video');
    if (!video) return;
    var clip = null;
    if (video.hasAttribute('data-tmv-demo') && !video.getAttribute('src') && !video.querySelector('source')) {
      clip = demoClip(root, video);
    }
    var auto = video.autoplay;
    var visible = true, heldOff = false;
    // tmv-playing class once a video frame is shown; reduced motion = first frame, frozen
    video.addEventListener('playing', function () {
      root.classList.add('tmv-playing');
      if (reduced()) video.pause();
    });
    video.addEventListener('play', function () { if (clip) clip.start(); });
    video.addEventListener('pause', function () { if (clip) clip.stop(); });
    video.addEventListener('emptied', function () { root.classList.remove('tmv-playing'); });
    // No playable source (missing file, unsupported format): tmv-failed class, the text keeps the gradient
    video.addEventListener('error', function () {
      setTimeout(function () {
        if (video.error || video.networkState === 3) {
          root.classList.remove('tmv-playing');
          root.classList.add('tmv-failed');
        }
      }, 0);
    }, true);
    video.addEventListener('canplay', function () {
      if (auto && visible && !heldOff && !reduced() && video.paused) {
        var c = video.play();
        if (c && c.catch) c.catch(function () {});
      }
    }, { once: true });

    function settle() {
      if (reduced()) {
        if (!root.classList.contains('tmv-playing')) {
          if (clip) clip.still();
          var p = video.play();
          if (p && p.catch) p.catch(function () {});
        } else {
          video.pause();
        }
      } else if (auto && visible) {
        var q = video.play();
        if (q && q.catch) q.catch(function () {});
      }
    }
    // Off screen the video pauses; an autoplaying video resumes when it comes back
    if ('IntersectionObserver' in window) {
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (!visible && !video.paused) { heldOff = auto; video.pause(); }
        else if (visible && heldOff) { heldOff = false; if (!reduced()) { var r = video.play(); if (r && r.catch) r.catch(function () {}); } }
      }).observe(root);
    }
    if (reduce && reduce.addEventListener) reduce.addEventListener('change', settle);
    settle();
  }

  function start() { document.querySelectorAll('.fx-0619-root').forEach(init); }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
  else start();
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Your video (HTML — src or <source> on .tmv-video) demo clip (data-tmv-demo) Remove data-tmv-demo and give it a file. Keep muted, playsinline and loop; without autoplay the video waits for your own play().
Text size (CSS — --tmv-size) 4rem The bigger and bolder the letters, the more video shows. clamp(2rem, 9vw, 6rem) follows the screen width.
Block background and height (CSS — --tmv-page, --tmv-height) #0a0a0f / 250px --tmv-page:transparent and --tmv-height:0px put the heading straight on your page, which must be dark (or light with tmv-light).
Light page (tmv-light class on the root) absent Black text, blend modes swapped: the white around the letters disappears instead of the black. The default background turns white.
Fallback and clip colours (CSS — --tmv-c1 to --tmv-c4) #6366f1, #d946ef, #06b6d4, #f43f5e Hues of the gradient shown while loading or after a failure, and of the demo clip. Use your video's dominant colours for a seamless switch.
Text element (HTML) <h2 class="tmv-text"> Any block-level element works (h1, p, div): the class is what matters. Avoid a bare span, which takes no height.

FAQ

How do I use my own video?

In the HTML, on the <video class="tmv-video"> tag: delete data-tmv-demo and add <source src="title.webm" type="video/webm"> then <source src="title.mp4" type="video/mp4">. A short silent clip (5 to 10 s), 640 px wide, is plenty: only the inside of the letters is seen. Pick bright, contrasted footage.

What happens if the video does not load?

While loading, the letters show the animated gradient placed under the video. If no source is playable (file not found, rejected format), the script adds tmv-failed to the root and hides the video: the gradient stays and the heading remains readable. If the browser blocks autoplay, the first frame or the gradient is shown.

Why must the text sit on a dark or light background, not grey?

The video is cut out by two colour blends: the black around the letters turns transparent on the page (screen mode). On a light page the tmv-light class does the same with white. On a mid-tone background (grey, a bright colour) the letters take a veil of that colour: the cut-out stays exact but the video looks washed out.