← Overlays

Dynamic Notch

A small black pill at the top of the screen springs open into a live status, a call, a ticking timer or an upload filling up, then shrinks back into a pill once it’s said its piece.

SMobileSam Carter
⏱ Timer
☁️Uploading 3 photos
9:41Tuesday 9 June

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-dynamic-notch to an element at the top of your screen or app bar. It centres itself.
  3. Put one child with data-notch-status inside for each status. The pill grows to fit whichever one it shows.
  4. Show one from a button with data-notch-show="#that-id" data-status="0" (counting from 0), or from code.

Options

Add any of these attributes to the same element.

data-holdHow long each status stays open. Default 2800 ms.
data-widthWidth of the closed pill. Default 112 px.
data-heightHeight of the closed pill. Default 32 px.
data-autoplayShows each status in turn while in view, until a button is used. Handy for demos.
data-notch-countInside a status: counts down this many seconds while it’s open, shown as 4:59.
data-notch-progressInside a status: a bar that fills up while it’s open.

The pill is a polite live region, so screen readers announce each status as it opens. From code: animacious.notch(pill, 1) shows the second status and animacious.notch(pill, 1, { hold: 5000 }) keeps it open longer.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Dynamic Notch: a small black pill at the top of the screen springs open
    // into a live status (a call, a timer, an upload…), stays a moment while it
    // updates, then shrinks back into a pill.
    // Usage:
    //   <div data-dynamic-notch>
    //     <div data-notch-status>📞 Sam is calling…</div>
    //     <div data-notch-status>⏱ <span data-notch-count="300"></span></div>
    //     <div data-notch-status>Uploading <i data-notch-progress></i></div>
    //   </div>
    // Each data-notch-status child is one status; the pill takes its size. Put
    // the pill at the top of your screen or app bar (it centres itself).
    // Live bits inside a status: data-notch-count="300" counts down that many
    // seconds (shown as 4:59), and data-notch-progress fills up while it's open.
    // Optional: data-hold (ms each status stays open, default 2800),
    // data-width / data-height (the closed pill in px, default 112 x 32),
    // data-autoplay (it shows each status in turn while in view, until a
    // trigger is used).
    // Show a status from a button: data-notch-show="#notch" data-status="1"
    // (counting from 0).
    // Script use: animacious.notch(el, 1) or animacious.notch(el, 1, { hold: 4000 }).
    var DEFAULTS = { hold: 2800, width: 112, height: 32, gap: 900 };

    var css =
      '.dnt{position:relative;box-sizing:border-box;margin:0 auto;overflow:hidden;background:#000;color:#fff;border-radius:99px;font:600 14px/1.25 system-ui,sans-serif;text-align:left;-webkit-font-smoothing:antialiased}' +
      '.dnt-cam{position:absolute;top:50%;right:12px;width:10px;height:10px;margin-top:-5px;border-radius:50%;background:radial-gradient(circle at 40% 35%,#3b3b58 0 25%,#141420 45%)}' +
      '.dnt-s{position:absolute;left:0;top:0;width:max-content;box-sizing:border-box;visibility:hidden;opacity:0}' +
      ':where(.dnt-s){padding:10px 16px}' +
      '.dnt-s [data-notch-progress]{display:block;height:5px;border-radius:5px;background:rgba(255,255,255,.18);overflow:hidden;position:relative}' +
      '.dnt-s [data-notch-progress]::after{content:"";position:absolute;inset:0;border-radius:inherit;background:var(--dnt-fill,#a3e635);transform-origin:0 50%;transform:scaleX(var(--dnt-p,0))}' +
      '.dnt-s [data-notch-count]{font-variant-numeric:tabular-nums}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }
    function clock(s) { s = Math.max(0, s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }

    function setup(el) {
      var d = el.dataset;
      var n = {
        el: el, at: -1, busy: null,
        w: +d.width || DEFAULTS.width, h: +d.height || DEFAULTS.height,
        hold: +d.hold || DEFAULTS.hold,
        statuses: Array.from(el.querySelectorAll('[data-notch-status]'))
      };
      el._dnt = n;
      el.classList.add('dnt');
      if (!el.closest('[aria-hidden="true"]')) {
        el.setAttribute('role', 'status');
        el.setAttribute('aria-live', 'polite');
      }
      el.style.width = n.w + 'px';
      el.style.height = n.h + 'px';
      n.statuses.forEach(function (s) {
        s.classList.add('dnt-s');
        s.setAttribute('aria-hidden', 'true');
        s.querySelectorAll('[data-notch-count]').forEach(function (c) { c.textContent = clock(+c.getAttribute('data-notch-count') || 0); });
      });
      var cam = document.createElement('span');
      cam.className = 'dnt-cam';
      cam.setAttribute('aria-hidden', 'true');
      el.appendChild(cam);
      n.cam = cam;
      return n;
    }

    // Live bits: countdowns tick each second, progress bars fill over the hold.
    function live(n, s, hold) {
      var counts = Array.from(s.querySelectorAll('[data-notch-count]'));
      var bars = Array.from(s.querySelectorAll('[data-notch-progress]'));
      var t0 = performance.now();
      var start = counts.map(function (c) { return +c.getAttribute('data-notch-count') || 0; });
      counts.forEach(function (c, i) { c.textContent = clock(start[i]); });
      bars.forEach(function (b) { b.style.setProperty('--dnt-p', 0); });
      if (reduced()) { bars.forEach(function (b) { b.style.setProperty('--dnt-p', 1); }); return function () {}; }
      var raf = 0;
      (function frame(now) {
        var t = (now || t0) - t0;
        counts.forEach(function (c, i) { c.textContent = clock(start[i] - Math.floor(t / 1000)); });
        // Eased fill that lands just as the status closes.
        var p = Math.min(1, t / (hold * 0.92));
        p = 1 - Math.pow(1 - p, 2);
        bars.forEach(function (b) { b.style.setProperty('--dnt-p', p.toFixed(3)); });
        raf = requestAnimationFrame(frame);
      })();
      return function () { cancelAnimationFrame(raf); };
    }

    function show(n, i, opts) {
      var s = n.statuses[i];
      if (!s) return;
      opts = opts || {};
      var hold = opts.hold || n.hold, el = n.el;
      if (n.busy) n.busy();
      var cancelled = false, timers = [], stop = function () {};
      n.busy = function () { cancelled = true; timers.forEach(clearTimeout); stop(); };
      var prev = n.statuses[n.at];
      n.at = i;
      n.statuses.forEach(function (o) { if (o !== s) { anime.utils.remove(o); o.style.visibility = 'hidden'; o.style.opacity = 0; o.setAttribute('aria-hidden', 'true'); } });
      anime.utils.remove([el, s, n.cam]);
      s.style.visibility = 'visible';
      s.removeAttribute('aria-hidden');
      var w = s.offsetWidth, h = s.offsetHeight;
      var max = el.parentNode.clientWidth - 16;
      if (w > max) { s.style.width = max + 'px'; w = max; h = s.offsetHeight; }
      var r = Math.min(h / 2, 28);
      stop = live(n, s, hold);

      if (reduced()) {
        anime.utils.set(el, { width: w, height: h, borderRadius: r });
        anime.utils.set(s, { opacity: 1 });
        anime.utils.set(n.cam, { opacity: 0 });
        timers.push(setTimeout(function () { if (!cancelled) close(n); }, hold));
        return;
      }
      var spring = anime.spring({ bounce: 0.32, duration: 620 });
      anime.animate(n.cam, { opacity: 0, duration: 120 });
      anime.animate(el, { width: w, height: h, borderRadius: r, ease: spring });
      anime.animate(s, { opacity: [0, 1], scale: [0.9, 1], filter: ['blur(6px)', 'blur(0px)'], duration: 380, delay: prev ? 120 : 160, ease: 'out(3)' });
      timers.push(setTimeout(function () { if (!cancelled) close(n); }, hold));
    }

    function close(n) {
      var el = n.el, s = n.statuses[n.at];
      if (n.busy) n.busy();
      n.busy = null;
      n.at = -1;
      if (!s) return;
      var done = function () { s.style.visibility = 'hidden'; s.setAttribute('aria-hidden', 'true'); s.style.width = ''; };
      if (reduced()) {
        anime.utils.set(el, { width: n.w, height: n.h, borderRadius: n.h / 2 });
        anime.utils.set(s, { opacity: 0 });
        anime.utils.set(n.cam, { opacity: 1 });
        return done();
      }
      anime.animate(s, { opacity: 0, scale: 0.94, filter: 'blur(4px)', duration: 180, ease: 'in(2)', onComplete: done });
      anime.animate(el, { width: n.w, height: n.h, borderRadius: n.h / 2, delay: 90, ease: anime.spring({ bounce: 0.28, duration: 560 }) });
      anime.animate(n.cam, { opacity: 1, duration: 200, delay: 360 });
    }

    // Demo mode: each status in turn, with a pause as a pill in between.
    function autoplay(n) {
      var timer = null, visible = false, i = 0;
      n.auto = true;
      function next() {
        clearTimeout(timer);
        if (!visible || !n.auto) return;
        show(n, i++ % n.statuses.length);
        timer = setTimeout(next, n.hold + DEFAULTS.gap + 500);
      }
      n.stopAuto = function () { n.auto = false; clearTimeout(timer); };
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) { clearTimeout(timer); timer = setTimeout(next, 600); }
        else clearTimeout(timer);
      }, { threshold: 0.3 }).observe(n.el);
    }

    document.addEventListener('click', function (e) {
      var t = e.target.closest && e.target.closest('[data-notch-show]');
      if (!t) return;
      var el;
      try { el = document.querySelector(t.getAttribute('data-notch-show')); } catch (err) { return; }
      if (!el || !el.hasAttribute('data-dynamic-notch')) return;
      var n = el._dnt || setup(el);
      if (n.stopAuto) n.stopAuto();
      show(n, +t.getAttribute('data-status') || 0);
    });

    function init() {
      document.querySelectorAll('[data-dynamic-notch]').forEach(function (el) {
        if (el._dnt) return;
        var n = setup(el);
        if (on(el.dataset.autoplay) && !reduced() && n.statuses.length) autoplay(n);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.notch = function (el, i, opts) {
      var n = el._dnt || setup(el);
      if (n.stopAuto) n.stopAuto();
      if (i === false || i === undefined) close(n); else show(n, i, opts);
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div id="notch" data-dynamic-notch>
  <div data-notch-status>📞 Sam is calling…</div>
  <div data-notch-status>⏱ <span data-notch-count="300"></span></div>
  <div data-notch-status>Uploading <i data-notch-progress></i></div>
</div>

<button data-notch-show="#notch" data-status="1">Start timer</button>