← Text effects

Video Text

A video plays inside the letters, as if the words were cut out of a sheet laid over the footage. Each frame is drawn through the shape of the text, so it works on any background and the text stays real, selectable text.

In motion

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-video-text to a big, heavy, single-line heading.
  3. Point data-src at a short, looping clip, or put a <video> inside the heading instead.

Options

Add any of these attributes to the same element.

data-srcThe video to play inside the letters.
data-strokeColour of a thin outline round the letters, to help them read over pale footage. Default none.
data-fallbackColour the letters show before the video loads. Default #6a0dad.

The video is muted and loops, and only plays while the text is on screen. With reduced motion turned on the letters hold a still frame. Heavy, chunky type shows off the most footage.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Video Text: a video plays inside the letterforms, as if the words were
    // cut out of a sheet laid over the footage. Each frame is drawn onto a
    // canvas through the shape of the text, so it works on any background.
    // Usage: <h1 data-video-text data-src="clip.mp4">Wild ride</h1>
    // Optional: data-src (the video; or put a <video> inside the element
    // instead), data-stroke (colour of a thin outline round the letters, to
    // help them read over pale footage; default none), data-fallback (colour
    // shown before the video loads; default #6a0dad).
    // Keep the text to one line. The video should be muted; it loops, plays
    // only while the text is on screen and holds a still frame for people who
    // prefer reduced motion.
    var DEFAULTS = { fallback: '#6a0dad' };

    var css =
      '.vdt-line{position:relative;display:inline-block;white-space:nowrap;color:transparent;-webkit-text-fill-color:transparent}' +
      '.vdt-line::selection{background:rgba(106,13,173,.25)}' +
      '.vdt-base{display:inline-block;width:0;height:0;vertical-align:baseline}' +
      '.vdt-canvas{position:absolute;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var video = el.querySelector('video');
      if (video) video.remove();
      else {
        video = document.createElement('video');
        if (d.src) video.src = d.src;
      }
      video.muted = true;
      video.loop = true;
      video.playsInline = true;
      video.setAttribute('playsinline', '');
      video.preload = 'auto';

      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.textContent = '';
      var line = document.createElement('span');
      line.className = 'vdt-line';
      line.textContent = text;
      var base = document.createElement('span');
      base.className = 'vdt-base';
      line.appendChild(base);
      var canvas = document.createElement('canvas');
      canvas.className = 'vdt-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      line.appendChild(canvas);
      el.appendChild(line);

      var ctx = canvas.getContext('2d');
      var W = 0, H = 0, pad = 0, dpr = 1, baseline = 0, font = '', spacing = '0px';

      function build() {
        var cs = getComputedStyle(el);
        var fs = parseFloat(cs.fontSize);
        dpr = Math.min(2, window.devicePixelRatio || 1);
        pad = Math.ceil(fs * 0.15);
        W = Math.ceil(line.offsetWidth + pad * 2);
        H = Math.ceil(line.offsetHeight + pad * 2);
        baseline = base.offsetTop + pad;
        canvas.width = W * dpr;
        canvas.height = H * dpr;
        canvas.style.width = W + 'px';
        canvas.style.height = H + 'px';
        canvas.style.left = canvas.style.top = -pad + 'px';
        font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
        spacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing;
        draw();
      }

      function draw() {
        if (!W) return;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.globalCompositeOperation = 'source-over';
        ctx.clearRect(0, 0, W, H);
        ctx.font = font;
        if ('letterSpacing' in ctx) ctx.letterSpacing = spacing;
        ctx.textBaseline = 'alphabetic';
        ctx.fillStyle = '#000';
        ctx.fillText(text, pad, baseline);
        // Everything drawn next only lands inside the letters.
        ctx.globalCompositeOperation = 'source-in';
        var vw = video.videoWidth, vh = video.videoHeight;
        if (vw && video.readyState >= 2) {
          var k = Math.max(W / vw, H / vh);
          ctx.drawImage(video, (W - vw * k) / 2, (H - vh * k) / 2, vw * k, vh * k);
        } else {
          ctx.fillStyle = d.fallback || DEFAULTS.fallback;
          ctx.fillRect(0, 0, W, H);
        }
        if (d.stroke) {
          ctx.globalCompositeOperation = 'source-over';
          ctx.strokeStyle = d.stroke;
          ctx.lineWidth = 1;
          ctx.strokeText(text, pad, baseline);
        }
      }

      build();
      if (document.fonts && document.fonts.ready) document.fonts.ready.then(build);
      var lastW = 0;
      new ResizeObserver(function () {
        if (line.offsetWidth === lastW) return;
        lastW = line.offsetWidth;
        build();
      }).observe(line);
      video.addEventListener('loadeddata', draw);
      video.addEventListener('seeked', draw);

      if (reduced()) {
        // A still frame from a little way in.
        video.addEventListener('loadedmetadata', function () {
          video.currentTime = Math.min(1, (video.duration || 2) / 2);
        });
        video.load();
        return;
      }

      var ticker = anime.createTimer({ loop: true, duration: 1000, autoplay: false, onUpdate: draw });

      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          var p = video.play();
          if (p && p.catch) p.catch(function () {});
          ticker.play();
        } else {
          video.pause();
          ticker.pause();
        }
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-video-text]').forEach(function (el) {
        if (el._vdt) return;
        el._vdt = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-video-text data-src="clip.mp4">In motion</h1>

<!-- or with your own video element: -->
<h1 data-video-text>
  In motion
  <video src="clip.mp4" muted loop playsinline></video>
</h1>