← Broadcast

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

  1. Paste the snippet just before </head>.
  2. Add data-webcam-frame to 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-colorsComma-separated border colours. Default purple, pink, amber and blue.
data-speedSeconds for the colours to go once round. Default 4.
data-widthBorder thickness in pixels. Default 4.
data-radiusCorner radius in pixels. Default 16.
data-labelText 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.

1 · Snippet
<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>
2 · Your HTML
<div data-webcam-frame data-label="pixel_panda"
     style="width: 480px; aspect-ratio: 16 / 9">
  <video autoplay muted playsinline></video>
</div>