FAB to Toolbar
A round floating action button waits in the corner. Tap it and it swoops down to the middle of the bottom edge, then stretches sideways into a full-width toolbar as its actions pop in from the centre outwards. The close button at the end shrinks the bar back into a circle that swoops home.
How to use it
- Paste the snippet just before
</head>. - Add
data-fab-to-toolbarto an element holding your toolbar actions, one button each. - Pin that element along the bottom yourself (for example
position: fixed; left: 0; right: 0; bottom: 0): the toolbar spans its width. The script adds the round button and a close button.
Options
Add any of these attributes to the same element.
data-color | Colour of the button and toolbar. Default #6a0dad. |
data-size | Size of the round button, in px. Default 56. |
data-height | Height of the toolbar, in px. Default 56. |
data-inset | Gap between the button and the edges, in px. Default 16. |
data-radius | Corner radius of the toolbar, in px. Default 0. |
data-label | Accessible name for the round button. Default “Open toolbar”. |
data-autoplay | It opens and closes itself while in view, pausing when someone taps. Handy for demos. |
The round button gets aria-expanded, the bar is a role="toolbar" that is out of the tab order while closed, and opening it moves focus to the first action. Escape or picking an action closes it, and it fires a toggle event (e.detail.open). From code, animacious.fabToolbar(el) opens or closes it.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// FAB to Toolbar: a round floating action button sits in the corner. Tap
// it and it swoops down to the middle of the bottom edge, then stretches
// sideways into a full-width toolbar as its actions pop in from the
// centre outwards. The close button at the end shrinks the toolbar back
// into a circle that swoops home to the corner.
// Usage: <div data-fab-to-toolbar>
// <button aria-label="Photo"><svg>…</svg></button>
// <button aria-label="Share"><svg>…</svg></button> …
// </div>
// Each child is a toolbar action. The element is the strip along the
// bottom: give it the width the toolbar should span and pin it down
// yourself (e.g. position: fixed; left: 0; right: 0; bottom: 0).
// The script adds the round button, and a close button at the end of the
// toolbar.
// Optional: data-color (button and toolbar colour, default #6a0dad),
// data-size (button size in px, default 56), data-height (toolbar height
// in px, default 56), data-inset (gap between the button and the edges in
// px, default 16), data-radius (toolbar corner radius in px, default 0),
// data-label (accessible name for the button, default "Open toolbar"),
// 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.
// Script use: animacious.fabToolbar(el) opens or closes it.
var DEFAULTS = { color: '#6a0dad', size: 56, height: 56, inset: 16, radius: 0 };
var css =
':where(.ftb){position:relative}' +
'.ftb{height:var(--ftb-h);color:#fff}' +
'.ftb-shape{position:absolute;top:0;right:var(--ftb-inset);width:var(--ftb-size);height:var(--ftb-size);border-radius:calc(var(--ftb-size) / 2);background:var(--ftb-color);box-shadow:0 8px 22px rgba(30,10,60,.3);pointer-events:none}' +
'.ftb-fab{position:absolute;top:0;right:var(--ftb-inset);z-index:2;display:grid;place-items:center;width:var(--ftb-size);height:var(--ftb-size);padding:0;border:0;border-radius:50%;background:none;color:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.ftb-plus{position:relative;width:38%;height:38%;pointer-events:none}' +
'.ftb-plus::before,.ftb-plus::after{content:"";position:absolute;left:0;top:50%;width:100%;height:2.5px;margin-top:-1.25px;border-radius:2px;background:currentColor}' +
'.ftb-plus::after{transform:rotate(90deg)}' +
'.ftb-bar{position:absolute;left:0;right:0;bottom:0;z-index:1;height:var(--ftb-bar);display:flex;align-items:center;justify-content:space-evenly;padding:0 8px;box-sizing:border-box;visibility:hidden}' +
'.ftb-bar>*{flex:none;display:grid;place-items:center;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:none;color:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.ftb-bar>* svg{width:22px;height:22px}' +
'.ftb-bar>:hover{background:rgba(255,255,255,.14)}' +
'.ftb-x{position:relative}' +
'.ftb-x::before,.ftb-x::after{content:"";position:absolute;left:30%;right:30%;top:50%;height:2px;margin-top:-1px;border-radius:2px;background:currentColor;transform:rotate(45deg)}' +
'.ftb-x::after{transform:rotate(-45deg)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// The shape's spots: in the corner, at the middle of the bar, and as the bar.
function spots(s) {
var W = s.el.offsetWidth, H = s.el.offsetHeight;
var top = H - s.bar - (s.size - s.bar) / 2;
return {
corner: { left: W - s.inset - s.size, top: 0, width: s.size, height: s.size, borderRadius: s.size / 2 },
middle: { left: (W - s.size) / 2, top: top, width: s.size, height: s.size, borderRadius: s.size / 2 },
bar: { left: 0, top: H - s.bar, width: W, height: s.bar, borderRadius: s.radius }
};
}
function px(o) {
var r = {};
for (var k in o) r[k] = o[k] + 'px';
return r;
}
function toggle(el) {
var s = el._ftb;
var open = (s.open = !s.open);
el.classList.toggle('ftb-open', open);
if (s.a11y) {
s.fab.setAttribute('aria-expanded', open);
if (open) s.toolbar.removeAttribute('inert'); else s.toolbar.setAttribute('inert', '');
}
el.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { open: open } }));
anime.utils.remove([s.shape, s.plus].concat(s.items));
var p = spots(s);
var still = reduced();
var tl;
if (open) {
s.toolbar.style.visibility = 'visible';
s.fab.style.visibility = 'hidden';
s.shape.style.right = 'auto';
anime.utils.set(s.shape, px(still ? p.bar : p.corner));
if (still) {
anime.utils.set(s.items, { opacity: 1, scale: 1 });
s.shape.style.width = '100%';
} else {
anime.animate(s.plus, { rotate: 90, opacity: 0, duration: 220, ease: 'in(2)' });
// Across and down at different paces, so it swoops along a curve.
tl = anime.createTimeline({ onComplete: function () { if (s.open) s.shape.style.width = '100%'; } });
tl.add(s.shape, { left: p.middle.left, duration: 300, ease: 'inOut(2)' }, 0)
.add(s.shape, { top: p.middle.top, duration: 300, ease: 'in(3)' }, 0)
.add(s.shape, { left: 0, width: p.bar.width, top: p.bar.top, height: p.bar.height, borderRadius: s.radius, duration: 340, ease: 'out(3)' }, 290)
.add(s.items, { opacity: [0, 1], scale: [0.4, 1], duration: 360, delay: anime.stagger(45, { from: 'center' }), ease: 'outBack(2)' }, 400);
}
if (s.a11y && s.focusIn) setTimeout(function () { if (s.open && s.items[0]) s.items[0].focus({ preventScroll: true }); }, still ? 0 : 450);
} else {
var done = function () {
if (s.open) return;
s.toolbar.style.visibility = 'hidden';
s.fab.style.visibility = '';
s.shape.removeAttribute('style');
};
anime.utils.set(s.shape, px({ left: 0, width: p.bar.width }));
if (still) { anime.utils.set(s.items, { opacity: 0 }); anime.utils.set(s.plus, { rotate: 0, opacity: 1 }); done(); return; }
tl = anime.createTimeline({ onComplete: done });
tl.add(s.items, { opacity: 0, scale: 0.6, duration: 160, ease: 'in(2)' }, 0)
.add(s.shape, { left: p.middle.left, width: p.middle.width, top: p.middle.top, height: p.middle.height, borderRadius: p.middle.borderRadius, duration: 300, ease: 'inOut(3)' }, 100)
.add(s.shape, { left: p.corner.left, duration: 320, ease: 'inOut(2)' }, 390)
.add(s.shape, { top: p.corner.top, duration: 320, ease: 'out(3)' }, 390)
.add(s.plus, { rotate: [90, 0], opacity: [0, 1], duration: 260, ease: 'out(2)' }, 560);
}
}
function setup(el) {
var d = el.dataset;
var items = Array.prototype.slice.call(el.children);
var a11y = !el.closest('[aria-hidden="true"]');
var num = function (v, def) { var n = parseFloat(v); return isNaN(n) ? def : n; };
var s = {
el: el, open: false, a11y: a11y, focusIn: false,
size: num(d.size, DEFAULTS.size), bar: num(d.height, DEFAULTS.height),
inset: num(d.inset, DEFAULTS.inset), radius: num(d.radius, DEFAULTS.radius)
};
el.classList.add('ftb');
el.style.setProperty('--ftb-color', d.color || DEFAULTS.color);
el.style.setProperty('--ftb-size', s.size + 'px');
el.style.setProperty('--ftb-bar', s.bar + 'px');
el.style.setProperty('--ftb-inset', s.inset + 'px');
// Tall enough for the button plus its gap below, and for the bar.
el.style.setProperty('--ftb-h', Math.max(s.size + s.inset, s.bar) + 'px');
var shape = document.createElement('span');
shape.className = 'ftb-shape';
shape.setAttribute('aria-hidden', 'true');
var toolbar = document.createElement('div');
toolbar.className = 'ftb-bar';
items.forEach(function (it) {
toolbar.appendChild(it);
if (a11y && it.tagName === 'BUTTON' && !it.getAttribute('type')) it.type = 'button';
});
var close = document.createElement(a11y ? 'button' : 'span');
close.className = 'ftb-x';
toolbar.appendChild(close);
var fab = document.createElement(a11y ? 'button' : 'span');
fab.className = 'ftb-fab';
var plus = document.createElement('span');
plus.className = 'ftb-plus';
plus.setAttribute('aria-hidden', 'true');
fab.appendChild(plus);
if (a11y) {
toolbar.setAttribute('role', 'toolbar');
toolbar.setAttribute('inert', '');
close.type = 'button';
close.setAttribute('aria-label', 'Close toolbar');
fab.type = 'button';
fab.setAttribute('aria-label', d.label || 'Open toolbar');
fab.setAttribute('aria-expanded', 'false');
}
el.appendChild(shape);
el.appendChild(toolbar);
el.appendChild(fab);
s.shape = shape; s.toolbar = toolbar; s.fab = fab; s.plus = plus;
s.items = Array.prototype.slice.call(toolbar.children);
el._ftb = s;
anime.utils.set(s.items, { opacity: 0 });
fab.addEventListener('click', function () { s.focusIn = true; toggle(el); s.focusIn = false; });
toolbar.addEventListener('click', function (e) {
var t = e.target.closest('.ftb-bar>*');
if (!t || !s.open) return;
if (t.tagName === 'A' && t.getAttribute('href') === '#') e.preventDefault();
var back = t === close && s.a11y;
toggle(el);
if (back) fab.focus({ preventScroll: true });
});
el.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && s.open) { toggle(el); if (s.a11y) fab.focus({ preventScroll: true }); }
});
// Keep the shape in its spot when the width changes.
window.addEventListener('resize', function () {
if (s.open) { anime.utils.remove(shape); shape.style.left = '0px'; shape.style.width = '100%'; }
});
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var timer = null, idleUntil = 0;
el.addEventListener('pointerdown', function () { idleUntil = Date.now() + 8000; });
var tick = function () {
if (Date.now() > idleUntil && !el.contains(document.activeElement)) toggle(el);
timer = setTimeout(tick, s.open ? 2400 : 1500);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(tick, 700);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-fab-to-toolbar]').forEach(function (el) {
if (el._ftb) return;
setup(el);
});
}
window.animacious = window.animacious || {};
window.animacious.fabToolbar = function (el) { if (el._ftb) toggle(el); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-fab-to-toolbar class="dock">
<button aria-label="Photo"><svg>…</svg></button>
<button aria-label="Like"><svg>…</svg></button>
<button aria-label="Share"><svg>…</svg></button>
</div>
<style>.dock { position: fixed; left: 0; right: 0; bottom: 0; }</style>