← Buttons

Bookmark Save

Press save and a drop of colour falls into the empty bookmark. It dips under the splash and springs back as the colour fills it from the top down, and the label turns to “Saved”. Press again and the colour drains away.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-bookmark-save to a button. The script adds the bookmark in front of your label; leave the button empty, with an aria-label, for the icon on its own.
  3. Listen for its change event; event.detail.saved says which way it went.

Options

Add any of these attributes to the same element.

data-onLabel while saved. Default “Saved”.
data-colorColour of the fill. Default #6a0dad.
data-savedStart saved.
data-autoplayIt saves and unsaves itself every couple of seconds while in view, until someone presses it. Handy for demos.

While saved the button has the class bkm-on and aria-pressed="true". Size it with font-size. With reduced motion it fills straight away.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Bookmark Save: press it and a drop falls into the empty bookmark, which
    // dips under the splash and fills with colour from the top down, the
    // label changing to "Saved". Press again and the colour drains away.
    // Usage: <button data-bookmark-save>Save</button>
    // (or an empty button with an aria-label for the icon on its own)
    // Optional: data-on (label while saved, default "Saved"), data-color
    // (colour of the fill, default #6a0dad), data-saved (start saved),
    // data-autoplay (it saves and unsaves itself every couple of seconds
    // while in view, until someone presses it; handy for demos).
    // Events: fires "change" on the button with event.detail.saved.
    var DEFAULTS = { on: 'Saved', color: '#6a0dad', every: 2400 };
    var PATH = 'M7 2.8h10a1.6 1.6 0 0 1 1.6 1.6V21l-6.6-4.6L5.4 21V4.4A1.6 1.6 0 0 1 7 2.8Z';
    var uid = 0;

    var css =
      ':where(.bkm){font:inherit;font-weight:700;padding:.65em 1.2em .65em 1em;border-radius:999px}' +
      '.bkm{position:relative;display:inline-flex;align-items:center;gap:.45em;cursor:pointer;white-space:nowrap;color:#27272a;' +
      'background:#fff;border:2px solid #e4e4e7;box-shadow:0 4px 14px rgba(15,15,30,.08);transition:border-color .3s,color .3s;-webkit-tap-highlight-color:transparent}' +
      '.bkm.bkm-on{border-color:color-mix(in srgb,var(--bkm-c) 40%,transparent);color:var(--bkm-c)}' +
      '.bkm:focus-visible{outline:3px solid var(--bkm-c);outline-offset:3px}' +
      '.bkm.bkm-solo{padding:.6em;border-radius:50%}' +
      '.bkm-icon{position:relative;width:1.3em;height:1.3em;flex:none;overflow:visible;transform-origin:50% 0}' +
      '.bkm-line{fill:none;stroke:currentColor;stroke-width:2;stroke-linejoin:round}' +
      '.bkm-fill{fill:var(--bkm-c)}' +
      '.bkm-drop{fill:var(--bkm-c);opacity:0;transform-box:fill-box;transform-origin:50% 100%}' +
      '.bkm-labels{display:inline-grid;overflow:hidden}' +
      '.bkm-labels>span{grid-area:1/1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var id = 'bkm-clip-' + (++uid);
      el.classList.add('bkm');
      el.style.setProperty('--bkm-c', d.color || DEFAULTS.color);
      var offText = el.textContent.trim();
      el.textContent = '';
      el.insertAdjacentHTML('afterbegin',
        '<svg class="bkm-icon" viewBox="0 0 24 24" aria-hidden="true">' +
        '<clipPath id="' + id + '"><path d="' + PATH + '"/></clipPath>' +
        '<rect class="bkm-fill" clip-path="url(#' + id + ')" x="4" y="2" width="16" height="0"/>' +
        '<path class="bkm-line" d="' + PATH + '"/>' +
        '<path class="bkm-drop" d="M12 -9c1.6 2.2 2.4 3.6 2.4 4.8a2.4 2.4 0 0 1-4.8 0c0-1.2.8-2.6 2.4-4.8Z"/></svg>');
      var icon = el.firstChild;
      var fill = icon.querySelector('.bkm-fill');
      var drop = icon.querySelector('.bkm-drop');
      var off = null, on = null;
      if (offText) {
        var labels = document.createElement('span');
        labels.className = 'bkm-labels';
        off = document.createElement('span');
        off.textContent = offText;
        on = document.createElement('span');
        on.textContent = d.on || DEFAULTS.on;
        labels.appendChild(off);
        labels.appendChild(on);
        el.appendChild(labels);
      } else {
        el.classList.add('bkm-solo');
      }
      var saved = false;
      var level = { h: 0 };
      function draw() { fill.setAttribute('height', level.h); }

      function label(state, t) {
        if (!off) return;
        var a = state ? on : off, b = state ? off : on;
        a.style.visibility = '';
        b.setAttribute('aria-hidden', 'true');
        a.removeAttribute('aria-hidden');
        anime.animate(b, { opacity: 0, y: t ? '-60%' : 0, duration: 200 * t, ease: 'in(2)', onComplete: function () { b.style.visibility = 'hidden'; } });
        anime.animate(a, { opacity: [0, 1], y: [t ? '60%' : 0, '0%'], duration: 320 * t, delay: 120 * t, ease: 'out(3)' });
      }

      function show(state, animate) {
        saved = state;
        el.classList.toggle('bkm-on', state);
        el.setAttribute('aria-pressed', state ? 'true' : 'false');
        var t = animate && !reduced() ? 1 : 0;
        label(state, t);
        anime.utils.remove(level);
        if (!t) { level.h = state ? 20 : 0; draw(); return; }
        if (state) {
          // The drop falls in, the bookmark dips, then the colour pours down.
          anime.animate(drop, { opacity: [0, 1, 1, 0], y: [0, 15], scaleY: [1.2, 1.4, 0.6], duration: 300, ease: 'in(2)' });
          anime.animate(icon, {
            keyframes: [
              { y: 0, duration: 260 },
              { y: '18%', scaleY: 0.86, scaleX: 1.1, duration: 120, ease: 'out(2)' },
              { y: '0%', scaleY: 1, scaleX: 1, duration: 620, ease: 'outElastic(1, .5)' }
            ]
          });
          anime.animate(level, { h: 20, duration: 560, delay: 270, ease: 'out(2)', onUpdate: draw });
          anime.animate(el, { scale: [1, 1.05, 1], duration: 420, delay: 260, ease: 'out(2)' });
        } else {
          anime.animate(level, { h: 0, duration: 380, ease: 'inOut(2)', onUpdate: draw });
          anime.animate(icon, { y: ['0%', '-10%', '0%'], duration: 360, ease: 'out(2)' });
        }
      }

      show(d.saved !== undefined && d.saved !== 'false', false);

      var userTouched = false;
      el.addEventListener('click', function (e) {
        if (e.isTrusted) userTouched = true;
        show(!saved, true);
        el.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { saved: saved } }));
      });

      if (!reduced() && d.autoplay !== undefined && d.autoplay !== 'false') {
        var timer = null;
        var loop = function () {
          if (userTouched) return;
          if (!el.matches(':hover')) show(!saved, true);
          timer = setTimeout(loop, DEFAULTS.every);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(loop, 700);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-bookmark-save]').forEach(function (el) {
        if (el._bkm) return;
        el._bkm = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button data-bookmark-save>Save</button>

<!-- icon only, already saved: -->
<button data-bookmark-save data-saved aria-label="Save"></button>