DJ · house, disco, edits — based in Lyon
VELMORAY
Next dates
Clubs, festivals, private events. Reply within 48 h.
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.
Updated
This effect is free — the SVG category contains 30 effects total, including 3 free. Effect.Labs has 840 vanilla effects. Explore the category →
DJ · house, disco, edits — based in Lyon
Next dates
Clubs, festivals, private events. Reply within 48 h.
Grimolte
12.4k followers · platform games
LIVE
1,284 viewers · chat open
Hover or focus a poster: the trailer plays inside its title.
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.
<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.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.<button> that calls pause() on the video.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.
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.
Copy the three blocks into your page. No dependencies.
<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>
.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;
}
}
(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();
})();
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
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. |
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.
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.
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.