Padlock Unlock
The keyhole clicks, the lock gives a little jiggle, then the shackle lifts and the padlock springs open with a bounce. Lock it again and the shackle swings back and drops home with a thud. Tap it to toggle.
Tap the padlock to lock or unlock it
How to use it
- Paste the snippet just before
</head>. - Add
data-padlock-unlockto an empty<span>or<button>. Size it withfont-sizeand colour it withcolor.
Options
Add any of these attributes to the same element.
data-loop | Unlock, hold, lock and repeat, until someone taps it. |
data-hold | How long it stays open or shut, when looping. Default 1600 ms. |
data-locked | Stay locked until tapped, instead of unlocking when scrolled into view. |
data-open-color | Body colour once it’s open, e.g. #16a34a. |
data-shackle | Shackle colour. Default #a1a1aa. |
data-delay | Wait before it unlocks. |
It unlocks when scrolled into view. Its aria-label says whether it’s locked or unlocked. Listen for the toggle event (e.detail.open), or call animacious.padlock(el) to toggle it from code. With reduced motion it simply shows open.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Padlock Unlock: the keyhole clicks, the shackle lifts and the padlock springs
// open with a little bounce. Lock it again and the shackle drops back in
// with a thud. It unlocks when scrolled into view; tap it to toggle.
// Usage: <span data-padlock-unlock></span> (size with font-size, colour with color)
// Optional: data-loop (unlock, hold, lock and repeat), data-hold (ms it stays
// open or shut when looping, default 1600), data-delay (ms before it starts),
// data-locked (stay locked until tapped), data-shackle (shackle colour,
// default #a1a1aa), data-open-color (body colour once open, e.g. #22c55e).
// Listen for the "toggle" event: e.detail.open is true or false.
// Script use: animacious.padlock(el) toggles it from code.
var DEFAULTS = { hold: 1600, shackle: '#a1a1aa' };
var NS = 'http://www.w3.org/2000/svg';
var css =
'.pdl{display:inline-block;line-height:0;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.pdl svg{display:block;width:1em;height:1.3em;overflow:visible}' +
'.pdl-shackle{transform-origin:44px 44px}' +
'.pdl-body{transform-origin:32px 72px}' +
'.pdl-case{transition:fill .45s ease}' +
'.pdl-key{transform-origin:32px 54px}' +
'.pdl-ray{transform-origin:16px 26px;opacity:0}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function build(el) {
var shackle = el.dataset.shackle || DEFAULTS.shackle;
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 -6 64 84');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML =
// The shackle sits behind the body; its left leg is the short, free one.
'<path class="pdl-shackle" d="M20 41 V26 a12 12 0 0 1 24 0 V46" fill="none" stroke="' + shackle + '" stroke-width="7" stroke-linecap="round"/>' +
'<g class="pdl-rays" stroke="currentColor" stroke-width="2.6" stroke-linecap="round">' +
'<path class="pdl-ray" d="M9 22 L4 19"/><path class="pdl-ray" d="M9 30 L3 31"/><path class="pdl-ray" d="M13 15 L10 10"/></g>' +
'<g class="pdl-body"><rect class="pdl-case" x="8" y="38" width="48" height="34" rx="9" fill="currentColor"/>' +
'<rect x="8" y="38" width="48" height="8" rx="4" fill="#fff" opacity=".14"/>' +
'<g class="pdl-key" fill="#fff" opacity=".92"><circle cx="32" cy="52" r="4.6"/><rect x="30" y="53" width="4" height="9" rx="2"/></g></g>';
el.appendChild(svg);
return {
el: el, open: false,
shackle: svg.querySelector('.pdl-shackle'),
body: svg.querySelector('.pdl-body'),
caseEl: svg.querySelector('.pdl-case'),
key: svg.querySelector('.pdl-key'),
rays: svg.querySelectorAll('.pdl-ray'),
color: el.dataset.openColor
};
}
function label(s) {
s.el.setAttribute('aria-label', s.open ? 'Unlocked' : 'Locked');
}
function place(s) {
anime.utils.set(s.shackle, { y: s.open ? -10 : 0, rotate: s.open ? 30 : 0 });
if (s.color) s.caseEl.style.fill = s.open ? s.color : '';
}
function unlock(s) {
s.open = true;
label(s);
s.el.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { open: true } }));
if (reduced()) { place(s); return; }
var tl = anime.createTimeline();
// Click the keyhole, jiggle the lock, then the shackle pops up and swings.
tl.add(s.key, { scale: [1, 0.8, 1], duration: 320, ease: 'inOut(2)' })
.add(s.body, { x: [0, -2, 2, -1, 0], duration: 260, ease: 'linear' }, '-=60')
.add(s.shackle, { y: -10, duration: 260, ease: 'out(3)' })
.add(s.shackle, { rotate: 30, duration: 900, ease: 'outElastic(1, .45)' }, '-=120')
.add(s.body, { scaleY: [1, 0.9, 1], scaleX: [1, 1.05, 1], duration: 420, ease: 'out(2)' }, '<<')
.add(s.rays, { opacity: [0, 1, 0], scale: [0.4, 1.2], duration: 520, ease: 'out(2)', delay: anime.stagger(50) }, '<<')
.call(function () { if (s.color) s.caseEl.style.fill = s.color; }, '<<');
}
function lock(s) {
s.open = false;
label(s);
s.el.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { open: false } }));
if (s.color) s.caseEl.style.fill = '';
if (reduced()) { place(s); return; }
var tl = anime.createTimeline();
tl.add(s.shackle, { rotate: 0, duration: 380, ease: 'inOut(3)' })
.add(s.shackle, { y: 0, duration: 200, ease: 'in(3)' })
// A thud as it clicks home.
.add(s.body, { y: [0, 3, 0], duration: 320, ease: 'out(2)' }, '-=20')
.add(s.key, { scale: [1, 0.8, 1], duration: 300, ease: 'inOut(2)' }, '-=200');
}
function toggle(s) { if (s.open) lock(s); else unlock(s); }
function start(s) {
var d = s.el.dataset;
var hold = +d.hold || DEFAULTS.hold;
var loop = d.loop !== undefined && d.loop !== 'false';
var locked = d.locked !== undefined && d.locked !== 'false';
var cycle = function () {
if (s.stopped) return;
toggle(s);
if (loop) s.timer = setTimeout(cycle, hold + (s.open ? 1300 : 600));
};
if (!locked) s.timer = setTimeout(cycle, +d.delay || 300);
}
function setup(el) {
el.classList.add('pdl');
var s = build(el);
el._pdl = s;
label(s);
place(s);
// In a decorative preview (aria-hidden), it just plays: no tapping.
if (el.closest('[aria-hidden="true"]')) return;
if (!el.getAttribute('role') && el.tagName !== 'BUTTON') {
el.setAttribute('role', 'button');
el.tabIndex = 0;
}
var tap = function () {
s.stopped = true;
clearTimeout(s.timer);
toggle(s);
};
el.addEventListener('click', tap);
el.addEventListener('keydown', function (e) {
if (el.tagName === 'BUTTON') return;
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tap(); }
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
start(e.target._pdl);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-padlock-unlock]').forEach(function (el) {
if (el._pdl) return;
setup(el);
if (reduced()) {
if (el.dataset.locked === undefined || el.dataset.locked === 'false') { el._pdl.open = true; label(el._pdl); place(el._pdl); }
} else io.observe(el);
});
}
window.animacious = window.animacious || {};
window.animacious.padlock = function (el) { if (el._pdl) toggle(el._pdl); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><span data-padlock-unlock style="font-size: 96px; color: #7c3aed"></span>
<!-- locked until tapped, turning green when open: -->
<button data-padlock-unlock data-locked data-open-color="#16a34a"></button>