Minimise to Corner
A panel squeezes down into a small icon in the corner, like a window being minimised into the dock: its height goes first, its width follows, and the icon gives a little bounce as it lands.
Shopping list
How to use it
- Paste the snippet just before
</head>. - Add
data-minimise-to-cornerto the panel. - Point
data-targetat the icon it should shrink into, or leave it off and it shrinks into the bottom right corner.
Options
Add any of these attributes to the same element.
data-target | A CSS selector for the icon it shrinks into, like .dock-notes. The nearest match around the panel is used. The icon bounces as it arrives. |
data-corner | With no target, the corner of the nearest clipping container (or the window) it shrinks into: bottom-right, bottom-left, top-right or top-left. Default bottom-right. |
data-duration | How long the squeeze takes. Default 700 ms. |
data-delay | How long it stays in view before it minimises. Default 1200 ms. |
data-loop | Spring back out of the icon, hold and minimise again. |
data-hold | How long it stays open between goes, when looping. Default 1600 ms. |
It ends hidden (visibility: hidden) but still taking up its space, so position the panel absolutely (or fixed) if the layout shouldn’t keep a gap. To play it when you choose, leave the attribute off and call animacious.minimiseToCorner(el); animacious.restoreFromCorner(el) springs it back out of the icon.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Minimise to Corner: a panel squeezes down into a small icon in a
// corner, like a minimised window going into the dock, and the icon
// gives a little bounce as it arrives.
// Usage: <div data-minimise-to-corner data-target=".dock-icon">…</div>
// It plays once the panel has been in view for data-delay ms, and ends
// hidden (visibility: hidden) but still taking up its space.
// Optional: data-target (a CSS selector for the icon it shrinks into; the
// nearest match around the panel is used), data-corner (with no target,
// the corner of the nearest clipping container, or of the window, it
// shrinks into: "bottom-right", default, "bottom-left", "top-right" or
// "top-left"), data-duration (ms, default 700), data-delay (ms in view
// before it goes, default 1200), data-loop (spring back out of the icon,
// hold and minimise again), data-hold (ms to stay open when looping,
// default 1600).
// Script use: animacious.minimiseToCorner(el, done) minimises any panel
// now, and animacious.restoreFromCorner(el, done) brings it back out.
var DEFAULTS = { corner: 'bottom-right', duration: 700, delay: 1200, hold: 1600 };
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// The icon to aim for: the nearest match for data-target around the panel.
function findTarget(el) {
var sel = el.dataset.target;
if (!sel) return null;
for (var p = el.parentElement; p; p = p.parentElement) {
var t = p.querySelector(sel);
if (t && t !== el && !el.contains(t)) return t;
}
return null;
}
// The box it shrinks into, in viewport px.
function targetBox(el) {
var t = findTarget(el);
if (t) return t.getBoundingClientRect();
var box = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
var s = getComputedStyle(p);
if (s.overflowX !== 'visible' || s.overflowY !== 'visible') {
box = p.getBoundingClientRect();
break;
}
}
var c = el.dataset.corner || DEFAULTS.corner;
var size = 36;
var x = /left/.test(c) ? box.left + 12 : box.right - 12 - size;
var y = /top/.test(c) ? box.top + 12 : box.bottom - 12 - size;
return { left: x, top: y, width: size, height: size };
}
// The transform that lays the panel exactly over the target box.
function pose(el) {
anime.utils.set(el, { x: 0, y: 0, scaleX: 1, scaleY: 1 });
var r = el.getBoundingClientRect();
var g = targetBox(el);
return {
x: g.left - r.left,
y: g.top - r.top,
scaleX: Math.max(0.01, g.width / (r.width || 1)),
scaleY: Math.max(0.01, g.height / (r.height || 1))
};
}
function pop(el) {
var t = findTarget(el);
if (!t || reduced()) return;
anime.animate(t, {
scale: [{ to: 1.22, duration: 160, ease: 'out(2)' }, { to: 1, duration: 520, ease: 'outElastic(1, .45)' }]
});
}
function minimise(el, done) {
var t = +el.dataset.duration || DEFAULTS.duration;
if (reduced()) {
anime.utils.set(el, { opacity: 0 });
if (done) done();
return;
}
var p = pose(el);
el._mtcPose = p;
el.style.transformOrigin = '0 0';
anime.utils.set(el, { opacity: 1 });
// The height goes first and the width follows, so it squeezes rather
// than simply shrinking; the move lags a touch behind both.
anime.animate(el, {
scaleY: { to: p.scaleY, duration: t * 0.75, ease: 'inOut(3)' },
scaleX: { to: p.scaleX, delay: t * 0.12, duration: t * 0.88, ease: 'inOut(3)' },
x: { to: p.x, delay: t * 0.08, duration: t * 0.92, ease: 'inOut(2.5)' },
y: { to: p.y, delay: t * 0.04, duration: t * 0.96, ease: 'inOut(2.5)' },
opacity: { to: 0, delay: t * 0.72, duration: t * 0.28, ease: 'in(2)' },
onComplete: function () {
pop(el);
if (done) done();
}
});
}
function restore(el, done) {
var t = +el.dataset.duration || DEFAULTS.duration;
el.style.visibility = '';
if (reduced()) {
anime.utils.set(el, { x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 1 });
if (done) done();
return;
}
var p = el._mtcPose || pose(el);
el.style.transformOrigin = '0 0';
anime.utils.set(el, { x: p.x, y: p.y, scaleX: p.scaleX, scaleY: p.scaleY, opacity: 0 });
pop(el);
anime.animate(el, {
x: { to: 0, duration: t, ease: 'out(3)' },
y: { to: 0, duration: t, ease: 'out(3)' },
scaleX: { to: 1, duration: t * 0.9, ease: 'out(3)' },
scaleY: { to: 1, delay: t * 0.1, duration: t * 0.9, ease: 'out(3)' },
opacity: { to: 1, duration: t * 0.3, ease: 'out(2)' },
onComplete: function () {
el.style.transformOrigin = '';
if (done) done();
}
});
}
function cycle(el) {
var d = el.dataset;
var s = el._mtc;
minimise(el, function () {
if (d.loop === undefined || d.loop === 'false') {
el.style.visibility = 'hidden';
return;
}
// Sit in the corner a moment, spring back out, then go again once
// it's on screen.
setTimeout(function () {
restore(el, function () {
setTimeout(function () {
if (s.visible) cycle(el);
else s.waiting = true;
}, +d.hold || DEFAULTS.hold);
});
}, 700);
});
}
window.animacious = window.animacious || {};
window.animacious.minimiseToCorner = function (el, done) {
minimise(el, function () {
el.style.visibility = 'hidden';
if (done) done(el);
});
};
window.animacious.restoreFromCorner = function (el, done) {
restore(el, function () {
if (done) done(el);
});
};
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._mtc;
s.visible = e.isIntersecting;
if (!e.isIntersecting) return;
if (!s.started) {
s.started = true;
var wait = e.target.dataset.delay;
setTimeout(function () { cycle(e.target); }, wait === undefined ? DEFAULTS.delay : +wait || 0);
} else if (s.waiting) {
s.waiting = false;
cycle(e.target);
}
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-minimise-to-corner]').forEach(function (el) {
if (el._mtc) return;
el._mtc = {};
if (!reduced()) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div class="window" data-minimise-to-corner data-target=".dock-notes">…</div>
<nav class="dock"><img class="dock-notes" src="notes.svg" alt="Notes"></nav>
<!-- or on demand, with a minimise button and the dock icon to restore it: -->
<script>
minimiseButton.addEventListener('click', function () {
animacious.minimiseToCorner(win);
});
dockIcon.addEventListener('click', function () {
animacious.restoreFromCorner(win);
});
</script>