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
- Paste the snippet just before
</head>. - Add
data-bookmark-saveto a button. The script adds the bookmark in front of your label; leave the button empty, with anaria-label, for the icon on its own. - Listen for its
changeevent;event.detail.savedsays which way it went.
Options
Add any of these attributes to the same element.
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. |
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.
<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><button data-bookmark-save>Save</button>
<!-- icon only, already saved: -->
<button data-bookmark-save data-saved aria-label="Save"></button>