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
- Paste the snippet just before
</head>. - Wrap a video (or an image) in an element with
data-camcorder-viewfinderand give the wrapper a size. The picture fills it, cropped to cover. - For a video, add
autoplay muted loop playsinlineso it plays by itself.
Options
Add any of these attributes to the same element.
data-date | The date stamp in the corner. Default today’s date; false hides it. |
data-shake | How much the picture sways, from 0 to 2. Default 1. |
data-focus | false skips the focus hunt at the start. |
data-battery | How many bars the battery starts with, 1 to 4. Default 3. |
data-color | Colour of the read-out. Default #fff. |
With reduced motion the read-out shows, but the picture stays still and the clock doesn’t run.
<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><figure data-camcorder-viewfinder style="height:400px">
<video src="holiday.mp4" autoplay muted loop playsinline></video>
</figure>