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:

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

1 Generate the character clips

The single most important rule for this whole pipeline:

Generate your character on a pure-black background. Not dark gray, not very dark blue. Pure RGB (0,0,0). The luma key in step 3 turns black pixels transparent, so anything that isn't pure black will leak through as a faint halo. Most AI video tools accept a prompt like "centered character on pure solid black background, plain studio backdrop" and produce something close enough.

Recommended clip specs

PropertyRecommendationWhy
BackgroundPure black (#000000)So the luma key works cleanly
Character positionCentered horizontally, full body or 3/4The container crops sides, so centering protects the silhouette
Duration5-10 secondsLong enough not to feel jittery, short enough to fit small file sizes
MotionSubtle: breathing, blinking, small gesturesBig motion in a corner overlay reads as distracting; restraint reads as presence
WardrobeAvoid pure-black clothing if possibleBlack clothing on a black background gets keyed away with the bg, leaving holes in the silhouette
Quantity3-6 clips minimumSo the random rotation in step 6 doesn't feel repetitive within a session
Tip: If your character has dark hair or dark clothing, look at the source frame and confirm those areas have at least a bit of detail (luma above ~10 out of 255) rather than crushing to pure black. The key in step 3 has a small soft-falloff zone so slight darkness still reads as opaque, but absolute zero will be treated as background.

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.

Terminal · one clip
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:

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:

HTML · place once anywhere in body
<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:

The formula
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.

Tuning the threshold: If you see a faint dark halo around your character, increase the floor (e.g. -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.

HTML · companion structure
<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">&#9670;</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.

CSS · companion styling
.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.

JS · clip rotation
(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.

CSS · bubble styling
/* 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. */
JS · bubble + dialogue rotation
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.

JS · click-counted CTA bubble
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);
});
CSS · CTA link inside the bubble
.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:

8 Performance & accessibility

Pause when the tab is hidden

Keeps the video from chewing CPU/battery when nobody is looking.

JS · visibility pause
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.

JS · honor reduced-motion
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.

JS · skip on slow networks
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.

JS · close + reopen
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

Background isn't actually pure black. The single most common issue. Open one source frame in any image editor and check the corner pixel values. If they read RGB (5,5,5) or higher, your "black" isn't black, and you'll see a faint rectangular halo around the character. Either regenerate with a stricter prompt, or increase the luma-key floor (the -0.04 value) to be more aggressive, at the cost of biting into darker regions of the character.
Dark clothing or dark hair has holes in it. Pure-black pixels inside the character (a black t-shirt against a black background) get keyed away with the background. There's no algorithmic fix from the video side; either generate the character in lighter wardrobe, or accept the holes as "atmospheric" if they're small.
Video tries to autoplay with sound and gets blocked. Modern browsers block videos that autoplay with audio. Make sure you have the muted attribute on the video element (in step 4) and you're stripping audio at encode time (the -an flag in step 2).
Character gets cropped on the sides. The CSS 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.
iOS Safari silently drops SVG filters applied to <video> elements. The SVG filter approach in step 3 works perfectly on Chrome, Firefox, and desktop Safari, but iPhones render the unfiltered video including the black background. Wrapping the video in a <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.
One source clip looks like a black rectangle while the others look clean. The luma threshold has no idea your video is supposed to be background — it only checks brightness. AI-generated video models sometimes return clips with compression noise that pushes the "black" background to luma 13-17 instead of the expected 0-5. With the keying threshold at 10, those noisy pixels render as opaque and you see a rectangle. Fix at the source by adding a colorlevels filter to your ffmpeg encode in step 2 (also documented in the source cleanup section below).
Other iOS quirks: iOS Safari requires both 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

JS · detect iOS (including iPadOS 13+)
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:

  1. Strip the SVG filter from the video (it's a no-op on Safari anyway).
  2. 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.
  3. Insert a canvas in the original video slot. A requestAnimationFrame loop draws each video frame to the canvas, then rewrites the alpha channel based on per-pixel luma.
JS · iOS canvas overlay (call from boot)
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.

JS · tap-to-wake (add inside setupIOSCanvasOverlay)
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:

Python · sample background luma from an encoded clip
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.

Terminal · luma-crush re-encode
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:

Variants and ideas

The same skeleton handles a lot of different effects with small tweaks:

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.