← Filters & retro

Camcorder Viewfinder

Watch a clip through an old camcorder’s viewfinder. A blinking REC dot, a running timecode, a slowly draining battery, corner marks, a zoom bar and an orange date stamp sit over the picture. The picture sways a little as if hand-held, and the focus hunts for a moment as it starts recording.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap a video (or an image) in an element with data-camcorder-viewfinder and give the wrapper a size. The picture fills it, cropped to cover.
  3. For a video, add autoplay muted loop playsinline so it plays by itself.

Options

Add any of these attributes to the same element.

data-dateThe date stamp in the corner. Default today’s date; false hides it.
data-shakeHow much the picture sways, from 0 to 2. Default 1.
data-focusfalse skips the focus hunt at the start.
data-batteryHow many bars the battery starts with, 1 to 4. Default 3.
data-colorColour of the read-out. Default #fff.

With reduced motion the read-out shows, but the picture stays still and the clock doesn’t run.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Camcorder Viewfinder: puts an old camcorder's viewfinder read-out over a
    // video or image: a blinking REC dot, a running timecode, a battery that
    // slowly drains, corner marks, a centre cross, a zoom bar and a date stamp.
    // The picture sways a little, as if the camera is hand-held, and the focus
    // hunts for a moment when it starts recording.
    // Usage: <figure data-camcorder-viewfinder><video src="clip.mp4" autoplay muted loop playsinline></video></figure>
    // (an <img> or an inline <svg> works too). Give the wrapper a size.
    // Optional: data-date (the date stamp; defaults to today, "false" hides
    // it), data-shake (how much it sways, 0 to 2, default 1), data-focus
    // ("false" skips the focus hunt), data-battery (how full the battery
    // starts, 1 to 4 bars, default 3), data-color (the read-out colour,
    // default "#fff").
    var DEFAULTS = { shake: 1, battery: 3, color: '#fff' };
    var MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];

    var css =
      '.ccv{position:relative;overflow:hidden;background:#000}' +
      '.ccv-media{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;transform:scale(1.08);filter:contrast(1.06) saturate(1.15)}' +
      '.ccv-hud{position:absolute;inset:0;pointer-events:none;font:700 1em/1 ui-monospace,Menlo,Consolas,monospace;letter-spacing:.06em;text-shadow:0 1px 0 rgba(0,0,0,.55),0 0 6px rgba(0,0,0,.35)}' +
      '.ccv-hud::before{content:"";position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(0,0,0,.12) 0 1px,transparent 1px 3px)}' +
      '.ccv-c{position:absolute;width:1.4em;height:1.4em;border:0 solid currentColor;filter:drop-shadow(0 1px 0 rgba(0,0,0,.5))}' +
      '.ccv-c:nth-child(1){top:7%;left:5%;border-width:2px 0 0 2px}' +
      '.ccv-c:nth-child(2){top:7%;right:5%;border-width:2px 2px 0 0}' +
      '.ccv-c:nth-child(3){bottom:7%;left:5%;border-width:0 0 2px 2px}' +
      '.ccv-c:nth-child(4){bottom:7%;right:5%;border-width:0 2px 2px 0}' +
      '.ccv-x{position:absolute;left:50%;top:50%;width:1.6em;height:1.6em;transform:translate(-50%,-50%);opacity:.8}' +
      '.ccv-x::before,.ccv-x::after{content:"";position:absolute;background:currentColor;box-shadow:0 1px 0 rgba(0,0,0,.5)}' +
      '.ccv-x::before{left:0;right:0;top:50%;height:2px;margin-top:-1px}' +
      '.ccv-x::after{top:0;bottom:0;left:50%;width:2px;margin-left:-1px}' +
      '.ccv-tl,.ccv-tr,.ccv-bl,.ccv-br{position:absolute;display:grid;gap:.45em}' +
      '.ccv-tl{top:calc(7% + 2em);left:calc(5% + .6em)}' +
      '.ccv-tr{top:calc(7% + 2em);right:calc(5% + .6em);justify-items:end}' +
      '.ccv-bl{bottom:calc(7% + 1.9em);left:calc(5% + .6em)}' +
      '.ccv-br{bottom:calc(7% + 1.9em);right:calc(5% + .6em);justify-items:end}' +
      '.ccv-rec{display:flex;align-items:center;gap:.4em}' +
      '.ccv-dot{width:.75em;height:.75em;border-radius:50%;background:#ff2a2a;box-shadow:0 0 6px #ff2a2a}' +
      '.ccv-off .ccv-dot{visibility:hidden}' +
      '.ccv-bat{display:flex;align-items:center;gap:.4em}' +
      '.ccv-cell{display:flex;gap:2px;padding:2px;border:2px solid currentColor;border-radius:2px;position:relative}' +
      '.ccv-cell::after{content:"";position:absolute;right:-5px;top:28%;width:3px;height:44%;background:currentColor}' +
      '.ccv-cell i{width:.4em;height:.7em;background:currentColor}' +
      '.ccv-cell i.ccv-empty{opacity:0}' +
      '.ccv-cell i.ccv-low{animation:ccv-blink 1s steps(1) infinite}' +
      '.ccv-zoom{display:flex;align-items:center;gap:.4em}' +
      '.ccv-track{position:relative;width:5em;height:.5em;border:2px solid currentColor}' +
      '.ccv-knob{position:absolute;top:-2px;bottom:-2px;left:0;width:.5em;background:currentColor}' +
      '.ccv-date{font-size:1.1em;color:#ffb347}' +
      '@keyframes ccv-blink{50%{opacity:0}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function pad(n) { return (n < 10 ? '0' : '') + n; }

    function setup(el) {
      var d = el.dataset;
      var media = el.querySelector('video, img, svg');
      var shake = d.shake !== undefined ? +d.shake : DEFAULTS.shake;
      var battery = Math.max(1, Math.min(4, +d.battery || DEFAULTS.battery));
      var now = new Date();
      var date = d.date === 'false' ? '' : d.date || MONTHS[now.getMonth()] + '. ' + pad(now.getDate()) + ' ' + now.getFullYear();
      if (media) media.classList.add('ccv-media');

      var hud = document.createElement('div');
      hud.className = 'ccv-hud';
      hud.setAttribute('aria-hidden', 'true');
      hud.style.color = d.color || DEFAULTS.color;
      hud.innerHTML =
        '<span class="ccv-c"></span><span class="ccv-c"></span><span class="ccv-c"></span><span class="ccv-c"></span>' +
        '<span class="ccv-x"></span>' +
        '<div class="ccv-tl"><span class="ccv-rec"><i class="ccv-dot"></i><b class="ccv-mode">STBY</b></span><span class="ccv-tc">00:00:00:00</span></div>' +
        '<div class="ccv-tr"><span class="ccv-bat">SP <span class="ccv-cell"><i></i><i></i><i></i><i></i></span></span></div>' +
        '<div class="ccv-bl"><span class="ccv-zoom">W <span class="ccv-track"><i class="ccv-knob"></i></span> T</span></div>' +
        (date ? '<div class="ccv-br"><span class="ccv-date">' + date + '</span></div>' : '');
      el.appendChild(hud);
      var rec = hud.querySelector('.ccv-rec');
      var mode = hud.querySelector('.ccv-mode');
      var tc = hud.querySelector('.ccv-tc');
      var cells = hud.querySelectorAll('.ccv-cell i');
      var knob = hud.querySelector('.ccv-knob');

      function setBattery(n) {
        cells.forEach(function (c, i) {
          c.className = i >= n ? 'ccv-empty' : n === 1 ? 'ccv-low' : '';
        });
      }
      setBattery(battery);

      // The read-out scales with the box.
      function fit() { hud.style.fontSize = Math.max(9, Math.min(18, el.clientWidth * 0.03)) + 'px'; }
      fit();
      if (window.ResizeObserver) new ResizeObserver(fit).observe(el);

      if (reduced()) {
        mode.textContent = 'REC';
        return;
      }

      // Off screen the clock, the zoom and the sway all wait.
      var visible = false, parked = [];
      function later(fn) { if (visible) return false; parked.push(fn); return true; }

      function play() {
        // Stand by, then start recording.
        var frames = 0, last = 0, acc = 0, recording = false, started = performance.now();
        var timer = anime.createTimer({
          loop: true, duration: 1000, autoplay: false,
          onUpdate: function () {
            var t = performance.now();
            var dt = last ? Math.min(100, t - last) : 0;
            last = t;
            if (!recording) {
              if (t - started < 900) return;
              recording = true;
              mode.textContent = 'REC';
            }
            acc += dt;
            while (acc >= 40) { acc -= 40; frames++; }
            var s = Math.floor(frames / 25);
            tc.textContent = '00:' + pad(Math.floor(s / 60) % 60) + ':' + pad(s % 60) + ':' + pad(frames % 25);
            rec.classList.toggle('ccv-off', Math.floor(frames / 13) % 2 === 1);
            // One bar drains every 20 seconds, down to the last blinking one, then a fresh battery.
            var used = Math.floor(s / 20) % battery;
            setBattery(battery - used);
          }
        });

        // The zoom rocker eases in and out now and then.
        function zoom() {
          if (later(zoom)) return;
          anime.animate(knob, {
            left: anime.utils.random(0, 90) + '%',
            duration: anime.utils.random(1400, 2400),
            ease: 'inOut(3)',
            delay: anime.utils.random(1200, 3000),
            onComplete: zoom
          });
        }
        zoom();

        if (!media) return timer;
        // Hand-held sway: small, slow, random moves.
        function sway() {
          if (later(sway)) return;
          anime.animate(media, {
            x: anime.utils.random(-12, 12) * shake * 0.1 + '%',
            y: anime.utils.random(-10, 10) * shake * 0.1 + '%',
            rotate: anime.utils.random(-10, 10) * shake * 0.08,
            scale: 1.08,
            duration: anime.utils.random(1300, 2200),
            ease: 'inOut(2)',
            onComplete: sway
          });
        }
        if (shake > 0) sway();
        if (d.focus !== 'false') {
          anime.animate(media, {
            filter: ['contrast(1.06) saturate(1.15) blur(6px)', 'contrast(1.06) saturate(1.15) blur(2px)', 'contrast(1.06) saturate(1.15) blur(4px)', 'contrast(1.06) saturate(1.15) blur(0px)'],
            duration: 1600,
            ease: 'inOut(2)'
          });
        }
        return timer;
      }

      var timer = null;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (!visible) { if (timer) timer.pause(); return; }
        if (!timer) timer = play();
        timer.play();
        parked.splice(0).forEach(function (fn) { fn(); });
      }, { threshold: 0.1 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-camcorder-viewfinder]').forEach(function (el) {
        if (el._ccv) return;
        el._ccv = true;
        el.classList.add('ccv');
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-camcorder-viewfinder style="height:400px">
  <video src="holiday.mp4" autoplay muted loop playsinline></video>
</figure>