← Navigation

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

  1. Paste the snippet just before </head>.
  2. Add data-speed-dial to an element holding your actions (buttons or links). Style them as small round buttons and give each an aria-label; it doubles as the label shown beside it.
  3. 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-directionWhich way the actions stack: up, down, left or right. Default up.
data-labelsSet to false to hide the labels beside the actions. They only show when stacking up or down.
data-labelAccessible name for the main button. Default “Open actions”.
data-autoplayIt 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.

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