How to Build a Holographic Character Idler for Your Website
A floating character companion using browser-native luma keying, looping idle clips, and an optional speech bubble. Works on any site, no plugins, no engines.
A character idler is a small floating video of a character (real, painted, animated, or AI-generated) that sits in the corner of your page and breathes, blinks, or makes small idle motions. It adds presence without distracting from the main content. This guide walks through the full pipeline, end to end, with copy-paste code at each step. Everything runs entirely in the browser. No video-with-alpha encoding required, no canvas tricks, no WebGL.
What you'll build
A persistent corner element that:
- Plays short idle video loops of a character on a transparent background.
- Cycles between multiple clips without obvious repeat patterns.
- Optionally shows a speech bubble on click with rotating dialogue lines.
- Can be dismissed and reopened by the visitor.
- Respects performance: lazy-loads, pauses on hidden tabs, honors reduced-motion preferences.
The technique works equally well for serious-tone sites (a guide, an advisor, a character from your IP) and playful ones (a mascot, a pet, a desk plant). The core idea is the same: render a character clip with transparent edges so it floats naturally over your page rather than sitting inside a rectangular video box.
Prerequisites
- A way to generate short video clips of your character on a pure-black background. Common options: AI video models (Sora, Veo, Kling, LTX, Runway, etc), traditional 3D/2D animation rendered against black, or a real-world greenscreen shoot composited onto black.
ffmpeginstalled locally for re-encoding (most platforms:brew install ffmpeg,apt install ffmpeg, or grab a Windows build).- Basic HTML / CSS / JavaScript familiarity. You should be comfortable pasting a few snippets into your site template.
- A modern browser to test in. Chrome, Firefox, Safari, and Edge all support the SVG filter used here.
1 Generate the character clips
The single most important rule for this whole pipeline:
Recommended clip specs
| Property | Recommendation | Why |
|---|---|---|
| Background | Pure black (#000000) | So the luma key works cleanly |
| Character position | Centered horizontally, full body or 3/4 | The container crops sides, so centering protects the silhouette |
| Duration | 5-10 seconds | Long enough not to feel jittery, short enough to fit small file sizes |
| Motion | Subtle: breathing, blinking, small gestures | Big motion in a corner overlay reads as distracting; restraint reads as presence |
| Wardrobe | Avoid pure-black clothing if possible | Black clothing on a black background gets keyed away with the bg, leaving holes in the silhouette |
| Quantity | 3-6 clips minimum | So the random rotation in step 6 doesn't feel repetitive within a session |
2 Encode for the web
Take whatever the model output as the source file and re-encode it to a small, web-optimised MP4. Faststart-flagged H.264 inside an MP4 container is the most compatible option and plays everywhere.
ffmpeg -i source.mp4 \
-vf "scale=480:-2" \
-c:v libx264 -profile:v main -pix_fmt yuv420p -crf 23 -preset slow \
-movflags +faststart \
-an \
idle-1.mp4
What each piece does:
scale=480:-2scales width to 480px and lets height adjust to maintain aspect ratio. 480px is plenty for a corner overlay; bigger just wastes bandwidth.crf 23is good visual quality at modest file size. Use 20 for a sharper master, 26 if you really need to shrink.-preset slowtrades encode time for better compression. Usemediumif you're iterating fast.-movflags +faststartmoves the metadata to the start of the file so playback can begin before the whole file downloads.-anstrips audio. Your character idler does not need audio.
Repeat this for each clip, naming them idle-1.mp4, idle-2.mp4, and so on. Aim for under ~300KB per clip; sub-100KB is achievable for low-motion clips.
3 The luma-key SVG filter
This is the technique that makes the whole thing work without needing alpha-channel video. An SVG <filter> with a feColorMatrix can take any element and rewrite its alpha channel based on a per-pixel formula. Apply it to your video element via CSS, and the browser luma-keys the black background away in real time, GPU-accelerated.
Drop this snippet anywhere inside your <body>. It's a hidden 0x0 SVG, no visible impact:
<svg width="0" height="0" style="position:absolute;" aria-hidden="true">
<filter id="char-lumakey" color-interpolation-filters="sRGB">
<feColorMatrix type="matrix" values="
1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0.30 0.59 0.11 0 -0.04" />
</filter>
</svg>
What the matrix is doing: the first three rows pass red, green, and blue through unchanged. The fourth row computes alpha as a weighted brightness (luma) of the input, minus a small floor:
alpha = 0.30 * R + 0.59 * G + 0.11 * B - 0.04
The 0.30 / 0.59 / 0.11 weights match the standard luminance formula (the human eye is most sensitive to green, so green is weighted highest). The -0.04 at the end gives black pixels a slightly negative alpha which is clamped to zero (fully transparent). Bright pixels end up with alpha near 1.0 (fully opaque). Mid-luma pixels get partial alpha, creating naturally soft edges.
-0.06 or -0.08) to be more aggressive. If too much of your character is getting eaten (especially dark hair or pants), reduce it (e.g. -0.02). You can also adjust the per-channel weights if your character has a strong color cast you want to preserve.4 HTML scaffold
The companion is a single container with a video element, optional speech bubble, and a dismiss button. Plus a small reopen pill that shows after the user dismisses the character.
<div class="char-companion" id="char-companion">
<div class="char-frame" id="char-frame">
<video class="char-video" id="char-video"
muted playsinline webkit-playsinline preload="none"></video>
<button class="char-close" id="char-close"
title="Dismiss" aria-label="Dismiss">×</button>
</div>
<div class="char-name">CHARACTER NAME</div>
<div class="char-bubble" id="char-bubble"></div>
</div>
<button class="char-reopen" id="char-reopen"
aria-label="Show character">
<span class="glyph">◆</span> Character
</button>
5 CSS styling
This is the styling that gives the companion its "hologram in the corner" look. The key bits: position: fixed pins it to the page corner, filter: url(#char-lumakey) applies the SVG luma key from step 3, and a couple of drop-shadow filters add a soft glow.
.char-companion {
position: fixed;
bottom: 1.2rem;
left: 1.2rem;
width: 180px;
z-index: 50;
pointer-events: auto;
}
.char-frame {
position: relative;
width: 100%;
aspect-ratio: 3 / 4; /* portrait container */
cursor: pointer;
filter: drop-shadow(0 12px 26px rgba(0,0,0,0.55))
drop-shadow(0 0 22px rgba(75, 224, 255, 0.18));
transition: filter 0.2s, transform 0.2s;
}
.char-frame:hover {
transform: translateY(-1px);
filter: drop-shadow(0 14px 30px rgba(0,0,0,0.65))
drop-shadow(0 0 32px rgba(75, 224, 255, 0.28));
}
.char-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: none; /* shown once a clip loads */
filter: url(#char-lumakey); /* desktop browsers honor this */
}
.char-video.loaded { display: block; }
.char-name {
margin-top: 8px;
text-align: center;
color: #d4af37;
font-family: 'Inter', sans-serif;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.char-close {
position: absolute; top: 4px; right: 4px;
width: 22px; height: 22px; padding: 0;
background: rgba(0,0,0,0.45); border: 0;
border-radius: 50%; color: #d4af37;
font-size: 14px; cursor: pointer; opacity: 0;
transition: opacity 0.2s;
z-index: 5;
}
.char-companion:hover .char-close { opacity: 1; }
.char-reopen {
position: fixed; bottom: 1.2rem; left: 1.2rem;
z-index: 49; display: none;
background: rgba(20,20,28,0.85);
border: 1px solid rgba(212,175,55,0.4);
border-radius: 999px; padding: 0.45rem 0.85rem;
color: #d4af37; font-size: 0.72rem;
letter-spacing: 0.1em; text-transform: uppercase;
cursor: pointer;
}
.char-reopen.show { display: inline-flex; align-items: center; gap: 0.4rem; }
@media (max-width: 600px) {
.char-companion { width: 130px; bottom: 0.6rem; left: 0.6rem; }
}
The cyan-tinted second drop-shadow is what gives the "hologram" feel. Change the rgb values for a different mood: warm gold for a campfire-style companion, magenta for a synth-wave one, white for clean modernist, etc.
6 Clip rotation: no-repeat random
Hard-coding a single clip on loop reads as fake very quickly. The fix: have a pool of clips and swap to a new random one each time the current one ends. Never repeat the same clip back-to-back.
(function() {
'use strict';
// Your clip pool — add more as you generate them
const clips = [
'/character/idle-1.mp4',
'/character/idle-2.mp4',
'/character/idle-3.mp4',
'/character/idle-4.mp4'
];
let lastClip = null;
function pickClip() {
if (clips.length === 0) return null;
if (clips.length === 1) return clips[0];
// No-repeat random: filter out the last one played
const pool = clips.filter(c => c !== lastClip);
const next = pool[Math.floor(Math.random() * pool.length)];
lastClip = next;
return next;
}
function loadClip() {
const src = pickClip();
if (!src) return;
const video = document.getElementById('char-video');
if (!video) return;
video.loop = false; // we cycle on 'ended' instead
video.src = src;
video.addEventListener('loadeddata', function once() {
video.classList.add('loaded');
video.removeEventListener('loadeddata', once);
});
video.play().catch(() => {});
}
// When current clip finishes, swap in another
function setupCycling() {
const video = document.getElementById('char-video');
if (!video) return;
video.addEventListener('ended', () => {
const next = pickClip();
if (!next) return;
video.src = next;
video.play().catch(() => {});
});
}
function boot() {
setupCycling();
// Defer the first load so we don't compete with above-the-fold rendering
if ('requestIdleCallback' in window) {
requestIdleCallback(loadClip, { timeout: 1800 });
} else {
setTimeout(loadClip, 250);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();
The IIFE wrapper (the (function(){ ... })(); structure) keeps these variables private so they don't pollute the global namespace or collide with anything else on your page.
7 Optional: speech bubble with rotating lines
The bubble shows on click, displays a random non-repeating dialogue line from a pool you define, and auto-dismisses after a few seconds. Same no-repeat pattern as the clips.
/* Bubble positioning trick for corner-anchored characters: anchor the
bubble's TAIL a fixed distance from its own left edge (here 30px) so
the bubble extends RIGHTWARD from above the character rather than
centering on her. This keeps it on-screen even when the character is
pinned in a left corner. Mirror the same offset on the resting AND
active state transforms — if the active state reverts to centering,
the bubble snaps off-screen the moment it opens. */
.char-bubble {
position: absolute;
bottom: calc(100% + 14px);
left: 50%;
transform: translateX(-30px) translateY(6px);
width: 240px;
max-width: calc(100vw - 2rem); /* viewport-safe backstop */
background: linear-gradient(180deg,
rgba(28,24,18,0.97), rgba(18,16,12,0.97));
border: 1px solid rgba(212, 175, 55, 0.45);
border-radius: 6px;
padding: 0.85rem 1rem;
color: #d8c9a8;
font-family: Georgia, serif;
font-size: 1rem;
line-height: 1.45;
font-style: italic;
box-shadow: 0 10px 28px rgba(0,0,0,0.6);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s, transform 0.3s;
z-index: 10;
}
.char-bubble.active {
opacity: 1;
pointer-events: auto;
transform: translateX(-30px) translateY(0); /* match resting x-offset; only y changes */
}
/* Bubble tail (the little triangle pointing at the character) — anchored
at the same 30px offset so it sits above the character's head. */
.char-bubble::before, .char-bubble::after {
content: '';
position: absolute;
left: 30px;
width: 0; height: 0;
}
/* Mirror these on your mobile media query, swapping -30/30 for whatever
offset suits the narrower mobile companion size. */
const lines = [
"Welcome. I'm just here for atmosphere.",
"Did you know the search box is faster than scrolling?",
"Click me again. I'll say something different.",
// ...add as many lines as you like
];
let lastLine = null;
function pickLine() {
if (lines.length === 0) return '';
if (lines.length === 1) return lines[0];
const pool = lines.filter(l => l !== lastLine);
const next = pool[Math.floor(Math.random() * pool.length)];
lastLine = next;
return next;
}
function setupBubble() {
const frame = document.getElementById('char-frame');
const bubble = document.getElementById('char-bubble');
if (!frame || !bubble) return;
let visible = false;
let timer = null;
frame.addEventListener('click', () => {
if (visible) {
bubble.classList.remove('active');
visible = false;
clearTimeout(timer);
} else {
bubble.textContent = pickLine();
bubble.classList.add('active');
visible = true;
clearTimeout(timer);
timer = setTimeout(() => {
bubble.classList.remove('active');
visible = false;
}, 9000);
}
});
}
Call setupBubble() alongside setupCycling() in your boot() function. The bubble pulls a fresh non-repeating line each click and auto-hides after 9 seconds. Tune to taste.
Optional: trigger a one-time CTA after N clicks
A nice progressive-disclosure pattern: after the visitor clicks the character a few times (so they've shown active interest, not just bounce traffic) replace the normal random line with a special call-to-action. Newsletter signup, Discord invite, demo booking, tip jar, whatever conversion goal the page actually has. Show it once per session so it doesn't nag.
const NUDGE_AT = 3; // fire on the 3rd click
const NUDGE_TIMEOUT_MS = 14000;
let clickCount = 0;
let nudgeShown = false;
// Replace the body of your click handler with this:
frame.addEventListener('click', () => {
if (visible) {
bubble.classList.remove('active');
visible = false;
clearTimeout(timer);
return;
}
clickCount++;
// Special CTA on the Nth click (once per session)
if (clickCount === NUDGE_AT && !nudgeShown) {
nudgeShown = true;
bubble.innerHTML =
'Curious, aren\'t you? '
+ '<a href="/subscribe" class="char-cta">Get the newsletter</a>';
bubble.classList.add('active');
visible = true;
clearTimeout(timer);
timer = setTimeout(() => {
bubble.classList.remove('active');
visible = false;
}, NUDGE_TIMEOUT_MS);
return;
}
// Default: random non-repeating line
bubble.textContent = pickLine();
bubble.classList.add('active');
visible = true;
clearTimeout(timer);
timer = setTimeout(() => {
bubble.classList.remove('active');
visible = false;
}, 9000);
});
.char-bubble .char-cta {
display: inline-block;
margin-top: 6px;
padding: 4px 10px;
background: rgba(212, 175, 55, 0.18);
border: 1px solid rgba(212, 175, 55, 0.55);
border-radius: 999px;
color: #f0d98a;
text-decoration: none;
font-style: normal;
font-size: 0.85rem;
letter-spacing: 0.04em;
transition: background 0.2s, color 0.2s;
}
.char-bubble .char-cta:hover {
background: rgba(212, 175, 55, 0.35);
color: #fff;
}
Tuning notes:
- 3 clicks is a defensible default. One click could be accidental. Two could be curiosity. Three is intent. Push higher (5-6) if your character is in a high-traffic surface where you want to be especially polite.
- One-time per session is important. The
nudgeShownflag prevents the same visitor seeing the CTA twice. If you want to also suppress it across sessions for visitors who already subscribed, persist it inlocalStorage. - Longer auto-dismiss for CTA. The CTA needs time to read and decide; 14s vs the normal 9s gives the visitor breathing room.
- The line itself should match your character's voice. "Curious, aren't you?" works for a slightly knowing tone. A pet companion might say "Want me to bring you something? Sign up →". A serious-tone guide character: "If this resonates, we send a short note weekly." Tone discipline matters more than the technique.
- Track the CTA click in analytics. Add a
data-source="char-idler-nudge"attribute to the link so you can see conversion rate vs other CTAs on the same page.
8 Performance & accessibility
Pause when the tab is hidden
Keeps the video from chewing CPU/battery when nobody is looking.
function setupVisibilityPause() {
const video = document.getElementById('char-video');
if (!video) return;
document.addEventListener('visibilitychange', () => {
if (document.hidden) video.pause();
else if (video.src) video.play().catch(() => {});
});
}
Respect prefers-reduced-motion
Some users have motion sensitivities or just prefer calm pages. The browser tells you via a CSS media query and a JS matchMedia equivalent.
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
// In your boot() function, skip loading the clip if so
function boot() {
setupBubble();
setupCycling();
setupVisibilityPause();
if (prefersReducedMotion) {
// Show a static frame instead, or skip entirely
return;
}
if ('requestIdleCallback' in window) {
requestIdleCallback(loadClip, { timeout: 1800 });
} else {
setTimeout(loadClip, 250);
}
}
Lazy load with preload="none"
The preload="none" attribute on the video element (already in step 4) tells the browser not to fetch the file until JavaScript explicitly asks. Combined with requestIdleCallback, this means the character clip never competes with above-the-fold rendering for bandwidth.
Save-Data / slow-connection respect
Skip the video entirely on data-saver mode or slow effective connection types.
const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (conn) {
if (conn.saveData) return; // user opted into data saving
if (['slow-2g','2g'].includes(conn.effectiveType)) return;
}
loadClip();
Dismissible
The close button and reopen pill let visitors banish the character if they find it distracting. Trivial but important: never trap someone with a thing they didn't ask for.
function setupClose() {
const closeBtn = document.getElementById('char-close');
const reopenBtn = document.getElementById('char-reopen');
const companion = document.getElementById('char-companion');
if (!closeBtn || !reopenBtn || !companion) return;
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
companion.style.display = 'none';
reopenBtn.classList.add('show');
});
reopenBtn.addEventListener('click', () => {
companion.style.display = 'block';
reopenBtn.classList.remove('show');
});
}
Common pitfalls
-0.04 value) to be more aggressive, at the cost of biting into darker regions of the character.muted attribute on the video element (in step 4) and you're stripping audio at encode time (the -an flag in step 2).aspect-ratio: 3 / 4 container with object-fit: cover will crop the sides of a 16:9 source. Either compose your character in the center 30% of the source frame, or change the aspect ratio (16 / 9 for landscape, 1 / 1 for square) to match your source.<div> and putting the filter on the wrapper does not reliably fix it either — iOS Safari has inconsistent behavior with filter chains. The robust fix is the iOS canvas-overlay section below: detect iOS at runtime, move the source video off-screen, draw each frame to a canvas with the luma key applied in JavaScript pixel data. Test on a real iPhone before shipping; the iOS Simulator and desktop Safari don't reproduce the SVG-filter bug.colorlevels filter to your ffmpeg encode in step 2 (also documented in the source cleanup section below).playsinline and webkit-playsinline on the video element to play inline rather than going fullscreen. They're in the step-4 snippet for this reason. Don't remove them.i iOS Safari compatibility (canvas overlay)
The SVG filter approach in step 3 is the simple, fast, GPU-accelerated path that works everywhere except iOS Safari. WebKit silently ignores filter: url(#...) applied to <video> elements, so the page renders the raw video including its black background. Several "workarounds" (wrapping the video in a div with the filter; transform: translateZ(0) to force GPU compositing) work sometimes and break other times; in practice they're not reliable.
The robust solution is to detect iOS at runtime and replace the SVG filter path with a JavaScript canvas renderer that does the keying in pixel data. Desktop browsers stay on the cheap SVG path; iOS gets the heavier but bulletproof canvas path.
Detection
function isIOS() {
var ua = navigator.userAgent || '';
if (/iPad|iPhone|iPod/.test(ua) && !window.MSStream) return true;
// iPadOS 13+ reports MacIntel but supports touch
if (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) return true;
return false;
}
The canvas-overlay setup
Three structural moves on iOS only:
- Strip the SVG filter from the video (it's a no-op on Safari anyway).
- Move the source video far off-screen (
position: fixed; left: -9999px) at small but non-zero dimensions (16×16). iOS won't autoplay zero-dimension videos even when muted, so dimensions matter; off-screen positioning means iOS still renders and decodes it. - Insert a canvas in the original video slot. A
requestAnimationFrameloop draws each video frame to the canvas, then rewrites the alpha channel based on per-pixel luma.
function setupIOSCanvasOverlay() {
if (!isIOS()) return; // desktop untouched
var video = document.getElementById('char-video');
var frame = document.getElementById('char-frame');
if (!video || !frame) return;
// Move source video off-screen but keep dimensions non-zero (iOS won't
// autoplay zero-size videos even when muted+playsinline).
video.style.cssText =
'position:fixed !important;left:-9999px !important;top:0 !important;' +
'width:16px !important;height:16px !important;opacity:0 !important;' +
'pointer-events:none !important;z-index:-1 !important;display:block !important;' +
'filter:none !important;-webkit-filter:none !important;';
video.classList.remove('loaded');
// Insert canvas in the original slot
var canvas = document.createElement('canvas');
canvas.id = 'char-canvas';
canvas.setAttribute('aria-hidden', 'true');
canvas.style.cssText =
'position:absolute;inset:0;width:100%;height:100%;' +
'pointer-events:none;z-index:0;';
frame.insertBefore(canvas, frame.firstChild);
var ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
var LUMA_THRESHOLD = 10;
var rafId = null;
// Critical: canvas internal buffer must match its DISPLAYED size
// (CSS pixels × devicePixelRatio). Otherwise drawImage stretches the
// video to fit the buffer and you get a squished character.
function syncCanvasSize() {
var dpr = window.devicePixelRatio || 1;
var rect = canvas.getBoundingClientRect();
var cw = Math.max(1, Math.round(rect.width * dpr));
var ch = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== cw) canvas.width = cw;
if (canvas.height !== ch) canvas.height = ch;
return { cw: cw, ch: ch };
}
function drawFrame() {
rafId = null;
if (video.readyState >= 2 && !video.ended) {
var d = syncCanvasSize();
var cw = d.cw, ch = d.ch;
var vw = video.videoWidth, vh = video.videoHeight;
if (vw > 0 && vh > 0 && cw > 0 && ch > 0) {
// object-fit:cover math: scale and crop so video fills canvas
var vAspect = vw / vh, cAspect = cw / ch;
var dW, dH, dX, dY;
if (vAspect > cAspect) {
dH = ch; dW = Math.round(ch * vAspect);
dX = Math.round((cw - dW) / 2); dY = 0;
} else {
dW = cw; dH = Math.round(cw / vAspect);
dX = 0; dY = Math.round((ch - dH) / 2);
}
try {
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(video, dX, dY, dW, dH);
var img = ctx.getImageData(0, 0, cw, ch);
var px = img.data;
for (var i = 0; i < px.length; i += 4) {
// Rec. 601 luma weights (×256 for integer math)
var lum = (px[i] * 76 + px[i+1] * 150 + px[i+2] * 29) >> 8;
px[i+3] = lum < LUMA_THRESHOLD ? 0 : 255;
}
ctx.putImageData(img, 0, 0);
} catch (e) {
// CORS taint — abandon the canvas approach silently
canvas.style.display = 'none';
return;
}
}
}
rafId = requestAnimationFrame(drawFrame);
}
function kickoff() {
if (rafId == null) rafId = requestAnimationFrame(drawFrame);
}
video.addEventListener('loadeddata', kickoff);
video.addEventListener('canplay', kickoff);
video.addEventListener('playing', kickoff);
if (video.readyState >= 2) kickoff();
window.addEventListener('resize', kickoff);
}
Autoplay fallback — tap to wake
iOS Safari has every right to refuse autoplay (low-power mode, data-saver, user gestures expired, etc), even with muted + playsinline. Wrap the video's play method so a small tap-prompt appears if and only if the real play call rejects. Once the visitor taps, playback starts and the prompt disappears.
var tapPromptEl = null;
function attachTapToPlay() {
if (tapPromptEl) return;
tapPromptEl = document.createElement('div');
tapPromptEl.style.cssText =
'position:absolute;inset:0;display:flex;align-items:center;' +
'justify-content:center;color:#d4af37;font-family:sans-serif;' +
'font-size:0.65rem;letter-spacing:0.2em;text-transform:uppercase;' +
'background:rgba(20,20,28,0.35);cursor:pointer;z-index:6;';
tapPromptEl.textContent = 'tap to wake';
frame.appendChild(tapPromptEl);
tapPromptEl.addEventListener('click', function(e) {
e.stopPropagation();
var p = video.play();
if (p && p.then) p.then(removeTapPrompt).catch(function(){});
else removeTapPrompt();
});
}
function removeTapPrompt() {
if (tapPromptEl) { tapPromptEl.remove(); tapPromptEl = null; }
}
// Monkey-patch video.play so the tap prompt only appears when the REAL
// play attempt rejects (after a src has been assigned). Without this,
// the prompt would fire prematurely against an empty <video>.
var originalPlay = video.play.bind(video);
video.play = function() {
var p = originalPlay();
if (p && p.then) p.then(removeTapPrompt).catch(function() {
if (video.src && video.src.length > 0) attachTapToPlay();
});
return p;
};
Call setupIOSCanvasOverlay() from your boot() function alongside the other setup calls. On desktop the function returns immediately (the isIOS() check fails) and your existing SVG-filter path is untouched.
ii Source video luma cleanup
The luma key only looks at brightness — it has no semantic understanding that some pixels "belong" to the background. If your source video has compression noise that pushes the background to luma 13-17 (common with AI-generated clips, especially those exported with high motion or aggressive codecs) your threshold of 10 will leave those pixels opaque and the visitor sees a rectangle.
Check your encoded clips before shipping. A quick Python sanity check on a sampled frame:
ffmpeg -y -ss 3 -i your-clip.mp4 -frames:v 1 sample.jpg
python3 -c "
from PIL import Image
im = Image.open('sample.jpg').convert('L')
W, H = im.size
# Sample top strip and left strip (definitely bg areas)
bg = [im.getpixel((x, y)) for x in range(0, W, 2) for y in range(0, int(H*0.08), 2)]
bg += [im.getpixel((x, y)) for x in range(0, int(W*0.05), 2) for y in range(0, H, 2)]
above = sum(1 for v in bg if v >= 10)
print(f'bg avg luma {sum(bg)/len(bg):.1f}, {above/len(bg)*100:.1f}% >= 10')
"
If the percentage above 10 is more than ~3%, re-encode the source with an RGB luma crush. The colorlevels filter pushes any RGB channel below 10% (~25/255) to true zero, killing the noise without affecting the visible character.
ffmpeg -y -i source.mp4 \
-vf "format=rgb24,colorlevels=rimin=0.10:gimin=0.10:bimin=0.10,format=yuv420p,scale=480:-2" \
-c:v libx264 -profile:v main -pix_fmt yuv420p -crf 23 -preset slow \
-movflags +faststart -an cleaned.mp4
Two things to know:
- Why not just raise the luma threshold? Because that eats into the dark parts of your character (hair, dark clothing, shadows on skin). Cleaning the source is the targeted fix.
- Don't use
lutyuvalone. That filter only crushes the Y channel; the chroma U/V channels stay noisy and the converted-back RGB still has positive values.colorlevelsafterformat=rgb24operates in RGB space directly and crushes cleanly.
Variants and ideas
The same skeleton handles a lot of different effects with small tweaks:
- Mascot or pet: Drop the speech bubble, add cute idle clips of an animal. Hover-trigger a special "noticed you" clip.
- Onboarding guide: Trigger specific bubble lines from elsewhere in your code as the user reaches certain page sections. Replace the random-pick logic with a sequential script.
- News anchor: Put the companion in a small rounded "broadcast" frame (skip the luma key), with a lower-third headline that rotates. See our in-world broadcast tutorial for that pattern.
- Decorative-only: Strip the click handler and bubble entirely. The character just exists in the corner, breathing. Surprisingly effective on long-scrolling articles.
- Multiple characters: Repeat the whole companion structure with different ids (
char-companion-2, etc.) and tuck them in different corners. Each gets its own clip pool and bubble. - Different "hologram" colors: Change the cyan rgb values in the drop-shadow filters. Warm gold reads as fire, magenta as synth-wave, white as clinical or modern, green as Matrix-y.
- Replace the luma key with a real alpha video: If you can produce WebM-with-alpha video (VP9 with yuva420p, or HEVC with alpha on Apple platforms) you can drop the SVG filter entirely and let the video itself carry transparency. More work to encode, less work at runtime, and your character can have pure-black wardrobe without holes.
Closing
That's the full pipeline. A handful of HTML, a hundred lines of CSS, fifty of JavaScript, one SVG filter, and a few short video clips. The result is a small piece of personality that quietly inhabits your page without dominating it. Add to taste, tune to your character's vibe, and don't feel obligated to do all of it; many sites work great with just the luma-keyed video and no bubble at all.
If you build something with this approach, we'd love to see it. Send a screenshot to the contact on the home page.