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
- Paste the snippet just before
</head>. - Add
data-dynamic-notchto an element at the top of your screen or app bar. It centres itself. - Put one child with
data-notch-statusinside for each status. The pill grows to fit whichever one it shows. - 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-hold | How long each status stays open. Default 2800 ms. |
data-width | Width of the closed pill. Default 112 px. |
data-height | Height of the closed pill. Default 32 px. |
data-autoplay | Shows each status in turn while in view, until a button is used. Handy for demos. |
data-notch-count | Inside a status: counts down this many seconds while it’s open, shown as 4:59. |
data-notch-progress | Inside 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.
<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><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>