Bookshelf Tilt
A row of books stands on a shelf. Point at one and it tips out towards you on its bottom edge, just as if you’d hooked a finger over the top, showing the edges of its pages. Move along and it drops back into line with a little bounce. On a phone, tap a book.
Emma
Dracula
Walden
Middlemarch
Beowulf
Persuasion
Moby-Dick
Hamlet
Jane Eyre
Odyssey
Frankenstein
Ivanhoe
How to use it
- Paste the snippet just before
</head>. - Add
data-bookshelf-tiltto a container. - Put one element in it for each book: its spine. Give each a
--color, a width and a height.
Options
Add any of these attributes to the same element.
data-angle | How far a book tips out, in degrees. Default 20. |
data-depth | How deep the books are, front to back, as any CSS length. Default 3.6em. |
data-shelf | Colour of the shelf. Default #a16207. |
data-autoplay | Milliseconds: tip books out one after another, at random, until someone points at the shelf. |
The books are plain elements turned with CSS 3D, so a spine can hold any text or image. With reduced motion a book simply leans out, with no swing.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Bookshelf Tilt: a row of books stands on a shelf; point at one and it tips
// out towards you on its bottom edge, showing the tops of its pages, then
// drops back into place with a little bounce when you move on. Tap a book on
// a phone. With data-autoplay, books tip out one after another by themselves.
// Usage: <div data-bookshelf-tilt>
// <span style="--color: #7c3aed; height: 150px">Moby-Dick</span> …
// </div>
// Optional: data-angle (how far a book tips, degrees, default 20),
// data-depth (how deep the books are, any CSS length, default "3.6em"),
// data-shelf (colour of the shelf, default "#a16207"), data-autoplay (ms
// between books; tips them in turn until someone points at the shelf).
var DEFAULTS = { angle: 20, depth: '3.6em', shelf: '#a16207' };
var css =
'.bst{position:relative;display:flex;align-items:flex-end;justify-content:center;gap:.2em;' +
'padding:0 .8em .9em;perspective:900px;perspective-origin:50% -40%}' +
'.bst::after{content:"";position:absolute;left:0;right:0;bottom:0;height:.9em;border-radius:.15em;' +
'background:linear-gradient(180deg,rgba(255,255,255,.28),rgba(255,255,255,0) 30%,rgba(0,0,0,.25)),var(--bst-shelf);' +
'box-shadow:0 .5em 1em rgba(40,20,0,.25)}' +
'.bst-book{position:relative;flex:none;transform-origin:50% 100%;transform-style:preserve-3d;cursor:pointer;' +
'background-color:var(--color,#7c3aed);will-change:transform}' +
'.bst-book::before{content:"";position:absolute;left:4%;right:4%;top:0;height:var(--bst-depth);' +
'transform-origin:50% 0;transform:rotateX(-90deg);' +
'background:repeating-linear-gradient(90deg,#fffdf5 0 1px,#e9e2cf 1px 2px);box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}' +
'.bst-book::after{content:"";position:absolute;inset:0;background:#000;opacity:var(--bst-dim,0);pointer-events:none}';
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 angle = d.angle !== undefined ? +d.angle : DEFAULTS.angle;
el.classList.add('bst');
el.style.setProperty('--bst-shelf', d.shelf || DEFAULTS.shelf);
el.style.setProperty('--bst-depth', d.depth || DEFAULTS.depth);
var books = Array.prototype.slice.call(el.children);
books.forEach(function (b) { b.classList.add('bst-book'); });
var still = reduced();
var out = null;
function tip(b, on) {
if (!b) return;
b._bstOut = on;
if (still) {
b.style.transform = on ? 'rotateX(' + (-angle) + 'deg)' : '';
return;
}
anime.animate(b, on
? { rotateX: -angle, '--bst-dim': 0.12, duration: 520, ease: 'outBack(2.2)' }
: { rotateX: 0, '--bst-dim': 0, duration: 650, ease: 'outBounce' });
}
function show(b) {
if (b === out) return;
tip(out, false);
out = b;
tip(b, true);
}
var lastReal = 0;
el.addEventListener('pointerover', function (e) {
if (e.pointerType !== 'mouse') return;
lastReal = Date.now();
var b = e.target.closest && e.target.closest('.bst-book');
if (b && el.contains(b)) show(b);
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType !== 'mouse') return;
lastReal = Date.now();
show(null);
});
// Touch and pen: a tap tips a book out, a second tap puts it back.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse') return;
lastReal = Date.now();
var b = e.target.closest && e.target.closest('.bst-book');
if (b && el.contains(b)) show(b === out ? null : b);
});
if (d.autoplay === undefined || d.autoplay === 'false' || still) return;
var every = +d.autoplay || 1500;
var order = [], visible = false;
function next() {
if (!order.length) {
order = books.slice().sort(function () { return Math.random() - 0.5; });
if (order[0] === out) order.push(order.shift());
}
return order.shift();
}
setInterval(function () {
if (!visible || Date.now() - lastReal < 4000) return;
// Alternate: tip a book out, then put it back before the next.
if (out) show(null);
else show(next());
}, every / 2);
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
}, { threshold: 0.2 }).observe(el);
}
function init() {
document.querySelectorAll('[data-bookshelf-tilt]').forEach(function (el) {
if (el._bst) return;
el._bst = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-bookshelf-tilt>
<span style="--color: #7c3aed; height: 150px; width: 28px">Emma</span>
<span style="--color: #1e1b4b; height: 170px; width: 34px">Dracula</span>
<span style="--color: #0d9488; height: 140px; width: 26px">Walden</span>
</div>