← Navigation

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

  1. Paste the snippet just before </head>.
  2. Add data-fab-to-toolbar to an element holding your toolbar actions, one button each.
  3. 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-colorColour of the button and toolbar. Default #6a0dad.
data-sizeSize of the round button, in px. Default 56.
data-heightHeight of the toolbar, in px. Default 56.
data-insetGap between the button and the edges, in px. Default 16.
data-radiusCorner radius of the toolbar, in px. Default 0.
data-labelAccessible name for the round button. Default “Open toolbar”.
data-autoplayIt 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.

1 · Snippet
<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>
2 · Your HTML
<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>