← 3D & depth

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

  1. Paste the snippet just before </head>.
  2. Add data-bookshelf-tilt to a container.
  3. 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-angleHow far a book tips out, in degrees. Default 20.
data-depthHow deep the books are, front to back, as any CSS length. Default 3.6em.
data-shelfColour of the shelf. Default #a16207.
data-autoplayMilliseconds: 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.

1 · Snippet
<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>
2 · Your HTML
<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>