Library / Tutorial

Bookshelf sidebar,
step by step.

A sidebar that is a bookshelf. Pick a book and it comes off the shelf, turns to face you and opens with its chapters; search dims the shelf, drag the books into a new order, add your own, fold the case into a rail and light the evening lamp. Then the same engine becomes a light-oak notes shelf. Eight stages, each a live page you can run.

8Stages
2Looks
0Libraries

This is the whole build of the bookshelf sidebar, in the order it was written. A course site whose navigation is a walnut bookcase: every course is a cloth book with its title in gold foil. Pick one and it comes off the shelf, turns to face you and opens in the sidebar; its chapters are the menu. Search dims the shelf, you can drag the books into a new order, add a course of your own, fold the case into a rail and switch on an evening lamp. The last stage puts the same engine in a second look, Nordic: a notes app on a light-oak shelf. Each stage below is the component as it stood at that step. It shows a still, and Run it here starts the live page in its place. The code under each heading is only what that stage adds. The finished source is at the end, and on GitHub under the MIT licence.

There is no library and no build step. shared/shelf.js is the shelf engine; it knows nothing about courses or notebooks. Each look is a page that hands it its books and its taste through an options object: library/library.js for the Library, nordic/nordic.js for Nordic. shared/sheet.js is the phone's bottom sheet and shared/util.js holds small helpers (saved state, the live region, the toast, icons). The wood and cloth are textures made by a script with seeded noise; the lesson pictures are drawn in SVG by library/art.js. No picture is downloaded and none is AI-made.

1 · The bookcase

The page is three columns: the case, the sidebar, the lesson. In this first stage only the case has anything in it.

library/index.html — the app, stage 1
<div class="app" id="app" data-layout="desk">

  <nav class="case" aria-label="Course shelf">
    <h2 class="sr-only" id="shelf-title">Courses</h2>
    <div class="case__scene" id="scene"></div>
  </nav>

  <aside class="side" aria-label="Course contents">
    <header class="side__top">
      <a class="logo" href="../" aria-label="SHELF home">
        <svg viewBox="0 0 32 32" aria-hidden="true"><path d="M6 5.5c0-1 .8-1.6 1.8-1.4L15 5.8v22.4l-7.6-1.9A1.8 1.8 0 0 1 6 24.6V5.5Z" fill="currentColor"/><path d="M26 5.5c0-1-.8-1.6-1.8-1.4L17 5.8v22.4l7.6-1.9a1.8 1.8 0 0 0 1.4-1.7V5.5Z" fill="currentColor" opacity=".82"/></svg>
        <span class="logo__word">Library</span>
      </a>
      <div class="side__tools">
      </div>
    </header>

  </aside>

  <main class="main" id="lesson" tabindex="-1">
  </main>
</div>

The engine's whole interface is its options. The page says which books there are and in what order, what a book looks like (face), how big it is (dims), and which shelf layouts to try (configs).

shelf.js — the options
/**
 * @param {HTMLElement} scene  positioned element that holds the books (perspective lives here); scrolls in strip mode
 * @param {object} o
 *   items: Map(id → item)       order: [id]
 *   face(item, mode) → HTML of the book's faces           label(item) → accessible name
 *   configs(mode, W, H, n) → [{ rows: [...], unit }]     tried in order until every book fits
 *   dims(item, cfg, mode) → { w, h }                        (w = thickness, h = height, px)
 *   afterLayout(pos, cfg, mode)
 */

Every book is an <li> slot with a <button> in it, absolutely placed at the top-left of the shelf. Its faces are drawn by the page into a .book__lift wrapper; the slot moves the book, the lift tips it (from stage 2).

shelf.js — a slot
make(id) {
  const li = document.createElement('li'); li.className = 'slot'; li.dataset.id = id;
  const b = document.createElement('button'); b.type = 'button'; b.className = 'book'; b.dataset.id = id; b.tabIndex = -1;
  li.append(b); this.nodes.set(id, li); this.paint(id); this.ul.append(li);
  return li;
}
shelf.js — its faces and label
paint(id) {
  const li = this.nodes.get(id); if (!li) return;
  const b = li.firstChild;
  const item = this.o.items.get(id);
  b.innerHTML = `<span class="book__lift">${this.o.face(item, this.mode)}</span>`;
  b.setAttribute('aria-label', this.o.label(item));
  if (item && item.color) li.style.setProperty('--c', item.color);
  this.o.afterPaint?.(li);
}

The Library's case has two boards of 18 px and three compartments: 23 % of the inside for the top, 38.5 % for the middle, the rest at the bottom. Each row has a mode. The top row is flat: at most two books lying on their side. The middle row stands at most four books upright and lets the last one lean up to 17°. The bottom row takes the rest. A book's thickness comes from one unit, so that two books fit lying in the top compartment. If the books don't fit, the next config makes every book a size smaller: the unit times 1, 0.9, 0.8, 0.72, 0.64 and 0.56.

library.js — the shelf layouts to try
function configs(mode, W, H) {
  if (mode === 'strip') {
    return [{ key: `strip${H}`, rows: [{ mode: 'upright', x: 14, base: H - 16, w: Infinity, h: H, gap: 2 }], unit: { w: H / 4.3, h: H - 34 },
      extent: { w: (pos) => Math.max(...[...pos.values()].map((p) => p.x + p.w)) + 18 } }];
  }
  const b = 18, inner = H - 2 * b, c0 = Math.round(inner * 0.23), c1 = Math.round(inner * 0.385), c2 = inner - c0 - c1;
  const h = Math.min(Math.min(c1, c2) - 18, W - 30);
  return [1, 0.9, 0.8, 0.72, 0.64, 0.56].map((k) => ({
    key: `desk${W}x${H}k${k}`, k, boards: [c0, c0 + b + c1], b,
    rows: [
      { mode: 'flat', x: 10, base: c0, w: W - 20, h: c0 - 16, max: 2, gap: 1, jitter: (id, i) => (hash01(id, 3) - 0.5) * 16 + (i ? 8 : 0) },
      { mode: 'upright', x: 6, base: c0 + b + c1, w: W - 12, h: c1 - 10, max: 4, lean: 17, gap: 1.5 },
      { mode: 'upright', x: 6, base: H, w: W - 12, h: c2 - 10, gap: 1.5 },
    ],
    unit: { w: Math.min((W - 12) / 4.3, (c0 - 18) / 2.3) * k, h },   // two books must lie in the top compartment
  }));
}

Sizes come from the course's id through a small hash, so a book is the same size on every load: its height is 84 to 97 % of the unit, its thickness 95 to 105 %. On a touch screen a spine is never thinner than 44 px, a fingertip.

library.js — a book's size
const hf = (id) => 0.84 + hash01(id, 1) * 0.13;
const tf = (c) => (c.tf || 1) * (0.95 + hash01(c.id, 2) * 0.1);

function configs(mode, W, H) {
  if (mode === 'strip') {
    return [{ key: `strip${H}`, rows: [{ mode: 'upright', x: 14, base: H - 16, w: Infinity, h: H, gap: 2 }], unit: { w: H / 4.3, h: H - 34 },
      extent: { w: (pos) => Math.max(...[...pos.values()].map((p) => p.x + p.w)) + 18 } }];
  }
  const b = 18, inner = H - 2 * b, c0 = Math.round(inner * 0.23), c1 = Math.round(inner * 0.385), c2 = inner - c0 - c1;
  const h = Math.min(Math.min(c1, c2) - 18, W - 30);
  return [1, 0.9, 0.8, 0.72, 0.64, 0.56].map((k) => ({
    key: `desk${W}x${H}k${k}`, k, boards: [c0, c0 + b + c1], b,
    rows: [
      { mode: 'flat', x: 10, base: c0, w: W - 20, h: c0 - 16, max: 2, gap: 1, jitter: (id, i) => (hash01(id, 3) - 0.5) * 16 + (i ? 8 : 0) },
      { mode: 'upright', x: 6, base: c0 + b + c1, w: W - 12, h: c1 - 10, max: 4, lean: 17, gap: 1.5 },
      { mode: 'upright', x: 6, base: H, w: W - 12, h: c2 - 10, gap: 1.5 },
    ],
    unit: { w: Math.min((W - 12) / 4.3, (c0 - 18) / 2.3) * k, h },   // two books must lie in the top compartment
  }));
}

const coarse = matchMedia('(pointer: coarse)');
function dims(c, cfg, mode) {
  // on touch screens a spine is at least a fingertip wide
  if (mode === 'strip') return { w: Math.max(coarse.matches ? 44 : 0, Math.round(cfg.unit.w * tf(c))), h: Math.round(cfg.unit.h * (0.86 + hash01(c.id, 1) * 0.14)) };
  return { w: Math.max(coarse.matches ? 44 : 0, Math.round(cfg.unit.w * tf(c))), h: Math.round(cfg.unit.h * hf(c.id)) };
}

compute() walks the books in order and places each in the first row with room. Upright books go left to right, 1.5 px apart. Flat books stack up from the board, and afterwards the stack is re-ordered so the first book lies on top. If a row ends with a gap at least 0.7 × the last book's thickness, that book leans into it: the angle whose sine is the gap over its height, capped at the row's limit. If nothing fits, the result is null and layout() tries the next config.

shelf.js — where every book goes
compute(order, cfg, force = false) {
  const rows = cfg.rows, st = rows.map((R) => ({ x: R.x, stack: 0, n: 0, last: null }));
  const pos = new Map(); let ri = 0;
  for (const id of order) {
    const d = this.dim.get(id);
    for (;;) {
      if (ri >= rows.length) { if (!force) return null; ri = rows.length - 1; st[ri].n = 0; }
      const R = rows[ri], s = st[ri];
      const full = R.max && s.n >= R.max;
      if (R.mode === 'upright') {
        if (!full && (s.n === 0 || s.x + d.w <= R.x + R.w + 0.5 || force)) {
          pos.set(id, { x: s.x, y: R.base - d.h, rot: 0, w: d.w, h: d.h, row: ri });
          s.x += d.w + (R.gap ?? 1.5); s.n++; s.last = id; break;
        }
      } else if (R.mode === 'flat') {          // lying, stacked upwards from the board; long side along the row
        if (!full && s.stack + d.w <= R.h + 0.5 && d.h <= R.w + 2) {
          const jx = R.jitter ? (R.jitter(id, s.n)) : 0;
          pos.set(id, { x: R.x + jx, y: R.base - s.stack, rot: -90, w: d.w, h: d.h, row: ri });
          s.stack += d.w + (R.gap ?? 0.5); s.n++; break;
        }
      }
      ri++;
    }
  }
  // a lying stack reads top-down: the first book lies on top
  rows.forEach((R, i) => {
    if (R.mode !== 'flat') return;
    const ids = [...pos.entries()].filter(([, p]) => p.row === i).map(([k]) => k);
    const ws = ids.map((k) => pos.get(k).w);
    let y = R.base; for (let j = ids.length - 1; j >= 0; j--) { pos.get(ids[j]).y = y; y -= ws[j] + (R.gap ?? 0.5); }
  });
  // the last book of a row with room to spare leans on the side of the case
  rows.forEach((R, i) => {
    const s = st[i];
    if (R.mode !== 'upright' || !R.lean || !s.last || s.n < 2) return;
    const p = pos.get(s.last), gap = R.x + R.w - (p.x + p.w);
    if (gap < p.w * 0.7) return;
    p.lean = Math.min(Math.asin(clamp(gap / p.h, 0, 1)) / DEG, R.lean);
  });
  return pos;
}

A position becomes one 2D transform. A leaning book pivots on its bottom-right corner, so the translate is worked out for that pivot. The numbers are rounded to whole pixels: at rest a spine is drawn straight onto the screen and never resampled, which is what keeps the gold titles sharp.

shelf.js — a position as a transform
static transform(p, extra = 0) {
  let tx = p.x, ty = p.y, rot = p.rot + extra;
  if (p.lean) {                                   // pivot on the bottom-right corner
    const a = p.lean * DEG, c = Math.cos(a), s = Math.sin(a);
    tx = p.x + p.w - (p.w * c - p.h * s); ty = p.y + p.h - (p.w * s + p.h * c); rot = p.lean + extra;
  }
  // whole pixels and a 2D transform: at rest a spine is drawn straight onto the screen, never resampled
  return `translate(${Math.round(tx)}px, ${Math.round(ty)}px) rotate(${+rot.toFixed(2)}deg)`;
}
shelf.js — try the configs, size the books, place them
layout(animate = true) {
  const { W, H } = this.size(); if (!W || !H) return;
  const cfgs = this.o.configs(this.mode, W, H, this.all.length);
  let pos = null, cfg = null;
  for (const c of cfgs) {
    this.measure(c); pos = this.compute(this.all, c);
    if (pos) { cfg = c; break; }
  }
  if (!pos) { cfg = cfgs[cfgs.length - 1]; this.measure(cfg); pos = this.compute(this.all, cfg, true); }
  const cfgChanged = !this.cfg || this.cfg.key !== cfg.key;
  this.cfg = cfg;
  if (!animate || reducedMotion()) this.ul.classList.add('no-anim');
  for (const id of this.all) {
    const li = this.nodes.get(id), d = this.dim.get(id);
    if (li._w !== d.w || li._h !== d.h) { li._w = d.w; li._h = d.h; li.style.width = d.w + 'px'; li.style.height = d.h + 'px'; }
  }
  this.apply(pos);
  if (cfg.extent) { this.ul.style.width = cfg.extent.w ? cfg.extent.w(pos, this) + 'px' : ''; this.ul.style.height = cfg.extent.h ? cfg.extent.h(pos, this) + 'px' : ''; }
  else { this.ul.style.width = ''; this.ul.style.height = ''; }
  if (this.o.afterLayout) this.o.afterLayout(pos, cfg, this.mode, cfgChanged);
  if (!animate || reducedMotion()) { void this.ul.offsetWidth; requestAnimationFrame(() => this.ul.classList.remove('no-anim')); }
}
shelf.js — apply
apply(pos, skip = null) {
  for (const [id, p] of pos) {
    if (id === skip) continue;
    const li = this.nodes.get(id); if (!li) continue;
    const tr = Shelf.transform(p);
    if (li._tr !== tr) { li._tr = tr; li.style.transform = tr; }
    li.classList.toggle('is-flat', p.rot === -90);
    li.classList.toggle('is-lean', !!p.lean);
    li.dataset.row = p.row;
  }
  this.pos = pos;
}

Moving a book is only ever a change of transform, and the slot has a CSS transition of 0.68 s on a curve that overshoots a little (0.76 s for a book lying down). So every later change, a reorder, a new book, a resize, glides by itself.

library.css — the slots glide
.slot { position: absolute; left: 0; top: 0; transform-origin: 0 0; transition: transform .68s cubic-bezier(.22, 1.12, .36, 1); }
.slot.is-flat { transition-duration: .76s; }
.books.no-anim .slot { transition: none; }

The face is a spine with gold bands and the title. The cloth is a 192 px texture shown at 96 px and blended with the course's colour, a sheen a third of the way in rounds it, and the title is text with a metallic gradient clipped to it. The book is a size container, so the title's size follows the spine: at most 46 % of its width, small enough for the whole title to fit its height, between 11 and 36 px.

library.js — a book's face
const face = (c, mode) => `
  <span class="book__spine" style="--len:${Math.max(4, c.title.length)}">
    <span class="book__bands"></span>
    <span class="book__title">${esc(c.title)}</span>
  </span>`;
library.css — cloth, sheen, bands, gold foil
.book__spine {
  position: absolute; inset: 0; overflow: hidden; border-radius: 3px 3px 2px 2px; backface-visibility: hidden;
  background: url(../shared/tex/cloth.png) 0 0 / 96px 96px, var(--c);
  background-blend-mode: overlay;
  box-shadow: 4px 1px 8px rgba(0, 0, 0, .5), inset 0 0 0 1px rgba(0, 0, 0, .28);
  transition: box-shadow .25s;
}
.book__spine::after { /* the round of the spine: a soft sheen a third of the way in, shadow at both edges */
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background:
    linear-gradient(90deg, rgba(0, 0, 0, .46) 0%, rgba(0, 0, 0, .12) 7%, rgba(255, 255, 255, .06) 16%, rgba(255, 245, 230, .26) 29%, rgba(255, 255, 255, .08) 41%, rgba(255, 255, 255, 0) 55%, rgba(0, 0, 0, .12) 78%, rgba(0, 0, 0, .48) 100%),
    linear-gradient(180deg, rgba(255, 255, 255, .08), rgba(0, 0, 0, 0) 14%, rgba(0, 0, 0, 0) 88%, rgba(0, 0, 0, .28));
}
.book__bands { position: absolute; inset: 0; pointer-events: none;
  background:
    linear-gradient(var(--gold), var(--gold)) 0 calc(5% + 0px) / 100% 2px no-repeat,
    linear-gradient(var(--gold-lo), var(--gold-lo)) 0 calc(5% + 6px) / 100% 1.5px no-repeat,
    linear-gradient(var(--gold-lo), var(--gold-lo)) 0 calc(95% - 6px) / 100% 1.5px no-repeat,
    linear-gradient(var(--gold), var(--gold)) 0 95% / 100% 2px no-repeat;
}
.book__title {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) rotate(90deg); white-space: nowrap; pointer-events: none;
  font-family: 'Cormorant', Georgia, serif; font-weight: 600; letter-spacing: .01em; line-height: 1;
  background: linear-gradient(180deg, var(--gold-hi) 0%, var(--gold) 42%, var(--gold-lo) 58%, #e9c779 100%);
  -webkit-background-clip: text; background-clip: text; color: transparent;
}
.book__title { font-size: clamp(11px, min(46cqw, calc((100cqh - 64px) / (var(--len) * .45))), 36px); }

The boards are drawn by the page after every layout, at the heights the config chose; the engine only calls afterLayout.

library.js — the boards
function afterLayout(pos, cfg, mode) {
  if (mode !== 'desk' || cfg.key === boardsKey) { if (mode !== 'desk') { scene.querySelectorAll('.board').forEach((e) => e.remove()); boardsKey = ''; } return; }
  boardsKey = cfg.key;
  scene.querySelectorAll('.board').forEach((e) => e.remove());
  for (const y of cfg.boards) {
    const d = document.createElement('div'); d.className = 'board'; d.setAttribute('aria-hidden', 'true');
    d.style.transform = `translate3d(0, ${y}px, 0)`; d.style.height = cfg.b + 'px';
    scene.prepend(d);
  }
}
library.js — the shelf
const shelf = new Shelf(scene, {
  afterPaint: (li) => markFit(li),
  items: courses, order: [...courses.keys()], face, dims, configs, afterLayout,
  listLabel: 'Courses',
  label: (c) => c.title,
});

Under 720 px wide the page is a phone. The case moves under the header as a strip on one board, and the shelf switches to the strip config: one upright row as long as it needs, that scrolls sideways.

library.js — desk or phone
function decideLayout() {
  const w = innerWidth;
  if (w < 720) return 'phone';
  return 'desk';
}

function applyLayout(force = false) {
  const next = decideLayout();
  if (next === layout && !force) return;
  layout = next; app.dataset.layout = next;
  shelf.setMode(next === 'phone' ? 'strip' : 'desk');
  requestAnimationFrame(() => markFit());
}

let ro = new ResizeObserver(() => { shelf.layout(false); applyLayout(); });
ro.observe(scene);
library.css — the strip
.app[data-layout="phone"] { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto var(--strip-h) minmax(0, 1fr); grid-template-areas: "side" "case" "main"; }
.app[data-layout="phone"] .side { padding: 6px 8px 0 14px; border-right: 0; border-bottom: 1px solid var(--line); }
.app[data-layout="phone"] .side__top { min-height: 52px; }
.app[data-layout="phone"] .case { --frame: 0px; box-shadow: inset 0 10px 14px -6px rgba(0, 0, 0, .55); }
.app[data-layout="phone"] .case::before { inset: 0 0 16px 0; }
.app[data-layout="phone"] .case::after { /* the board the books stand on */
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 16px; z-index: 1;
  background: linear-gradient(180deg, rgba(255, 228, 196, .25), rgba(0, 0, 0, 0) 30%, rgba(0, 0, 0, .35)), url(../shared/tex/walnut_h.webp) 0 50% / 1600px 100%, var(--walnut-500);
  box-shadow: 0 -1px 0 rgba(0, 0, 0, .4); }
.case__scene[data-mode="strip"] { inset: 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; overscroll-behavior-x: contain; z-index: 2; }
.case__scene[data-mode="strip"]::-webkit-scrollbar { display: none; }
.case__scene[data-mode="strip"] .book { touch-action: pan-x; }
Stage 1 · the bookcaseOpen ↗
A walnut bookcase of three compartments: Typography and Color lying on the top shelf, Design Systems, Iconography, Forms and a leaning Layout in the middle, Motion, UX Writing and Accessibility at the bottom, every title in gold foil. The sidebar and the lesson column are still empty.

2 · Hover, keys and the open book

A spine has three more faces, drawn in 3D around it: the cover's side, the back and the page tops. They are hidden at rest, so the shelf reads head-on like a photo, and show only while a book tips out. Under the pointer a standing book comes 16 px forward, rises 5 px and turns (−9° about x, −5° about z) in its own 760 px perspective. The perspective is on that one book, not on the shelf, so the other spines are never resampled.

library.css — the hidden faces
/* page tops only show when a book is tipped out (hover, drag): at rest the shelf reads head-on, as in the concept */
.book__top, .book__cover, .book__back { opacity: 0; transition: opacity .2s; }
.book:hover .book__top, .book:focus-visible .book__top, .book:hover .book__cover, .book:hover .book__back { opacity: 1; }
library.css — tipping out
@media (hover: hover) {
  .slot:not(.is-dragging):not(.is-flat):not(.is-out) .book:hover .book__lift { transform: perspective(760px) translateZ(16px) translateY(-5px) rotateX(-9deg) rotateZ(-5deg); }
  .slot:not(.is-dragging).is-flat:not(.is-out) .book:hover .book__lift { transform: perspective(760px) translateZ(12px) translateX(5px); }
  .slot:not(.is-dragging):not(.is-out) .book:hover .book__spine { box-shadow: 10px 6px 16px rgba(0, 0, 0, .55), inset 0 0 0 1px rgba(0, 0, 0, .28); }
}
.slot:not(.is-out) .book:focus-visible .book__lift { transform: translateY(-3px); }
.slot.is-out .book__lift { transform: perspective(760px) translateZ(70px) translateY(-8px); opacity: 0; transition: transform .3s cubic-bezier(.3, .7, .3, 1), opacity .16s linear .14s; }

The shelf is one stop in the tab order: a roving tabindex. Arrow keys, Home and End walk the books; on a phone the strip scrolls the focused book into view. Enter or a click is the button's click, and the engine passes it to the page.

shelf.js — one tab stop
setTabStop(id) {
  const ids = this.all; if (!ids.length) return;
  if (!id || !this.nodes.has(id)) id = this.sel && this.nodes.has(this.sel) ? this.sel : ids[0];
  this.focusId = id;
  for (const [k, li] of this.nodes) li.firstChild.tabIndex = k === id ? 0 : -1;
}

focus(id) { this.setTabStop(id); this.nodes.get(id)?.firstChild.focus({ preventScroll: this.mode === 'desk' }); this.reveal(id); }
shelf.js — the keys
ul.addEventListener('keydown', (e) => {
  const b = e.target.closest('.book'); if (!b) return;
  const ids = this.all, id = b.dataset.id, i = ids.indexOf(id);
  const fwd = e.key === 'ArrowRight' || e.key === 'ArrowDown', back = e.key === 'ArrowLeft' || e.key === 'ArrowUp';
  let j = null;
  if (fwd) j = Math.min(ids.length - 1, i + 1); else if (back) j = Math.max(0, i - 1);
  else if (e.key === 'Home') j = 0; else if (e.key === 'End') j = ids.length - 1;
  if (j !== null) { e.preventDefault(); this.focus(ids[j]); }
});
shelf.js — the selected book
select(id) {
  const prev = this.sel; this.sel = id;
  for (const [k, li] of this.nodes) {
    li.classList.toggle('is-selected', k === id);
    const b = li.firstChild; b.setAttribute('aria-expanded', String(k === id)); if (k === id) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');
  }
  this.setTabStop(this.focusId && this.nodes.has(this.focusId) ? this.focusId : id);
  return prev;
}

The open book is a 3D object in the sidebar: a back cover, a spine, the page and a front cover. The page sits exactly at z = 0, so the perspective (2300 px) never scales it and its text stays crisp; the covers sit behind and in front of it.

library/index.html — the open book
<div class="tome-wrap" id="tome-wrap">
  <div class="tome" id="tome" hidden>
    <div class="tome__body" id="tome-body">
      <div class="tome__back"></div>
      <div class="tome__block"><i></i></div>
      <div class="tome__spine"><span class="tome__spine-t"></span></div>
      <section class="tome__page" id="tome-page" aria-labelledby="tome-title">
        <h2 class="tome__title" id="tome-title"></h2>
        <p class="tome__meta" id="tome-meta"></p>
      </section>
      <div class="tome__front" id="tome-front">
        <div class="tome__out"><span class="tome__foil"></span></div>
        <div class="tome__in"></div>
      </div>
      <i class="tome__ribbon" aria-hidden="true"></i>
    </div>
  </div>
  <p class="side__empty" id="side-empty" hidden>Pick a book from the shelf to open its chapters.</p>
</div>
library.css — the book in the sidebar
.tome-wrap { position: relative; flex: 1; min-height: 0; perspective: 2300px; perspective-origin: 18% 42%; }
.side__empty { position: absolute; left: 0; right: 0; top: 30%; padding: 0 18px; text-align: center; color: var(--muted); font-size: 14px; }
.tome { position: absolute; left: -20px; top: 14px; width: var(--tw, 260px); height: var(--th, 330px); transform-style: preserve-3d; }
.tome__body { position: absolute; inset: 0; transform-style: preserve-3d; transform-origin: 50% 50%; }
.tome__back { position: absolute; inset: -8px -12px -9px 0; border-radius: 2px 6px 6px 2px; transform: translateZ(calc(var(--tt, 26px) * -1));
  background: linear-gradient(90deg, rgba(0, 0, 0, .3), rgba(0, 0, 0, 0) 6%), url(../shared/tex/cloth.png) 0 0 / 96px 96px, var(--c); background-blend-mode: normal, overlay;
  box-shadow: 0 22px 38px -8px rgba(0, 0, 0, .35), 0 5px 12px rgba(0, 0, 0, .2); }
.tome__block { display: none; }
.tome__spine { position: absolute; left: 0; top: -6px; bottom: -7px; width: var(--tt, 26px); transform-origin: 0 50%; transform: translateZ(calc(var(--tt, 26px) * -1)) rotateY(-90deg); backface-visibility: hidden;
  background: linear-gradient(90deg, rgba(0, 0, 0, .4), rgba(255, 255, 255, .14) 35%, rgba(0, 0, 0, .35)), url(../shared/tex/cloth.png) 0 0 / 96px 96px, var(--c); background-blend-mode: normal, overlay; }
.tome__spine-t { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) rotate(90deg); white-space: nowrap; font: 600 18px/1 'Cormorant', serif; color: var(--gold); }
library.css — the page
.tome__page {
  position: absolute; inset: 0; display: flex; flex-direction: column; padding: 18px 12px 20px 22px; border-radius: 0 3px 3px 0;
  background: linear-gradient(90deg, rgba(70, 50, 20, .14), rgba(70, 50, 20, .035) 8%, rgba(0, 0, 0, 0) 15%), linear-gradient(180deg, #fffdf8, var(--paper) 40%, #f7f1e4);
  box-shadow: 1px 1px 0 #e6dfcf, 2px 2px 0 #fbf7ee, 3px 3px 0 #e0d8c6, 4px 4px 0 #f7f2e7, 5px 5px 0 #d9d0bd, 6px 6px 0 rgba(0, 0, 0, .06);
  color: var(--paper-ink); --ring: #2563eb;
}

Its size follows the sidebar: 22 px wider than it (190 to 380 px) and 1.25 × as tall (200 to 480 px). Its thickness follows the book on the shelf: the spine's thickness over its height, times the open book's height and 0.9, between 18 and 40 px.

library.js — the open book's size
function sizeTome() {
  if (layout === 'phone') return;
  const W = wrap.clientWidth, H = wrap.clientHeight;
  const tw = clamp(Math.round(W + 22), 190, 380), th = clamp(Math.round(Math.min(H - 24, tw * 1.25)), 200, 480);
  tomeEl.style.setProperty('--tw', tw + 'px'); tomeEl.style.setProperty('--th', th + 'px');
  if (openId) setThickness(openId);
}
function setThickness(id) {
  const p = shelf.pos.get(id), th = parseFloat(tomeEl.style.getPropertyValue('--th')) || 320;
  const t = p ? clamp(Math.round((p.w / p.h) * th * 0.9), 18, 40) : 26;
  tomeEl.style.setProperty('--tt', t + 'px');
}

Opening is a flight. It starts where the spine is: the open book is moved onto the spine's centre, turned with it, turned 90° about y so you see its spine, and scaled to the spine's height. A Web Animation of 640 ms brings it home through a point a third of the way, 26 px higher and 70 px nearer, half turned (38°). The slot it came from is marked is-out, so its spine rushes forward and fades, and the slot stays empty while the book is out.

library.js — where the flight starts
function spineStart(id) {
  const li = shelf.node(id), p = shelf.pos.get(id); if (!li || !p) return null;
  const r = li.getBoundingClientRect(), t = tomeEl.getBoundingClientRect();
  const th = t.height || 1;
  return { dx: r.left + r.width / 2 - (t.left + t.width / 2), dy: r.top + r.height / 2 - (t.top + t.height / 2), s: clamp(p.h / th, 0.25, 1.4), rot: p.rot + (p.lean || 0) };
}

470 ms in, the front cover swings open to −97° in 0.68 s, on a curve that overshoots, and the red ribbon swings once (1.6 s). A counter (seq) makes a newer click cancel an older flight at every step.

library.js — open
async function openTome(id, animate = true) {
  const k = ++seq, c = courses.get(id);
  for (const a of body.getAnimations()) a.cancel();
  openId = id; shelf.select(id);
  dressTome(c);
  if (layout === 'phone') { tomeEl.hidden = true; $('#side-empty').hidden = true; return; }
  $('#side-empty').hidden = true;
  tomeEl.hidden = false;
  if (!animate || reducedMotion()) {
    tomeEl.classList.add('no-anim', 'is-open'); shelf.node(id)?.classList.add('is-out');
    await raf(); tomeEl.classList.remove('no-anim'); return;
  }
  tomeEl.classList.add('no-anim'); tomeEl.classList.remove('is-open', 'is-settled');
  const st = spineStart(id);
  await raf(); if (k !== seq) return;
  tomeEl.classList.remove('no-anim');
  shelf.node(id)?.classList.add('is-out');
  if (st) {
    body.animate([
      { transform: `translate3d(${st.dx}px, ${st.dy}px, 0) rotate(${st.rot}deg) rotateY(90deg) scale(${st.s})`, opacity: 0.0, offset: 0 },
      { opacity: 1, offset: 0.12 },
      { transform: `translate3d(${st.dx * 0.32}px, ${st.dy * 0.32 - 26}px, 70px) rotate(${st.rot * 0.25}deg) rotateY(38deg) scale(${(st.s + 1) / 2 * 1.04})`, offset: 0.52 },
      { transform: 'translate3d(0, 0, 0) rotate(0deg) rotateY(0deg) scale(1)', opacity: 1, offset: 1 },
    ], { duration: 640, easing: 'cubic-bezier(.3,.66,.22,1)' });
  }
  await sleep(st ? 470 : 0); if (k !== seq) return;
  tomeEl.classList.add('is-open');
  await sleep(700); if (k !== seq) return;
  tomeEl.classList.add('is-settled');
}
library.css — the cover swings
.tome__front { position: absolute; inset: -8px -12px -9px 0; transform-origin: 0 50%; transform-style: preserve-3d;
  transform: translateZ(1px) rotateY(0deg); transition: transform .68s cubic-bezier(.3, 1.22, .45, 1); }
.tome.is-open .tome__front { transform: translateZ(1px) rotateY(-97deg); }
.tome:not(.is-open) .tome__front { transition-duration: .34s; transition-timing-function: cubic-bezier(.5, 0, .6, 1); }
library.css — the ribbon
.tome__ribbon { position: absolute; left: 33%; top: calc(100% - 24px); width: 13px; height: 78px; transform: translateZ(.5px); transform-origin: 50% 0;
  background: linear-gradient(90deg, #8f141c, #d23a40 45%, #a81c24 60%, #7d1118); clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 84%, 0 100%); opacity: 0; transition: opacity .2s; }
.tome.is-open .tome__ribbon { opacity: 1; transition: opacity .3s .4s; animation: ribbon 1.6s .45s cubic-bezier(.3, .6, .4, 1) both; }
@keyframes ribbon { 0% { transform: translateZ(.5px) rotate(14deg); } 35% { transform: translateZ(.5px) rotate(-8deg); } 65% { transform: translateZ(.5px) rotate(3.5deg); } 100% { transform: translateZ(.5px) rotate(0deg); } }

Closing runs it backwards: the cover shuts in 0.34 s, then the book flies home in 440 ms, and 330 ms in the spine reappears in its slot. Picking another book closes the open one first.

library.js — close
async function closeTome(animate = true) {
  const k = ++seq, id = openId; if (!id) return;
  openId = null;
  if (layout === 'phone' || !animate || reducedMotion()) {
    tomeEl.classList.remove('is-open', 'is-settled'); tomeEl.hidden = true; $('#side-empty').hidden = layout === 'phone'; shelf.node(id)?.classList.remove('is-out'); shelf.select(null);
    return;
  }
  tomeEl.classList.remove('is-open', 'is-settled');
  await sleep(300); if (k !== seq) return;
  const st = spineStart(id);
  if (st) {
    const a = body.animate([
      { transform: 'translate3d(0, 0, 0) rotate(0deg) rotateY(0deg) scale(1)', opacity: 1 },
      { transform: `translate3d(${st.dx * 0.3}px, ${st.dy * 0.3 - 20}px, 60px) rotate(${st.rot * 0.3}deg) rotateY(40deg) scale(${(st.s + 1) / 2})`, opacity: 1, offset: 0.45 },
      { transform: `translate3d(${st.dx}px, ${st.dy}px, 0) rotate(${st.rot}deg) rotateY(90deg) scale(${st.s})`, opacity: 0.2 },
    ], { duration: 440, easing: 'cubic-bezier(.5,0,.3,1)', fill: 'forwards' });
    await sleep(330); if (k !== seq) { a.cancel(); return; }
    shelf.node(id)?.classList.remove('is-out');
    await sleep(110); if (k !== seq) { a.cancel(); return; }
    a.cancel();
  } else shelf.node(id)?.classList.remove('is-out');
  if (k !== seq) return;
  tomeEl.hidden = true; $('#side-empty').hidden = false;
}
library.js — one book at a time
async function switchTo(id, animate) {
  if (openId === id) return;
  if (openId && layout !== 'phone') { await closeTome(animate); }
  await openTome(id, animate);
}
library.js — a click on a book
function pickCourse(id) {
  if (id === openId && layout !== 'phone') { closeTome(); say(`${courses.get(id).title} closed`); return; }
  const c = courses.get(id);
  switchTo(id, true);
  say(`${c.title} opened. ${c.lessons.length} lessons.`);
}
Stage 2 · Typography openOpen ↗
Typography has left its place on the top shelf, which stays empty, and lies open in the sidebar: a red cloth cover swung open, a cream page reading Typography, 9 lessons, a red ribbon. Iconography tips out of the case under the pointer.

3 · Chapters, lessons and progress

The courses are real: 9 courses and 66 short lessons in data.js, each with a lead, two paragraphs and a takeaway, and a picture drawn for it in SVG by art.js. What you have done is kept in localStorage, through a small store that keeps working in memory when storage is blocked (a private window) and saves 250 ms after the last change. Nine lessons start ticked off so the page shows progress.

util.js — saved state with a fallback
export function store(key, defaults) {
  let data;
  try { data = JSON.parse(localStorage.getItem(key)); } catch (e) { data = null; }
  data = Object.assign(structuredClone(defaults), data || {});
  let t = 0;
  const api = {
    data,
    save(now = false) {
      clearTimeout(t);
      const w = () => { try { localStorage.setItem(key, JSON.stringify(data)); } catch (e) { /* storage unavailable: keep working in memory */ } };
      if (now) w(); else t = setTimeout(w, 250);
    },
    reset() { try { localStorage.removeItem(key); } catch (e) {} },
  };
  addEventListener('pagehide', () => api.save(true));
  return api;
}
library.js — the state
const DEFAULT_DONE = ['layout/white-space-is-structure', 'layout/the-8-point-rhythm', 'layout/proximity-groups', 'layout/columns-and-gutters',
  'typography/anatomy-of-a-letter', 'typography/choosing-a-typeface', 'typography/type-on-screens', 'color/hue-value-chroma', 'motion/motion-carries-meaning'];
const S = store('shelf.library.v1', {
  done: Object.fromEntries(DEFAULT_DONE.map((k) => [k, 1])),
  current: 'layout/grids-that-breathe', last: {}, closed: false,
});

const lessonById = (id) => { const [c] = id.split('/'); return courses.get(c)?.lessons.find((l) => l.id === id) || null; };
const doneCount = (c) => c.lessons.reduce((n, l) => n + (S.data.done[l.id] ? 1 : 0), 0);
const resumeLesson = (c) => lessonById(S.data.last[c.id] || '') || c.lessons.find((l) => !S.data.done[l.id]) || c.lessons[0];

The chapters are an ARIA tree: chapters are expandable groups, lessons are tree items and links, and the lesson on screen has aria-current="page". The tree is rewritten only when the book changes; after that, updateTree() touches only the attributes that changed.

library.js — the chapter tree
function treeHTML(c) {
  return `<ul class="tree" id="tree" role="tree" aria-label="${esc(c.title)} chapters">${c.chapters.map((ch) => `
    <li class="ch" role="treeitem" aria-level="1" aria-expanded="true" tabindex="-1" data-ch="${ch.index}">
      <span class="ch__t"><span class="ch__chev" aria-hidden="true">${icon('chevD')}</span><span class="ch__n">Chapter ${ch.index}</span><span class="ch__name">${esc(ch.title)}</span></span>
      <ul role="group">${ch.lessons.map((l) => `<li role="none"><a class="ls" role="treeitem" aria-level="2" href="#/${l.id}" tabindex="-1" data-id="${l.id}"><span class="ls__tick" aria-hidden="true">${icon('check')}</span><span class="ls__t">${esc(l.title)}</span><span class="sr-only ls__s"></span></a></li>`).join('')}</ul>
    </li>`).join('')}</ul>`;
}
library.js — keep the tree up to date
function updateTree() {
  const tree = $('#tree'); if (!tree) return;
  const cur = S.data.current;
  let stop = null;
  const setAttr = (el, k, v) => { if (el.getAttribute(k) !== v) { if (v === null) el.removeAttribute(k); else el.setAttribute(k, v); } };
  for (const a of tree.querySelectorAll('.ls')) {
    const l = lessonById(a.dataset.id), done = !!S.data.done[l.id], on = l.id === cur;
    // touch only what changed: this runs on every route and every keystroke of a search
    a.classList.toggle('is-done', done);
    setAttr(a, 'aria-selected', String(on));
    setAttr(a, 'aria-current', on ? 'page' : null);
    if (on) stop = a;
    const s = a.querySelector('.ls__s'), st = done ? ', completed' : ''; if (s.textContent !== st) s.textContent = st;
  }
  const want = stop || tree.querySelector('[role=treeitem]');
  if (want.tabIndex !== 0 || tree.querySelectorAll('[tabindex="0"]').length !== 1) { for (const it of tree.querySelectorAll('[role=treeitem]')) if (it !== want && it.tabIndex !== -1) it.tabIndex = -1; want.tabIndex = 0; }
}

The tree's keys follow the pattern: ↑ ↓ Home End walk the visible items, → opens a chapter or steps into it, ← closes it or steps out, Enter opens. Escape shuts the book and gives the focus back to its spine.

library.js — the tree's keys
document.addEventListener('keydown', (e) => {
  const it = e.target.closest?.('#tree [role=treeitem]'); if (!it) return;
  const tree = it.closest('[role=tree]'), vis = visibleItems(tree), i = vis.indexOf(it), isCh = it.classList.contains('ch');
  let handled = true;
  switch (e.key) {
    case 'ArrowDown': if (vis[i + 1]) focusItem(vis[i + 1]); break;
    case 'ArrowUp': if (vis[i - 1]) focusItem(vis[i - 1]); break;
    case 'Home': focusItem(vis[0]); break;
    case 'End': focusItem(vis[vis.length - 1]); break;
    case 'ArrowRight': if (isCh) { if (it.getAttribute('aria-expanded') === 'false') toggleCh(it, true); else focusItem(it.querySelector('.ls')); } break;
    case 'ArrowLeft': if (isCh) { if (it.getAttribute('aria-expanded') === 'true') toggleCh(it, false); } else focusItem(it.closest('.ch')); break;
    case 'Enter': case ' ': if (isCh) toggleCh(it); else { location.hash = it.getAttribute('href'); } break;
    case 'Escape': if (layout !== 'phone' && openId) { const id = openId; closeTome(); S.data.closed = true; S.save(); shelf.focus(id); } else handled = false; break;
    default: handled = false;
  }
  if (handled) { e.preventDefault(); e.stopPropagation(); }
});

The URL holds the lesson (#/layout/grids-that-breathe), so every lesson can be linked and the back button works. On a route the book opens first and the lesson is written a frame later; the lesson itself arrives over three frames (heading, then the picture, then the text), so no frame carries the whole job on a slow phone.

library.js — the route
async function route() {
  let l = lessonById(parseHash() || '') || lessonById(S.data.current) || courses.get('layout').lessons[4];
  if (!parseHash()) history.replaceState(null, '', `#/${l.id}`);
  const animate = !first; first = false;
  const changedLesson = S.data.current !== l.id || !article.firstChild;
  S.data.current = l.id; S.data.last[l.course] = l.id; S.data.closed = false; S.save();
  if (openId !== l.course) {
    // open the book first, the lesson one frame later: no frame carries both jobs
    switchTo(l.course, animate);
    updateTree();
    if (animate) { await raf(); await raf(); } // the tree lays out in its own frame before the lesson is written
  } else { updateTree(); }
  if (changedLesson) { if (animate) $('#lesson').scrollTop = 0; renderLesson(l, !animate); }
  else updateProgress(courses.get(l.course), false);
  document.title = `${l.title} · ${courses.get(l.course).title} · Library`;
}
library.js — a lesson, over three frames
function renderLesson(l, sync = false) {
  const c = courses.get(l.course), i = c.lessons.indexOf(l), next = c.lessons[i + 1];
  const done = !!S.data.done[l.id];
  const k = ++renderSeq;
  // two frames: the heading and picture first, the text a frame later (each half fits a frame on a slow phone)
  article.innerHTML = `
    <p class="lesson__eyebrow">Lesson <span class="lesson__dot">·</span> ${l.n} of ${c.lessons.length} <span class="lesson__dot">·</span> ${l.min} min</p>
    <h1 class="lesson__title" id="lesson-title">${esc(l.title)}</h1>
    <figure class="lesson__art"></figure>
    <div class="lesson__text"></div>`;
  const pic = art(c.art, l.n, l.title);
  const rest = `
    <p class="lesson__lead">${esc(l.lead)}</p>
    ${l.body.map((p) => `<p>${esc(p)}</p>`).join('')}
    <aside class="lesson__tip" aria-label="Takeaway"><span class="lesson__tip-k">Takeaway</span>${esc(l.tip)}</aside>
    <div class="lesson__nav">
      <button class="btn btn--done${done ? ' is-done' : ''}" type="button" id="done" aria-pressed="${done}">${icon('check')}<span>${done ? 'Completed' : 'Mark as complete'}</span></button>
      ${next ? `<a class="btn btn--ghost" href="#/${next.id}">Next: ${esc(next.title)} ${icon('arrowR')}</a>` : `<span class="lesson__end">Last lesson in ${esc(c.title)}</span>`}
    </div>`;
  // three frames on the way in: heading, then the picture (SVG text is the costly part), then the text
  const fillArt = () => { if (k === renderSeq) article.querySelector('.lesson__art').innerHTML = pic; };
  const fill = () => { if (k === renderSeq) { const t = article.querySelector('.lesson__text'); t.innerHTML = rest; markFit(t); } };
  if (sync) { fillArt(); fill(); } else requestAnimationFrame(() => { fillArt(); requestAnimationFrame(fill); });
  updateProgress(c, false);
}

Picking a book now opens it where you left off: the last lesson you read in it, or its first unfinished one.

library.js — resume where you left off
function pickCourse(id) {
  if (id === openId && layout !== 'phone') { closeTome(); S.data.closed = true; S.save(); say(`${courses.get(id).title} closed`); return; }
  if (id === openId && layout === 'phone') { sheet.set('half'); return; }
  const c = courses.get(id), l = resumeLesson(c);
  if (layout === 'phone') setTimeout(() => sheet.set('half'), 30);
  if (location.hash === `#/${l.id}`) { switchTo(id, true); updateTree(); }
  else location.hash = `#/${l.id}`;
  say(`${c.title} opened. ${doneCount(c)} of ${c.lessons.length} lessons done.`);
}

Mark as complete ticks the lesson in the tree, where the check draws itself (a dash offset from 24 to 0 in 0.45 s), moves the progress bar (a scaleX over 0.8 s) and says so in the live region.

library.js — mark as complete
article.addEventListener('click', (e) => {
  const b = e.target.closest('#done'); if (!b) return;
  const l = lessonById(S.data.current), c = courses.get(l.course);
  const on = !S.data.done[l.id];
  if (on) S.data.done[l.id] = 1; else delete S.data.done[l.id];
  S.save();
  b.classList.toggle('is-done', on); b.setAttribute('aria-pressed', String(on)); b.querySelector('span').textContent = on ? 'Completed' : 'Mark as complete';
  updateTree(); updateProgress(c);
  const a = $(`#tree .ls[data-id="${CSS.escape(l.id)}"]`); if (a && on) { a.classList.remove('just-done'); void a.offsetWidth; a.classList.add('just-done'); }
  say(on ? `${l.title} completed. ${doneCount(c)} of ${c.lessons.length} done.` : `${l.title} marked as not complete.`);
});
library.css — the tick draws itself
.ls__tick svg { width: 16px; height: 16px; stroke-width: 2.4; stroke-dasharray: 24; stroke-dashoffset: 0; transition: stroke-dashoffset .4s cubic-bezier(.4, 0, .2, 1); }
.ls:not(.is-done) .ls__tick { width: 12px; height: 12px; margin: 0 2px; border: 1.5px solid #c9c0ae; border-radius: 50%; }
.ls:not(.is-done) .ls__tick svg { display: none; }
.ls.just-done .ls__tick svg { animation: tick .45s cubic-bezier(.4, 0, .2, 1); }
@keyframes tick { from { stroke-dashoffset: 24; } to { stroke-dashoffset: 0; } }

A book you shut stays shut after a reload: the route clears the flag, so it is read first.

library.js — a shut book stays shut
const wasClosed = S.data.closed;   // route() opens the current course and clears the flag: read it first
await route();
if (wasClosed && layout !== 'phone') { closeTome(false); S.data.closed = true; S.save(); }

On a phone there is no room for the open book, so the tree moves into a bottom sheet. It snaps to three heights: 74 px (peek), 52 % of the screen and all of it but 64 px. A drag moves it by transform only; past the ends it gives like a rubber band (2.5 × the square root of the overshoot), and on release it goes to the height nearest to where its speed would carry it in 220 ms. Its grip is a button too, for the keyboard and a tap.

library.js — the chapters sheet
const sheet = new Sheet($('#sheet'), {
  handle: $('#sheet-handle'), grip: $('#sheet-grip'),
  snaps: () => ({ peek: 74, half: Math.round(innerHeight * 0.52), full: Math.round(innerHeight - 64) }),
  label: (s) => `Chapters, ${s === 'peek' ? 'lowered' : s === 'half' ? 'half open' : 'open'}. Press to ${s === 'full' ? 'lower' : 'raise'}.`,
  onSnap: (s) => { $('#sheet-x').hidden = s === 'peek'; $('#sheet-body').inert = s === 'peek'; },
});
sheet.js — drag and snap
down(e) {
  if (!this.enabled || e.button !== 0) return;
  if (e.target.closest('button:not(.sheet__grip), a, input, textarea, [role=treeitem], [role=option]')) return;
  this.measure();
  const y0 = e.clientY, off0 = this.off, max = this.s.full - this.s.peek;
  let last = [{ y: y0, t: performance.now() }], dragging = false;
  this.moved = false;
  const move = (ev) => {
    const dy = ev.clientY - y0;
    if (!dragging && Math.abs(dy) < 6) return;
    if (!dragging) { dragging = true; this.moved = true; this.el.classList.add('no-anim'); this.el.classList.add('is-dragging'); }
    let o = off0 + dy;
    if (o < 0) o = -Math.sqrt(-o) * 2.5; else if (o > max) o = max + Math.sqrt(o - max) * 2.5;   // rubber band
    this.off = o; this.el.style.transform = `translate3d(0, ${o}px, 0)`;
    last.push({ y: ev.clientY, t: performance.now() }); if (last.length > 6) last.shift();
  };
  const up = () => {
    removeEventListener('pointermove', move); removeEventListener('pointerup', up); removeEventListener('pointercancel', up);
    this.el.classList.remove('is-dragging');
    if (!dragging) return;
    const a = last[0], b = last[last.length - 1], v = (b.y - a.y) / Math.max(16, b.t - a.t);   // px per ms
    const proj = clamp(this.off + v * 220, 0, max);
    const vis = this.s.full - proj;
    const pick = Object.entries({ peek: this.s.peek, half: this.s.half, full: this.s.full }).sort((p, q) => Math.abs(p[1] - vis) - Math.abs(q[1] - vis))[0][0];
    requestAnimationFrame(() => this.set(pick, true));
    setTimeout(() => (this.moved = false), 50);
  };
  addEventListener('pointermove', move); addEventListener('pointerup', up); addEventListener('pointercancel', up);
}
Stage 3 · chapters and a lessonOpen ↗
The Layout book open in the sidebar with its chapter tree: Space, Grids and Composition, ticks beside the finished lessons, Grids that breathe highlighted. The lesson column shows Layout progress 4 of 10, the lesson Grids that breathe with its grid picture, its text and a takeaway.

4 · Search

Search looks through the course titles and every lesson's title, lead and text. The books that don't match get a grey veil (68 %); the ones that do get a gold ring and glow. The count is written for screen readers and spoken 450 ms after the last key, so typing is not read out letter by letter.

library.js — search
function runSearch(raw) {
  query = raw.trim();
  $('#q-clear').hidden = !query;
  if (!query) { shelf.setFilter(null); $('#search-count').textContent = ''; updateTree(); return; }
  const q = query.toLowerCase(), hits = new Set();
  for (const c of courses.values()) {
    if (c.title.toLowerCase().includes(q) || c.lessons.some((l) => l.title.toLowerCase().includes(q) || l.lead.toLowerCase().includes(q) || l.body.some((p) => p.toLowerCase().includes(q)))) hits.add(c.id);
  }
  shelf.setFilter(hits);
  updateTree();
  const msg = hits.size ? `${hits.size} ${hits.size === 1 ? 'course matches' : 'courses match'} “${query}”` : `No course matches “${query}”`;
  $('#search-count').textContent = msg;
  clearTimeout(sayT); sayT = setTimeout(() => say(msg), 450);
  return hits;
}
shelf.js — dim and glow
setFilter(set) {
  this.filter = set;
  for (const [id, li] of this.nodes) {
    li.classList.toggle('is-dim', !!set && !set.has(id));
    li.classList.toggle('is-match', !!set && set.has(id));
  }
}
library.css — the veil and the glow
.slot.is-dim .book__dim { opacity: .68; }
.slot.is-match .book__spine { box-shadow: 0 0 0 2px #f4d27c, 0 0 20px 2px rgba(244, 210, 124, .5), 4px 1px 8px rgba(0, 0, 0, .5); }

Inside the open book, matching lessons are marked and the others fade to 38 %. The marking escapes the text first, so a lesson title can never inject HTML.

library.js — the tree, searched
const hit = q && (l.title.toLowerCase().includes(q) || l.lead.toLowerCase().includes(q) || l.body.some((p) => p.toLowerCase().includes(q)));
if (a.dataset.q !== q) { a.dataset.q = q; a.querySelector('.ls__t').innerHTML = q ? markText(l.title, query) : esc(l.title); }
a.classList.toggle('is-hit', !!hit);
a.classList.toggle('is-miss', !!q && !hit && !courseTitleHit(l.course));
util.js — <mark>, safely
export function markText(text, q) {
  if (!q) return esc(text);
  const t = String(text), low = t.toLowerCase(), ql = q.toLowerCase();
  let out = '', i = 0, j;
  while ((j = low.indexOf(ql, i)) !== -1) { out += esc(t.slice(i, j)) + '<mark>' + esc(t.slice(j, j + q.length)) + '</mark>'; i = j + q.length; }
  return out + esc(t.slice(i));
}

Enter opens the first matching course in shelf order, at the first lesson whose title matches (or else whose text does). Escape clears the field.

library.js — Enter and Escape
q.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') { if (q.value) { q.value = ''; runSearch(''); e.stopPropagation(); } else if (layout === 'phone') searchOpen(false); }
  if (e.key === 'Enter') {
    e.preventDefault(); const hits = runSearch(q.value); if (!hits || !hits.size) return;
    const id = shelf.order.find((k) => hits.has(k)), c = courses.get(id), ql = query.toLowerCase();
    const l = c.lessons.find((x) => x.title.toLowerCase().includes(ql)) || c.lessons.find((x) => x.lead.toLowerCase().includes(ql) || x.body.some((p) => p.toLowerCase().includes(ql))) || resumeLesson(c);
    location.hash = `#/${l.id}`;
    if (layout === 'phone') setTimeout(() => sheet.set('half'), 30);
  }
});
Stage 4 · searching for gridOpen ↗
The search field reads grid. On the shelf only Layout keeps its colour with a gold glow; the other books are veiled grey. In the open book, Grids, Columns and gutters and Grids that breathe are marked, the rest faded.

5 · Reordering

A book can be dragged to a new place. With a mouse the drag starts after 6 px. With a finger it starts after a 320 ms long press, and a finger that moves more than 10 px before that is scrolling the strip, not dragging.

shelf.js — press: mouse or long press
down(e) {
  if (e.button !== 0 || this.drag) return;
  const b = e.target.closest('.book'); if (!b) return;
  const id = b.dataset.id;
  if (this.o.canDrag && !this.o.canDrag(id)) return;
  const touch = e.pointerType !== 'mouse';
  const press = this.press = { id, x: e.clientX, y: e.clientY, pid: e.pointerId, touch, t: 0 };
  const move = (ev) => {
    if (ev.pointerId !== press.pid) return;
    if (this.drag) return this.dragMove(ev);
    const dx = ev.clientX - press.x, dy = ev.clientY - press.y, d = Math.hypot(dx, dy);
    if (!press.touch && d > 6) this.startDrag(press, ev);
    else if (press.touch && d > 10) cleanup();      // a finger that moves before the long press is scrolling
  };
  const up = (ev) => {
    if (ev.pointerId !== press.pid) return;
    if (this.drag) { this.endDrag(ev.type === 'pointercancel'); this.suppressClick = true; setTimeout(() => (this.suppressClick = false), 400); }
    cleanup();
  };
  const cleanup = () => { clearTimeout(press.t); removeEventListener('pointermove', move); removeEventListener('pointerup', up); removeEventListener('pointercancel', up); if (this.press === press) this.press = null; };
  addEventListener('pointermove', move); addEventListener('pointerup', up); addEventListener('pointercancel', up);
  if (touch) press.t = setTimeout(() => { if (this.press === press) this.startDrag(press, { clientX: press.x, clientY: press.y }); }, 320);
}

While you drag, the book follows the pointer and tips with its speed, up to 14°, like something held at the top (the speed is smoothed, 0.7 old and 0.3 new). On every move the engine tries every place the book could go, lays the shelf out for each, and keeps the one whose slot is nearest the pointer (height counts 1.3 ×). The others glide into their new slots, and a book dragged into the top compartment lies down while you hold it. Near the strip's ends it scrolls by itself.

shelf.js — the nearest slot
dragMove(ev) {
  const D = this.drag; if (!D) return;
  if (this.mode !== 'desk') { const r = this.ul.getBoundingClientRect(); D.ox = r.left; D.oy = r.top; }   // the strip may have scrolled
  const cx = ev.clientX - D.ox - D.gx, cy = ev.clientY - D.oy - D.gy;
  // the book follows the pointer and tips with its speed, like something held at the top
  D.vx = D.vx * 0.7 + (ev.clientX - D.lastX) * 0.3; D.lastX = ev.clientX;
  D.tilt = clamp(D.vx * 1.4, -14, 14);
  const base = { ...this.pos.get(D.id) || D.base };
  const c0 = Shelf.center(base);
  const q = { ...base, x: base.x + (cx - c0.x), y: base.y + (cy - c0.y), lean: 0 };
  D.li.style.transform = Shelf.transform(q, D.tilt);
  // nearest slot: try every insertion point, keep the closest
  const rest = this.order.filter((k) => k !== D.id);
  let best = D.idx, bd = Infinity, bestPos = null;
  for (let i = 0; i <= rest.length; i++) {
    const ord = [...rest.slice(0, i), D.id, ...rest.slice(i)];
    const pos = this.compute(ord, this.cfg, true); if (!pos) continue;
    const c = Shelf.center(pos.get(D.id)), dd = Math.hypot(c.x - cx, (c.y - cy) * 1.3);
    if (dd < bd) { bd = dd; best = i; bestPos = pos; }
  }
  if (best !== D.idx && bestPos) {
    D.idx = best; this.order = [...rest.slice(0, best), D.id, ...rest.slice(best)];
    this.apply(bestPos, D.id); this.o.afterLayout?.(bestPos, this.cfg, this.mode, false);
  }
  // edge auto-scroll in the strip
  const sc = this.scene, sr = sc.getBoundingClientRect();
  if (this.mode === 'strip') { if (ev.clientX < sr.left + 36) sc.scrollLeft -= 10; else if (ev.clientX > sr.right - 36) sc.scrollLeft += 10; }
}
library.css — the held book
.slot.is-dragging .book__lift { transform: perspective(760px) translateZ(36px) scale(1.04); }
.slot.is-dragging .book__spine { box-shadow: 14px 16px 26px rgba(0, 0, 0, .55), inset 0 0 0 1px rgba(0, 0, 0, .3); }

On release the book lands. Escape (or a cancelled pointer) puts it back where it was. The order is saved and the move is announced: "Motion moved to position 1 of 9".

shelf.js — drop
endDrag(cancel = false) {
  const D = this.drag; if (!D) return;
  if (cancel) { this.order.splice(this.order.indexOf(D.id), 1); this.order.splice(D.from, 0, D.id); }
  this.drag = null;
  D.li.classList.remove('is-dragging');
  D.li._tr = '';
  this.layout(true); this.syncDom();
  const moved = this.order.indexOf(D.id) !== D.from;
  this.o.onDragEnd?.(D.id, moved);
  if (moved && !cancel) {
    this.o.onReorder?.(this.order, D.id);
    this.o.announce?.(`${this.o.items.get(D.id).title} moved to position ${this.order.indexOf(D.id) + 1} of ${this.order.length}`);
  }
}

The DOM order catches up 760 ms later, after the glide: moving a node in the middle of its transition would cut it. Only the nodes that are out of place move, and the focus is kept.

shelf.js — the DOM follows
syncDom() {
  clearTimeout(this._sync);
  this._sync = setTimeout(() => {
    if (this.drag) return this.syncDom();
    const act = document.activeElement;
    // as few moves as possible: a moved node is re-attached and restyled, the rest are left alone
    let cur = this.ul.firstElementChild;
    for (const id of this.all) {
      const n = this.nodes.get(id); if (!n) continue;
      if (n === cur) { cur = cur.nextElementSibling; continue; }
      this.ul.insertBefore(n, cur);
    }
    if (act && this.ul.contains(act) && document.activeElement !== act) act.focus({ preventScroll: true });
  }, 760);
}

Dragging is not the only way. Alt + an arrow key moves the focused book one place (Ctrl + Shift + arrow too), and the ‹ › buttons under the open book move it with a single click, the alternative to dragging that WCAG 2.5.7 asks for.

shelf.js — Alt + arrow
if ((e.altKey || (e.ctrlKey && e.shiftKey)) && (fwd || back)) {
  e.preventDefault();
  if (this.move(id, fwd ? 1 : -1)) this.o.announce?.(`${this.o.items.get(id).title} moved to position ${this.order.indexOf(id) + 1} of ${this.order.length}`);
  else this.o.announce?.(`${this.o.items.get(id).title} is already ${fwd ? 'last' : 'first'}`);
  return;
}
library.js — the ‹ › buttons
function updateMover() {
  const id = openId || S.data.current.split('/')[0], i = shelf.order.indexOf(id);
  $('#mover-label').textContent = `${courses.get(id)?.title || ''} · ${i + 1} of ${shelf.order.length}`;
  $('#move-prev').disabled = i <= 0; $('#move-next').disabled = i >= shelf.order.length - 1;
}
for (const [sel, d] of [['#move-prev', -1], ['#move-next', 1]]) {
  $(sel).addEventListener('click', () => {
    const id = openId || S.data.current.split('/')[0];
    if (shelf.move(id, d)) { updateMover(); say(`${courses.get(id).title} moved to position ${shelf.order.indexOf(id) + 1} of ${shelf.order.length}`); }
    requestAnimationFrame(() => setThickness(id));
  });
}
Stage 5 · dragging MotionOpen ↗
Motion is held over the top compartment and lies down there above Typography; Color has stood up into the middle row and the other books have moved along.

6 · A new course

New course (or the + on a phone) adds a book to the end of the shelf. It starts outside the case, 40 px past its right edge and turned 8°, and glides in over 0.9 s. Its cloth is the first of ten colours no other course uses. Up to 14 courses fit.

shelf.js — a book slides in
add(item, from = 'right') {
  this.o.items.set(item.id, item); this.order.push(item.id);
  const li = this.make(item.id);
  this.measure(this.cfg || this.o.configs(this.mode, ...Object.values(this.size()), this.all.length)[0]);
  const d = this.dim.get(item.id); li.style.width = d.w + 'px'; li.style.height = d.h + 'px'; li._w = d.w; li._h = d.h;
  const { W, H } = this.size();
  li.classList.add('is-entering');
  li.style.transition = 'none';
  li.style.transform = from === 'top' ? `translate(${Math.round(W * 0.6)}px, ${Math.round(-d.h - 40)}px) rotate(-14deg)` : `translate(${Math.round(W + 40 + this.scene.scrollLeft)}px, ${Math.round(H * 0.5)}px) rotate(8deg)`;
  li._tr = li.style.transform;
  void li.offsetWidth; li.style.transition = '';
  requestAnimationFrame(() => { this.layout(true); setTimeout(() => li.classList.remove('is-entering'), 900); });
  if (this.filter) this.setFilter(this.filter);
  return li;
}
library.js — a new course
function addCourse() {
  if (adding) { popName.focus(); return; }
  if (shelf.order.length >= 14) { toast('The shelf is full. Remove a course to add another.'); return; }
  const used = new Set([...courses.values()].map((c) => c.color));
  const color = CLOTH.find((c) => !used.has(c)) || CLOTH[shelf.order.length % CLOTH.length];
  const id = 'c-' + Date.now().toString(36);
  const c = prepare(starterCourse(id, 'New course', color));
  adding = c;
  shelf.add(c, layout === 'desk' ? 'right' : 'right');
  popName.value = '';
  pop.hidden = false;
  placePop(id);
  setTimeout(() => placePop(id), 680);
  popName.focus();
  say('New course added to the shelf. Type its title.');
}

A popover beside the new spine takes its title (24 characters at most). The title is set on the spine in gold foil as you type, and the initials for the rail are worked out with it. "Add to shelf" opens the course; it has a real two-lesson outline to start from. Escape or Cancel takes the book away again.

library.js — the popover beside the spine
function placePop(id) {
  if (pop.hidden) return;
  const r = shelf.node(id)?.getBoundingClientRect(), pw = pop.offsetWidth, ph = pop.offsetHeight;
  let x, y;
  if (!r || layout === 'phone') { x = (innerWidth - pw) / 2; y = Math.min(innerHeight * 0.3, innerHeight - ph - 20); }
  else { x = r.right + 14; y = r.top + r.height / 2 - ph / 2; if (x + pw > innerWidth - 12) x = r.left - pw - 14; }
  pop.style.transform = `translate3d(${Math.round(clamp(x, 12, innerWidth - pw - 12))}px, ${Math.round(clamp(y, 12, innerHeight - ph - 12))}px, 0)`;
}
library.js — the title, as you type
popName.addEventListener('input', () => {
  if (!adding) return;
  const t = popName.value.trim() || 'New course';
  adding.title = t; adding.abbr = t.split(/\s+/).map((w) => w[0]).join('').slice(0, 2).toUpperCase() || 'N';
  shelf.paint(adding.id);
});

Your own courses can be removed. The book drops 60 px, turns 10° and fades, and a toast offers Undo for 6 s.

shelf.js — a book leaves
remove(id) {
  const li = this.nodes.get(id); if (!li) return;
  this.order = this.order.filter((k) => k !== id); this.nodes.delete(id); this.o.items.delete(id);
  li.classList.add('is-leaving');
  const p = this.pos.get(id);
  if (p && !reducedMotion()) { li.style.transform = `translate(${Math.round(p.x)}px, ${Math.round(p.y + 60)}px) rotate(${p.rot + 10}deg)`; li.style.opacity = '0'; setTimeout(() => li.remove(), 450); }
  else li.remove();
  this.layout(true);
  if (this.focusId === id) this.setTabStop(null);
}
library.js — Undo
toast(`${c.title} removed`, { label: 'Undo', run: () => {
  const back = prepare(starterCourse(saved.id, saved.title, saved.color));
  shelf.add(back); shelf.move(back.id, idx - shelf.order.indexOf(back.id));
  S.data.custom.push(saved); S.data.order = [...shelf.order]; S.save(); location.hash = `#/${back.lessons[0].id}`;
} });
util.js — the toast
export function toaster(el) {
  let t = 0;
  return (msg, action) => {
    el.innerHTML = ''; const s = document.createElement('span'); s.textContent = msg; el.append(s);
    if (action) { const b = document.createElement('button'); b.type = 'button'; b.textContent = action.label; b.onclick = () => { action.run(); el.classList.remove('on'); }; el.append(b); }
    el.classList.add('on'); clearTimeout(t); t = setTimeout(() => el.classList.remove('on'), action ? 6000 : 2600);
  };
}
Stage 6 · a new courseOpen ↗
A new green book stands at the end of the bottom shelf with Gardening in gold foil on its spine; beside it the New course popover, Title on the spine: Gardening, with Cancel and Add to shelf.

7 · The rail and the evening lamp

The case folds into a rail of 78 px: the books lie in one column, 7 px apart, and show their initials instead of a title. The button in the sidebar's header does it, and a window under 1000 px wide or 560 px tall is a rail by itself (the button is hidden then). The open book moves right, so its cover never hides the rail.

library.js — desk, rail or phone
function decideLayout() {
  const w = innerWidth, h = innerHeight;
  if (w < 720) return 'phone';
  if (w < 1000 || h < 560 || S.data.rail) return 'rail';
  return 'desk';
}
library.js — the rail's config
if (mode === 'rail') {
  return [{ key: `rail${W}`, rows: [{ mode: 'column', x: 0, top: 10, w: W, h: Infinity, gap: 7 }], unit: { w: 30, h: W - 14 },
    extent: { h: (pos) => Math.max(...[...pos.values()].map((p) => p.y)) + 14 } }];
}
shelf.js — a column of lying books
if (!full && (s.stack + d.w <= R.h + 0.5 || force)) {
  pos.set(id, { x: R.x + (R.w - d.h) / 2, y: R.top + s.stack + d.w, rot: -90, w: d.w, h: d.h, row: ri });
  s.stack += d.w + (R.gap ?? 6); s.n++; break;
}
library.css — the rail
.case__scene[data-mode="rail"] { inset: 12px 8px 12px 8px; overflow-x: hidden; overflow-y: auto; scrollbar-width: none; }
.case__scene[data-mode="rail"]::-webkit-scrollbar { display: none; }
.case__scene[data-mode="rail"] .book__title { display: none; }
.case__scene[data-mode="rail"] .book__abbr { display: block; }
.case__scene[data-mode="rail"] .book__bands { background-size: 100% 1.5px; }
.app[data-layout="rail"] .case { --frame: 8px; }
.app[data-layout="rail"] .tome { left: 34px; }   /* the open cover must not hide the rail */
.app[data-layout="rail"] .case::before { inset: 8px; }

The evening lamp is the dark theme. The page always opens in the light look; the dark one comes only from the lamp button and is remembered (shelf.library.theme in localStorage). A one-line script in the head reads it and sets data-theme before the first paint, so there is no flash; there is no switch on the system setting, so a Mac in dark mode still opens the daylight study. The case darkens, a brass picture light under the top board warms the middle shelf, and the pages stay warm cream.

library.js — the lamp
function setTheme(t, persist = true) {
  document.documentElement.dataset.theme = t;
  const dark = t === 'dark';
  $('#theme').innerHTML = icon(dark ? 'sun' : 'lamp');
  $('#theme').setAttribute('aria-pressed', String(dark));
  $('#theme').setAttribute('aria-label', dark ? 'Daylight theme' : 'Evening lamp theme');
  if (persist) try { localStorage.setItem('shelf.library.theme', t); } catch (e) {}
}
library.css — the picture light
.case__light { position: absolute; inset: 0; pointer-events: none; z-index: 2; opacity: 0; transition: opacity .8s; }
.case__light::before { content: ''; position: absolute; inset: 0;
  background: radial-gradient(ellipse 78% 44% at 36% calc(var(--b0, 30%) + 20%), rgba(255, 196, 120, .06) 0%, rgba(40, 20, 8, .30) 34%, rgba(14, 8, 3, .66) 66%, rgba(8, 5, 2, .82) 100%); }
.case__light::after { content: ''; position: absolute; inset: 0;
  background: radial-gradient(ellipse 54% 26% at 34% calc(var(--b0, 30%) + 9%), rgba(255, 170, 84, .30), rgba(255, 140, 60, .10) 50%, transparent 74%); }
.lamp { position: absolute; left: 14%; width: 46%; top: var(--b0, 30%); height: 9px; border-radius: 2px 2px 6px 6px; z-index: 3; opacity: 0; transition: opacity .6s;
  background: linear-gradient(180deg, #f6dc9a, #b8873a 60%, #7d5520); box-shadow: 0 2px 4px rgba(0, 0, 0, .5), 0 8px 26px 6px rgba(255, 190, 110, .55); }
.lamp::before { content: ''; position: absolute; left: 50%; top: -14px; width: 4px; height: 14px; transform: translateX(-50%); background: linear-gradient(90deg, #8a6024, #d9b46a, #8a6024); }
:root[data-theme="dark"] .case__light, :root[data-theme="dark"] .lamp { opacity: 1; }
.app[data-layout="rail"] .lamp, .app[data-layout="phone"] .lamp { display: none; }

Last, a warm-up. The first tap on a slow phone pays for compiling every function it runs, so once the page is idle it runs the first-open paths on detached nodes: every course's tree and a picture, a lesson and a marked search.

library.js — the warm-up
// warm-up: run the first-open code paths once on detached nodes, so the first real tap does not pay for compiling them
const idle = window.requestIdleCallback || ((f) => setTimeout(f, 300));
idle(() => {
  const tmp = document.createElement('div');
  for (const c of courses.values()) { tmp.innerHTML = treeHTML(c); art(c.art, 1, c.title); }
  const l = courses.get('color').lessons[3]; tmp.innerHTML = `${art('color', l.n, l.title)}<p>${esc(l.lead)}</p>${markText(l.title, 'to')}`;
  markFit(tmp); tmp.remove();
});

Reduced motion was built in stage by stage: the book does not fly or swing, books do not glide, and every state changes at once.

library.css — reduced motion
@media (prefers-reduced-motion: reduce) {
  .tome__front, .slot, .book__lift { transition-duration: 1ms !important; }
}

Measured with the project's frame probe on a 120 Hz screen (8.3 ms a frame) through a sweep across the spines, three books opened and one closed, a search, a drag and two Alt + arrow moves: no missed frames and no long tasks at 1440 × 900 or on a 390 × 844 phone, even with the CPU slowed 4 ×. The slowest frame took 10.6 ms.

Stage 7 · the finished Library, evening lampOpen ↗
The finished Library under the evening lamp: a dark case with a brass picture light warming the middle shelf, the Layout book open on its cream page, the lesson column dark with the grid picture.
Stage 7 · the Library on a phoneOpen ↗
The Library on a phone: the header, a strip of cloth spines on one walnut board, the lesson below and the Color course's chapters in a half-open bottom sheet.

8 · Nordic: the same engine, another shelf

The second look is a notes app on a floating light-oak shelf, with pastel notebooks, a brass bookend and a plant. It is a new page, nordic/, with its own CSS and its own data (6 notebooks, 21 notes), and the same shelf.js. The engine gains one option for it: trailing, an "Add notebook" spine that always stays last, is never dragged, and calls the page when clicked.

shelf.js — the trailing spine
const t = this.o.trailing && id === this.o.trailing.id;
const item = t ? null : this.o.items.get(id);
b.innerHTML = `<span class="book__lift">${t ? this.o.trailing.face(this.mode) : this.o.face(item, this.mode)}</span>`;
b.setAttribute('aria-label', t ? this.o.trailing.label : this.o.label(item));
nordic.js — the same engine, other options
const shelf = new Shelf(scene, {
  afterPaint: (li) => markFit(li),
  items: books, order: S.data.order, face, dims, configs, afterLayout,
  listLabel: 'Notebooks', controls: 'notes',
  trailing: { id: '__add', face: addFace, label: 'Add notebook' },
  label: (b) => b.title,
  describe: (b) => `${notesOf(b.id).length} notes`,
  announce: say,
  onSelect: (id) => pickBook(id),
  onTrailing: () => addNotebook(),
  onReorder: (order) => { S.data.order = [...order]; persistBooks(); updateMover(); },
});

Its configs are its own. The shelf floats 17 % of the height (at least 70 px) above the bottom. One unit is sized so that six notebooks and the add spine fit on the wall, at most 92 px. When a row is full, a second floating shelf appears above it and the books split between the two.

nordic.js — one shelf, or two
function configs(mode, W, H, n) {
  if (mode === 'rail') return [{ key: `rail${W}`, rows: [{ mode: 'column', x: 0, top: 6, w: W, h: Infinity, gap: 8 }], unit: { w: 30, h: W - 12 },
    extent: { h: (pos) => Math.max(...[...pos.values()].map((p) => p.y)) + 14 } }];
  if (mode === 'strip') return [{ key: `strip${H}`, rows: [{ mode: 'upright', x: 16, base: H - 18, w: Infinity, h: H, gap: 3 }], unit: { w: H / 4.6, h: H - 36 },
    extent: { w: (pos) => Math.max(...[...pos.values()].map((p) => p.x + p.w)) + 20 } }];
  // like the concept, the floating shelf runs past the wall: books fill the wall, the bookend and plant stand beyond it
  // six notebooks fill the wall; the add spine, bookend and plant stand on the part of the shelf that runs past it
  const base = Math.round(H - Math.max(70, H * 0.17)), top = 4, x0 = 40, rowW = W - x0 - 6;
  const u = Math.min(92, (W - x0 - 12) / 6.9);    // six notebooks + the add spine fit on the wall; nothing tall reaches the notes list
  const one = (k) => ({ key: `one${W}x${H}k${k}`, rows: [{ mode: 'upright', x: x0, base, w: rowW, h: base - top, gap: 3 }], boards: [base],
    unit: { w: u * k, h: Math.min(base - top, u * 9.6) } });
  // a second floating shelf appears above when one row is full: the bottom keeps half, the new shelf takes the rest
  const two = (k) => {
    const rowH = (base - top) / 2, b0 = Math.round(base - rowH), half = Math.ceil(n / 2);
    return { key: `two${W}x${H}n${n}k${k}`, rows: [{ mode: 'upright', x: x0, base, w: rowW, h: rowH - 26, gap: 3, max: half }, { mode: 'upright', x: x0, base: b0, w: rowW, h: rowH - 30, gap: 3 }],
      boards: [base, b0], unit: { w: Math.min(u, (W - x0) / (half + 0.4), (rowH - 40) / 5.4) * k, h: rowH - 40 } };
  };
  return [one(1), one(0.92), one(0.84), two(1), two(0.88), two(0.76), two(0.64)];
}

After every layout the page stands the bookend 3 px after the last book on the bottom shelf and the plant 18 px after the bookend, and widens the board under them, so the shelf runs past the wall as in the concept.

nordic.js — the bookend and the plant
// the bookend leans against the last book on the bottom shelf, the plant stands after it
let end = 0;
for (const [, p] of pos) if (p.row === 0) end = Math.max(end, p.x + p.w);
if (desk) {
  const base = cfg.rows[0].base, bw = bookend.offsetWidth, pw = plant.offsetWidth;
  const uh = Math.round(cfg.unit.h * 0.94); upright.style.height = uh + 'px';
  upright.style.transform = `translate3d(6px, ${base - uh}px, 0)`;
  bookend.style.transform = `translate3d(${end + 3}px, ${base - bookend.offsetHeight + 2}px, 0)`;
  const px = end + bw + 18;
  plant.style.transform = `translate3d(${px}px, ${base - plant.offsetHeight + 6}px, 0)`;
  const lower = scene.querySelector('.board:not(.board--upper)'); if (lower) lower.style.width = Math.round(px + pw + 26) + 'px';
  }
}

Nothing else needs JavaScript to look different: a selected notebook slides 26 px up out of the row, hover lifts one with a glow in its own colour.

nordic.css — selected
.slot.is-selected:not(.is-flat) .book__lift { transform: translateY(-26px); }
.slot.is-selected.is-flat .book__lift { transform: translateY(-10px); }
.slot.is-selected .book__spine { box-shadow: 0 16px 22px -10px var(--shadow-wall), 8px 4px 16px -6px var(--shadow-wall), inset 0 0 0 1px rgba(0, 0, 0, .05); }

A note is editable in place. The title grows with its text, Enter jumps to the body, and 350 ms after the last key the note is saved and a quiet "Saved" shows. A new notebook takes a name and one of 8 pastels from a real radio group (arrow keys move the choice) and drops onto the shelf from above.

nordic.js — autosave
function touched() {
  if (!editing) return;
  editing.edited = new Date().toISOString().slice(0, 19);
  $('#ed-date').textContent = `Edited ${fmt.format(new Date(editing.edited))}`;
  clearTimeout(saveT);
  saveT = setTimeout(() => { S.save(true); const s = $('#ed-saved'); s.classList.remove('on'); void s.offsetWidth; s.classList.add('on'); }, 350);
}
nordic.js — the colour, live on the spine
function pickSwatch(c, focus = false) {
  for (const b of sw.children) { const on = b.dataset.c === c; b.setAttribute('aria-checked', String(on)); b.tabIndex = on ? 0 : -1; if (on && focus) b.focus(); }
  if (adding) { adding.color = c; shelf.paint(adding.id); }
}
Stage 8 · NordicOpen ↗
Nordic: pastel notebooks (Ideas, Reading list, Recipes, Work, Journal) on a floating light-oak shelf with a brass bookend and a plant; Travel has slid up out of the row, and its notes list beside an open note, Lisbon in November.
Stage 8 · Nordic on a phoneOpen ↗
Nordic on a phone: pastel notebooks on a light-oak board under the header, Travel raised, the open note below and Travel's notes in a half-open sheet with a peach + button.

Make it yours

  • Your courses are COURSES in library/data.js: a title, two initials, a cloth colour, the kind of picture, and chapters of lessons (title, minutes, lead, two paragraphs, takeaway). The nine lessons that start ticked are DEFAULT_DONE in library.js.
  • The case is configs(): the compartment heights (23 % and 38.5 %), how many books lie on top (2), stand in the middle (4) and how far the last one may lean (17°). The cloth is shared/tex/cloth.png blended with each course's colour; CLOTH holds the ten colours new courses take.
  • Colours are tokens at the top of library.css, primitive → semantic → component, with the evening lamp's set under :root[data-theme="dark"].
  • Another look is another page: give Shelf your own face, dims and configs, as nordic.js does.
  • Scripts can drive it through window.__shelf (state(), open('layout'), search('grid'), progress('layout'), courses()) and Nordic through window.__notes.
  • Accessibility is already there: one tab stop for the shelf with arrow keys, Alt + arrow and ‹ › buttons to reorder without dragging, a real ARIA tree for the chapters, a polite live region for what changes, 44 px targets on touch screens, and prefers-reduced-motion honoured.

The code is on GitHub at UlviShabanbayli/domvybe-bookshelf-sidebar, under the MIT licence: use it in your own projects, commercial ones included. The fonts are Inter and Cormorant Garamond (SIL Open Font License). The courses and notes are written for the page, and no AI picture is used: the wood, the cloth, the plant, the bookend and every lesson picture are drawn by code.

The one idea worth keeping

Place everything with one transform and let CSS glide it. The shelf never lays out the page: every book is an absolutely placed slot, and a layout, a reorder, a new book or a resize only changes transforms, so one transition animates them all. Rounding them to whole pixels at rest keeps the gold titles as sharp as text.