3D Flip Button
Hover the button and it tips over in 3D, like a card turning on a hinge through its middle, to show a second label on its dark back. Move away and it flips back. On a phone a tap flips it over and it flips back a moment later.
How to use it
- Paste the snippet just before
</head>. - Add
data-flip-buttonto a button, with the label for its back indata-back.
Options
Add any of these attributes to the same element.
data-back | Label on the back. Default the front label. |
data-color | Colour of the front. Default #6a0dad. |
data-back-color | Colour of the back. Default #18181b. |
data-axis | x to tip over top to bottom, or y to turn side to side. Default x. |
data-duration | How long a flip takes. Default 700 ms. |
data-autoplay | It flips over and back every couple of seconds while in view, until someone uses it. Handy for demos. |
The button takes the size of its longer label, so neither face is cut off. Screen readers hear the front label. Keyboard focus flips it too. Size it with font-size.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// 3D Flip Button: hover the button and it flips over in 3D, like a card
// on a hinge through its middle, to show a second label on its back.
// Move away and it flips back. On touch screens a tap flips it over and
// it flips back a moment later. Keyboard focus flips it too.
// Usage: <button data-flip-button data-back="Let's go →">Get started</button>
// Optional: data-back (label on the back, default the front label),
// data-color (front colour, default #6a0dad), data-back-color (back
// colour, default #18181b), data-axis ("x" to tip over top to bottom,
// the default, or "y" to turn side to side), data-duration (ms, default
// 700), data-autoplay (it flips over and back by itself every couple of
// seconds while in view, until someone uses it; handy for demos).
var DEFAULTS = { color: '#6a0dad', back: '#18181b', axis: 'x', duration: 700, every: 1800 };
var css =
'.fbn{position:relative;display:inline-grid;padding:0;border:0;background:none;font:inherit;font-weight:700;color:#fff;cursor:pointer;' +
'perspective:40em;-webkit-tap-highlight-color:transparent}' +
'.fbn:focus-visible{outline:none}' +
'.fbn:focus-visible .fbn-face{outline:3px solid var(--fbn-c);outline-offset:3px}' +
'.fbn-inner{display:grid;transform-style:preserve-3d}' +
'.fbn-face{grid-area:1/1;display:flex;align-items:center;justify-content:center;gap:.4em;white-space:nowrap;' +
'padding:.85em 1.7em;border-radius:.7em;-webkit-backface-visibility:hidden;backface-visibility:hidden}' +
'.fbn-front{background:var(--fbn-c);box-shadow:0 .5em 1.3em color-mix(in srgb,var(--fbn-c) 30%,transparent),inset 0 1px 0 rgba(255,255,255,.22)}' +
'.fbn-back{background:var(--fbn-b);box-shadow:0 .5em 1.3em rgba(15,15,30,.25),inset 0 1px 0 rgba(255,255,255,.12)}' +
'.fbn-x .fbn-back{transform:rotateX(180deg)}' +
'.fbn-y .fbn-back{transform:rotateY(180deg)}';
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 axis = d.axis === 'y' ? 'y' : DEFAULTS.axis;
var prop = axis === 'y' ? 'rotateY' : 'rotateX';
var duration = +d.duration || DEFAULTS.duration;
var frontText = el.innerHTML.trim();
var backText = d.back || el.textContent.trim();
el.classList.add('fbn', 'fbn-' + axis);
el.style.setProperty('--fbn-c', d.color || DEFAULTS.color);
el.style.setProperty('--fbn-b', d.backColor || DEFAULTS.back);
el.setAttribute('aria-label', el.textContent.trim());
el.innerHTML = '';
var inner = document.createElement('span');
inner.className = 'fbn-inner';
inner.setAttribute('aria-hidden', 'true');
var front = document.createElement('span');
front.className = 'fbn-face fbn-front';
front.innerHTML = frontText;
var back = document.createElement('span');
back.className = 'fbn-face fbn-back';
back.textContent = backText;
inner.appendChild(front);
inner.appendChild(back);
el.appendChild(inner);
var over = false;
function flip(state) {
if (state === over) return;
over = state;
var props = { duration: reduced() ? 0 : duration, ease: 'outBack(1.3)' };
props[prop] = state ? 180 : 0;
anime.animate(inner, props);
if (!reduced()) anime.animate(inner, { scale: [1, 0.92, 1], duration: duration * 0.8, ease: 'inOut(2)' });
}
var userTouched = false, touchTimer = null;
el.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'touch') return;
userTouched = true;
flip(true);
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'touch') return;
flip(false);
});
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
userTouched = true;
clearTimeout(touchTimer);
flip(true);
touchTimer = setTimeout(function () { flip(false); }, 1600);
});
el.addEventListener('focus', function () { if (el.matches(':focus-visible')) flip(true); });
el.addEventListener('blur', function () { if (!el.matches(':hover')) flip(false); });
if (!reduced() && d.autoplay !== undefined && d.autoplay !== 'false') {
var timer = null;
var loop = function () {
if (userTouched) return;
flip(!over);
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-flip-button]').forEach(function (el) {
if (el._fbn) return;
el._fbn = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><button data-flip-button data-back="Let's go →">Get started</button>
<!-- turning side to side, in pink: -->
<button data-flip-button data-axis="y" data-color="#db2777" data-back="£9 a month">See pricing</button>