Speed Dial
Tap the floating button and its plus spins round into a close icon while smaller actions pop out one after another, stacked above it, each with a little label beside it. Tap again and they tuck back into the button.
How to use it
- Paste the snippet just before
</head>. - Add
data-speed-dialto an element holding your actions (buttons or links). Style them as small round buttons and give each anaria-label; it doubles as the label shown beside it. - Position the element where the button should float, for example fixed in the bottom-right corner. The script adds the main button last; style it with
.sdl-btn.
Options
Add any of these attributes to the same element.
data-direction | Which way the actions stack: up, down, left or right. Default up. |
data-labels | Set to false to hide the labels beside the actions. They only show when stacking up or down. |
data-label | Accessible name for the main button. Default “Open actions”. |
data-autoplay | It opens and closes itself while in view, pausing when someone taps. Handy for demos. |
Escape or picking an action closes it, and it fires a toggle event (e.detail.open). While open the element has the class sdl-open, handy for dimming the page behind it. Give an action a data-label to show a different label from its aria-label.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Speed Dial: a floating action button. Tap it and the plus spins into a
// close icon while smaller actions pop out one after another, stacked
// above it, each with a little label beside it. Tap again and they tuck
// back into the button.
// Usage: <div data-speed-dial>
// <button aria-label="Photo"><svg>…</svg></button>
// <button aria-label="Note"><svg>…</svg></button> …
// </div>
// Each child is an action; style them as small round buttons. The script
// adds the main button after them (style it with the class "sdl-btn") and a
// label beside each action, taken from its data-label or aria-label.
// Position the whole thing yourself (e.g. position: fixed; right: 24px;
// bottom: 24px).
// Optional: data-direction (up, down, left or right; default up),
// data-labels ("false" hides the labels), data-label (accessible name for
// the main button, default "Open actions"), data-autoplay (it opens and
// closes itself while in view; handy for demos).
// Escape or picking an action closes it. Listen for the "toggle" event:
// e.detail.open is true or false. While open the element has the class
// "sdl-open", handy for dimming the page behind it.
// Script use: animacious.speedDial(el) opens or closes it.
var DEFAULTS = { direction: 'up', gap: 12, stagger: 45 };
var css =
'.sdl{position:relative;display:inline-flex;align-items:center;gap:' + DEFAULTS.gap + 'px}' +
'.sdl-up{flex-direction:column}.sdl-down{flex-direction:column-reverse}' +
'.sdl-left{flex-direction:row}.sdl-right{flex-direction:row-reverse}' +
'.sdl-btn{position:relative;z-index:2;flex:none;display:grid;place-items:center;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:#6a0dad;color:#fff;cursor:pointer;-webkit-tap-highlight-color:transparent;box-shadow:0 10px 24px rgba(106,13,173,.35)}' +
'.sdl-plus{position:relative;display:block;width:38%;height:38%;pointer-events:none}' +
'.sdl-plus::before,.sdl-plus::after{content:"";position:absolute;left:0;top:50%;width:100%;height:2.5px;margin-top:-1.25px;border-radius:2px;background:currentColor}' +
'.sdl-plus::after{transform:rotate(90deg)}' +
'.sdl-item{position:relative;z-index:1;flex:none;visibility:hidden;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.sdl-tip{position:absolute;top:50%;right:100%;margin-right:12px;translate:0 -50%;padding:5px 10px;border-radius:8px;background:#1f1630;color:#fff;font-size:12px;font-weight:600;line-height:1.2;white-space:nowrap;pointer-events:none;opacity:0;box-shadow:0 6px 16px rgba(15,10,30,.18)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// How far each action sits from the main button, so it can start tucked behind it.
function offsets(s) {
var b = s.btn;
var bx = b.offsetLeft + b.offsetWidth / 2, by = b.offsetTop + b.offsetHeight / 2;
return s.items.map(function (it) {
return { x: bx - (it.offsetLeft + it.offsetWidth / 2), y: by - (it.offsetTop + it.offsetHeight / 2) };
});
}
function toggle(el) {
var s = el._sdl;
var open = (s.open = !s.open);
el.classList.toggle('sdl-open', open);
if (s.a11y) {
s.btn.setAttribute('aria-expanded', open);
s.items.forEach(function (it) { it.tabIndex = open ? 0 : -1; });
}
el.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { open: open } }));
var at = offsets(s);
anime.utils.remove(s.items.concat(s.tips, [s.plus]));
if (open) s.items.forEach(function (it) { it.style.visibility = 'visible'; });
if (reduced()) {
anime.utils.set(s.items, { x: 0, y: 0, scale: open ? 1 : 0, opacity: open ? 1 : 0 });
anime.utils.set(s.tips, { x: 0, opacity: open ? 1 : 0 });
anime.utils.set(s.plus, { rotate: open ? 135 : 0 });
if (!open) s.items.forEach(function (it) { it.style.visibility = 'hidden'; });
return;
}
var n = s.items.length;
// The action nearest the button (the last one) moves first.
var order = function (it, k) { return (n - 1 - k) * DEFAULTS.stagger; };
anime.animate(s.plus, { rotate: open ? 135 : 0, duration: 480, ease: open ? 'outBack(2)' : 'out(3)' });
anime.animate(s.btn, { scale: [0.9, 1], duration: 500, ease: 'outElastic(1, .5)' });
if (open) {
anime.animate(s.items, {
x: { from: function (it, k) { return at[k].x * 0.6; }, to: 0 },
y: { from: function (it, k) { return at[k].y * 0.6; }, to: 0 },
scale: [0.3, 1],
opacity: [0, 1],
duration: 460,
delay: order,
ease: 'outBack(1.8)'
});
anime.animate(s.tips, {
x: [10, 0], opacity: [0, 1],
duration: 320,
delay: function (t, k) { return 140 + order(t, k); },
ease: 'out(3)'
});
} else {
anime.animate(s.tips, { x: 6, opacity: 0, duration: 160, ease: 'in(2)' });
anime.animate(s.items, {
x: function (it, k) { return at[k].x * 0.6; },
y: function (it, k) { return at[k].y * 0.6; },
scale: 0.3,
opacity: 0,
duration: 260,
delay: function (it, k) { return k * 30; },
ease: 'in(3)',
onComplete: function () { if (!s.open) s.items.forEach(function (it) { it.style.visibility = 'hidden'; }); }
});
}
}
function setup(el) {
var d = el.dataset;
var items = Array.prototype.slice.call(el.children);
if (!items.length) return;
var a11y = !el.closest('[aria-hidden="true"]');
var dir = d.direction || DEFAULTS.direction;
if (!/^(up|down|left|right)$/.test(dir)) dir = DEFAULTS.direction;
el.classList.add('sdl', 'sdl-' + dir);
var btn = document.createElement(a11y ? 'button' : 'span');
btn.className = 'sdl-btn';
var plus = document.createElement('span');
plus.className = 'sdl-plus';
plus.setAttribute('aria-hidden', 'true');
btn.appendChild(plus);
if (a11y) {
btn.type = 'button';
btn.setAttribute('aria-label', d.label || 'Open actions');
btn.setAttribute('aria-expanded', 'false');
}
var tips = [];
var showTips = d.labels !== 'false' && (dir === 'up' || dir === 'down');
items.forEach(function (it) {
it.classList.add('sdl-item');
if (a11y) {
it.tabIndex = -1;
if (it.tagName === 'BUTTON' && !it.getAttribute('type')) it.type = 'button';
}
var text = it.getAttribute('data-label') || it.getAttribute('aria-label');
if (showTips && text) {
var tip = document.createElement('span');
tip.className = 'sdl-tip';
tip.setAttribute('aria-hidden', 'true');
tip.textContent = text;
it.appendChild(tip);
tips.push(tip);
}
});
el.appendChild(btn);
var s = el._sdl = { btn: btn, plus: plus, items: items, tips: tips, open: false, a11y: a11y };
anime.utils.set(items, { scale: 0.3, opacity: 0 });
btn.addEventListener('click', function () { toggle(el); });
items.forEach(function (it) {
it.addEventListener('click', function (e) {
if (it.tagName === 'A' && it.getAttribute('href') === '#') e.preventDefault();
if (s.open) toggle(el);
});
});
el.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && s.open) { toggle(el); if (s.a11y) btn.focus(); }
});
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var timer = null, idleUntil = 0, canHover = window.matchMedia('(hover: hover)').matches;
el.addEventListener('pointerdown', function () { idleUntil = Date.now() + 8000; });
var tick = function () {
if (Date.now() > idleUntil && !(canHover && el.matches(':hover')) && !el.contains(document.activeElement)) toggle(el);
timer = setTimeout(tick, s.open ? 2400 : 1400);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(tick, 700);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-speed-dial]').forEach(function (el) {
if (el._sdl) return;
setup(el);
});
}
window.animacious = window.animacious || {};
window.animacious.speedDial = function (el) { if (el._sdl) toggle(el); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-speed-dial style="position:fixed; right:24px; bottom:24px">
<button aria-label="Photo"><svg>…</svg></button>
<button aria-label="Message"><svg>…</svg></button>
<button aria-label="Share"><svg>…</svg></button>
</div>