Webcam Frame
A frame for the webcam box on a stream overlay. A gradient border chases round the edge with a soft glow behind it, corner brackets breathe in and out, and a name tag with a red live dot sits on the bottom edge. It draws itself in when it comes into view.
How to use it
- Paste the snippet just before
</head>. - Add
data-webcam-frameto the box that holds your webcam (a<video>, or the space your streaming software fills). Give it a size.
Options
Add any of these attributes to the same element.
data-colors | Comma-separated border colours. Default purple, pink, amber and blue. |
data-speed | Seconds for the colours to go once round. Default 4. |
data-width | Border thickness in pixels. Default 4. |
data-radius | Corner radius in pixels. Default 16. |
data-label | Text for a name tag on the bottom edge. |
data-glow="false" | Leave out the glow. |
data-corners="false" | Leave out the corner brackets. |
The brackets sit just outside the box and the glow spills a little past it, so leave some room around it.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Webcam Frame: an animated border for the webcam box on a stream overlay.
// A gradient border chases round the edge with a soft glow, corner
// brackets breathe in and out, and an optional name tag with a live dot
// sits on the bottom edge. It draws itself in when it comes into view.
// Usage: <div data-webcam-frame data-label="pixel_panda"><video autoplay muted playsinline></video></div>
// Give the element a size (or let its content size it); the border sits
// just inside its edge.
// Optional: data-colors (comma-separated border colours, default purple,
// pink, amber and blue), data-speed (seconds per lap, default 4),
// data-width (border thickness in px, default 4), data-radius (corner
// radius in px, default 16), data-label (name tag text), data-glow="false"
// (no glow), data-corners="false" (no corner brackets).
var DEFAULTS = { colors: '#a855f7,#ec4899,#f59e0b,#38bdf8', speed: 4, width: 4, radius: 16 };
var css =
'.wcf{position:relative;--wcf-w:4px;--wcf-r:16px;border-radius:var(--wcf-r)}' +
'.wcf>.wcf-media{border-radius:var(--wcf-r)}' +
'.wcf-layer{position:absolute;inset:0;border-radius:var(--wcf-r);pointer-events:none}' +
'.wcf-ring{overflow:hidden;padding:var(--wcf-w);' +
'-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;' +
'mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);mask-composite:exclude}' +
'.wcf-glow{filter:blur(10px);opacity:.75}' +
'.wcf-spin{position:absolute;left:50%;top:50%;border-radius:50%}' +
'.wcf-corner{position:absolute;width:12%;max-width:2.4em;aspect-ratio:1;border:0 solid #fff;pointer-events:none;opacity:.9}' +
'.wcf-tl{left:-0.5em;top:-0.5em;border-width:3px 0 0 3px;border-top-left-radius:calc(var(--wcf-r) + 6px)}' +
'.wcf-tr{right:-0.5em;top:-0.5em;border-width:3px 3px 0 0;border-top-right-radius:calc(var(--wcf-r) + 6px)}' +
'.wcf-bl{left:-0.5em;bottom:-0.5em;border-width:0 0 3px 3px;border-bottom-left-radius:calc(var(--wcf-r) + 6px)}' +
'.wcf-br{right:-0.5em;bottom:-0.5em;border-width:0 3px 3px 0;border-bottom-right-radius:calc(var(--wcf-r) + 6px)}' +
'.wcf-tag{position:absolute;left:50%;bottom:0;display:flex;align-items:center;gap:0.45em;padding:0.3em 0.8em;border-radius:999px;white-space:nowrap;' +
'background:#111827;color:#fff;font-weight:700;font-size:0.85em;line-height:1.2;box-shadow:0 0 0 2px var(--wcf-c1),0 0.4em 1em rgba(0,0,0,.4);transform:translate(-50%,50%)}' +
'.wcf-dot{width:0.55em;height:0.55em;border-radius:50%;background:#ef4444;box-shadow:0 0 0.5em #ef4444}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(el, k) { var v = el.dataset[k]; return v !== undefined && v !== '' ? +v : DEFAULTS[k]; }
function setup(el) {
var d = el.dataset;
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); }).filter(Boolean);
var stops = colors.concat(colors[0]).join(',');
el.classList.add('wcf');
el.style.setProperty('--wcf-w', num(el, 'width') + 'px');
el.style.setProperty('--wcf-r', num(el, 'radius') + 'px');
el.style.setProperty('--wcf-c1', colors[0]);
Array.prototype.forEach.call(el.children, function (c) { c.classList.add('wcf-media'); });
var spins = [];
function layer(cls) {
var l = document.createElement('span');
l.className = 'wcf-layer ' + cls;
l.setAttribute('aria-hidden', 'true');
var ring = l;
if (cls === 'wcf-glow') {
ring = document.createElement('span');
ring.className = 'wcf-layer wcf-ring';
l.appendChild(ring);
}
var spin = document.createElement('span');
spin.className = 'wcf-spin';
spin.style.background = 'conic-gradient(' + stops + ')';
ring.appendChild(spin);
spins.push(spin);
el.appendChild(l);
return l;
}
var glow = d.glow === 'false' ? null : layer('wcf-glow');
var ring = layer('wcf-ring');
var corners = [];
if (d.corners !== 'false') {
['tl', 'tr', 'bl', 'br'].forEach(function (k) {
var c = document.createElement('span');
c.className = 'wcf-corner wcf-' + k;
c.setAttribute('aria-hidden', 'true');
el.appendChild(c);
corners.push(c);
});
}
var tag = null;
if (d.label) {
tag = document.createElement('span');
tag.className = 'wcf-tag';
tag.innerHTML = '<span class="wcf-dot" aria-hidden="true"></span>';
tag.appendChild(document.createTextNode(d.label));
el.appendChild(tag);
}
var s = { spins: spins, glow: glow, ring: ring, corners: corners, tag: tag, angle: 0 };
el._wcf = s;
size(el);
if (window.ResizeObserver) new ResizeObserver(function () { size(el); }).observe(el);
if (reduced()) return;
anime.utils.set([ring, glow].filter(Boolean), { opacity: 0, scale: 1.04 });
anime.utils.set(corners, { opacity: 0, scale: 0.6 });
if (tag) anime.utils.set(tag, { opacity: 0, y: '1em' });
}
// The spinning gradient is a circle big enough to cover the box at any angle.
function size(el) {
var w = el.offsetWidth, h = el.offsetHeight;
var dia = Math.ceil(Math.sqrt(w * w + h * h)) + 4;
el._wcf.spins.forEach(function (sp) {
sp.style.width = sp.style.height = dia + 'px';
sp.style.margin = (-dia / 2) + 'px 0 0 ' + (-dia / 2) + 'px';
});
}
function enter(el) {
var s = el._wcf;
var tl = anime.createTimeline();
tl.add(s.ring, { opacity: [0, 1], scale: [1.04, 1], duration: 700, ease: 'out(3)' });
if (s.glow) tl.add(s.glow, { opacity: [0, 0.75], scale: [1.04, 1], duration: 900, ease: 'out(3)' }, 100);
if (s.corners.length) {
tl.add(s.corners, { opacity: [0, 0.9], scale: [0.6, 1], duration: 600, ease: 'outBack(2)', delay: anime.stagger(80) }, 250);
// Then they breathe in and out, forever.
s.breathe = anime.animate(s.corners, {
scale: [1, 0.9],
duration: 1400, ease: 'inOut(2)', loop: true, alternate: true, delay: 1000, autoplay: true
});
}
if (s.tag) tl.add(s.tag, { opacity: [0, 1], y: ['1em', '0em'], duration: 600, ease: 'outBack(2)' }, 500);
var last = performance.now();
s.timer = anime.createTimer({
onUpdate: function () {
var now = performance.now(), dt = Math.min(100, now - last);
last = now;
s.angle = (s.angle + dt * 360 / (num(el, 'speed') * 1000)) % 360;
s.spins.forEach(function (sp) { sp.style.transform = 'rotate(' + s.angle + 'deg)'; });
}
});
}
function init() {
// The border stops turning while the frame is off screen.
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._wcf;
if (!e.isIntersecting) {
if (s.timer) s.timer.pause();
if (s.breathe) s.breathe.pause();
return;
}
if (!s.timer) enter(e.target);
else { s.timer.play(); if (s.breathe) s.breathe.play(); }
});
}, { threshold: 0.1 });
document.querySelectorAll('[data-webcam-frame]').forEach(function (el) {
if (el._wcf) return;
setup(el);
if (!reduced()) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-webcam-frame data-label="pixel_panda"
style="width: 480px; aspect-ratio: 16 / 9">
<video autoplay muted playsinline></video>
</div>