Library / Tutorial

Origami FAQ,
step by step.

An accordion FAQ of folded paper. Each answer unfolds under its card as one concertina sheet, crease by crease, with the text riding every panel, light coming through the cotton and a folded crane floating beside it. Every word is real DOM; the paper is WebGL2. Three stages, each a live page you can run.

3Stages
5Questions
0Libraries

This is the whole build of the origami FAQ, in the order it was written: the help centre of a made-up handmade-paper mill in Kyoto, Tsuru Paper Mill. Each question is a cotton card with a folded corner; the open one turns navy dyed paper, and its answer unfolds under it as one long concertina sheet, crease by crease, with the text riding every panel. Each stage below is the page 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 framework and no build step: one HTML page, one style sheet and three ES modules. Every word is real DOM; the paper is one WebGL2 canvas behind the list, the crane another.

1 · The accordion, in paper made of CSS

Start with what a screen reader and a keyboard need: the WAI-ARIA accordion. Each question is a real button inside a heading; it says whether its answer is open and which region it controls, and the region is labelled by the button.

index.html — one question
<div class="item" data-i="1">
  <h3 class="qh"><button class="q" id="q2" type="button" aria-expanded="false" aria-controls="a2"><span class="n" aria-hidden="true">02</span><span class="t">Is the paper really handmade?</span><span class="plus" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 6v12M6 12h12"/></svg></span></button></h3>
  <div class="ans" id="a2" role="region" aria-labelledby="q2" hidden>
    <div class="ans-in">
      <p>Yes. Every sheet is pulled by hand from cotton rag and recycled linen in our mill in Kyoto, then pressed and dried for three days. No two sheets have the same deckle edge.</p>
      <p>Hold a sheet to the window and you will see it: a crane, pressed into the fibres while the paper was still wet. It is how we sign our work.</p>
      <div class="help" data-i="1"><a class="mill" href="#mill">Visit the mill <svg viewBox="0 0 20 12" aria-hidden="true"><path d="M1 6h17M13 1l5 5-5 5"/></svg></a><span class="hq">Helpful?</span><button type="button" data-v="yes" aria-pressed="false">Yes</button><button type="button" data-v="no" aria-pressed="false">No</button><span class="thanks" role="status"></span></div>
    </div>
  </div>
</div>

The paper is CSS for now: fibre and grain from two SVG noise filters, a folded corner from a diagonal gradient, the open card in navy, and the answer sheet with a crease line every two lines of text.

faq.css — CSS paper
.no-gl .q { background: var(--fiber), var(--grain), linear-gradient(180deg, #fffdf8, #f6f0e5); box-shadow: 0 1px 0 rgba(255, 255, 255, .8) inset, 0 1px 1px rgba(60, 45, 25, .12), 0 8px 16px -8px rgba(60, 45, 25, .35); }
.no-gl .q::after { content: ''; position: absolute; right: 0; top: 0; width: 22px; height: 22px; background: linear-gradient(225deg, var(--wall) 50%, #efe7d8 50%, #e2d8c6 100%); box-shadow: -2px 2px 3px rgba(60, 45, 25, .12); }
.no-gl .item.open .q { background: var(--fiber), linear-gradient(180deg, #2d3a63, #1f2a4b); box-shadow: 0 1px 0 rgba(255, 255, 255, .15) inset, 0 10px 24px -8px rgba(20, 25, 50, .55); }
.no-gl .ans { background: repeating-linear-gradient(180deg, rgba(255, 255, 255, .5) 0 1px, rgba(255, 255, 255, 0) 1px calc(var(--hp) - 1px), rgba(110, 90, 60, .14) calc(var(--hp) - 1px) var(--hp)), var(--fiber), var(--grain), #f8f2e7; box-shadow: 0 10px 20px -12px rgba(60, 45, 25, .45); }
.no-gl .ans.moving { opacity: 1; }

One question is open at a time. Opening one folds the others away; the state lives on the button.

app.js — open, close
function setOpen(i, open, { animate = true, focus = false } = {}) {
  const it = items[i]; if (!it) return;
  if (open) items.forEach((o) => { if (o !== it && o.target > 0.5) setOpen(o.i, false, { animate }); });
  it.target = open ? 1 : 0;
  it.node.classList.toggle('open', open);
  it.btn.setAttribute('aria-expanded', String(open));
  if (open) it.ans.hidden = false;           // the region exists while it unfolds (inert visual handled by .moving)
  if (!animate || RM) { it.p = it.target; it.v = 0; onSettled(it); }
  else if (open && it.p === 0) it.v = 0.15;
  if (animate && !RM) setFaqHeight(Math.max(faqH, items.reduce((t, o) => t + o.Hc + G.gap, 0) - G.gap + maxFolds()));
  else setFaqHeight(finalHeight());
  if (focus) it.btn.focus();
  kick();
}
app.js — the keys
el.faq.addEventListener('keydown', (e) => {
  const i = items.findIndex((it) => it.btn === document.activeElement); if (i < 0) return;
  let j = -1;
  if (e.key === 'ArrowDown') j = (i + 1) % items.length;
  else if (e.key === 'ArrowUp') j = (i - 1 + items.length) % items.length;
  else if (e.key === 'Home') j = 0;
  else if (e.key === 'End') j = items.length - 1;
  if (j >= 0) { e.preventDefault(); items[j].btn.focus(); }
});

The motion is already the real thing, even before there is any 3D. An answer is a concertina of panels, each two lines of text tall, so the creases always fall between lines. The panels open in pairs, a little after each other, and at rest the sheet keeps a 14° fold: it is paper, not a ribbon. Each panel's length is chosen so that at rest it projects to exactly one panel height, which is what lets the flat text sit on it later.

app.js — the concertina
const REST = 0.245;                    // the opened sheet keeps its folds (14°): a real concertina, never ironed flat
const PL = () => G.Hp / Math.cos(REST); // panel length: at rest each panel projects to exactly Hp, so the flat text never drifts off its panel
function pairAngles(it) {
  const M = Math.ceil(it.N / 2), sig = M > 1 ? 0.45 : 0, out = [];
  for (let j = 0; j < M; j++) {
    const s0 = M > 1 ? sig * j / (M - 1) : 0;
    const q = Math.max(0, Math.min(1, (it.p - s0) / (1 - sig)));
    const e = q * q * (3 - 2 * q) * 0.35 + q * 0.65;
    let b = HALF * (1 - e) + REST * e;
    if (it.p > 1) b -= (it.p - 1) * 0.9;            // a soft overshoot: the paper springs a touch past flat
    out.push(Math.max(-0.08, b));
  }
  return out;
}
function fold(it, hingeY) {
  const panels = [];
  if (it.p <= 0.0005 && it.v === 0) { it.foldH = 0; it.panels = panels; return; }
  const ang = pairAngles(it);
  let y = hingeY, z = 0, fh = 0;
  for (let k = 0; k < it.N; k++) {
    const b = ang[k >> 1], sg = k % 2 ? -1 : 1;
    const L = PL(), dy = Math.cos(b) * L, dz = sg * Math.sin(b) * L;
    panels.push({ k, b, sg, y0: y, z0: z, y1: y + dy, z1: z + dz });
    y += dy; z += dz; fh += dy;
  }
  it.foldH = fh; it.panels = panels;
}

A slightly under-damped spring drives it, so the paper settles with a soft overshoot. The cards below glide down by exactly what the sheet has opened.

app.js — the spring
function spring(o, key, vkey, target, k, d, dt) {
  const x = o[key], v = o[vkey] || 0;
  const a = (target - x) * k - v * d;
  const nv = v + a * dt, nx = x + nv * dt;
  o[vkey] = nv; o[key] = nx;
  if (Math.abs(target - nx) < 0.0006 && Math.abs(nv) < 0.004) { o[key] = target; o[vkey] = 0; return false; }
  return true;
}

Without 3D the flat sheet is simply revealed from the top while it opens.

app.js — the flat reveal
function syncText(it, moving) {
  // the flat sheet is revealed from the top while it opens (stage 2 folds it in 3D, the text riding every panel)
  it.ans.classList.toggle('moving', moving);
  if (moving) it.ans.style.clipPath = `inset(0 0 ${Math.max(0, it.N * G.Hp - it.foldH)}px 0)`;
  else if (it.ans.style.clipPath) it.ans.style.clipPath = '';
}

Each answer ends with "Helpful?", and the thank-you is a status line, so it is announced.

app.js — the answer to the answer
function applyHelp(it) {
  const v = helpful[it.i]; const h = it.help;
  h.querySelectorAll('button').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
  h.classList.toggle('done', !!v);
  $('.thanks', h).textContent = v ? (v === 'yes' ? 'Thanks — glad it helped.' : 'Thanks — we will make this answer better.') : '';
}

On arrival the second question opens by itself, a quarter of a second in, so the page shows what it does.

app.js — the showcase
// the showcase: the card turns navy and its answer unfolds, crease by crease, in the first second
setTimeout(() => setOpen(open, true), 260);
Stage 1 · the accordionOpen ↗
The Tsuru Paper Mill help centre in CSS paper: the crane drawn in SVG on the left over the headline Questions, neatly folded., five cream question cards with folded corners on the right, the second one navy with its answer open beneath it on a creased sheet.

2 · The paper

One transparent canvas goes behind the list, a little larger than it so the shadows fit.

index.html — the canvas
<canvas id="gl" aria-hidden="true"></canvas>

The paper starts as a texture baked once: the cloudy formation of a hand-pulled sheet, fine pale cotton strands, a few longer ones and sparse dark linen flecks.

paper.js — cotton rag
float form = fbm(uv + 1.7, 4.)*.7 + fbm(uv + 4.1, 16.)*.3;          // cloudy formation of a hand-pulled sheet
float light = fibres(uv, 120., 3., 1.0, .09);                        // fine pale cotton strands
float longf = fibres(uv, 44., 11., 1.3, .045);                       // a few longer, fainter ones
float dark = fibres(uv, 70., 9., .3, .16)*step(.94, hash(floor(uv*70.) + 2.));   // sparse linen flecks

A card is a flat quad whose fragment shader folds the corner down: everything past a diagonal is discarded and drawn again as the back of the paper lying on the card, with its own small shadow.

paper.js — the dog-ear
// dog-ear: the corner is folded down along (W - x) + y = d
float dg = uDog;
float diag = (W - sp.x) + sp.y;                       // < dg: the corner that is folded away
if (diag < dg - .7) discard;

The answer is a strip of panels, each a quad between two crease lines. A ridge gets a bright hairline of crushed fibre, a valley a dark one with a little occlusion, and they never disappear completely: paper remembers its folds.

paper.js — creases
// crease lines: crushed fibre on a ridge (bright hairline), a dark hairline + occlusion in a valley
float dT = v, dB = Hp - v;
float bendT = abs(uCrease.z), bendB = abs(uCrease.w);
float ridgeT = step(1.5, uCrease.x), ridgeB = step(1.5, uCrease.y)*(1. - uLast);
float valT = step(.5, uCrease.x)*(1. - ridgeT), valB = step(.5, uCrease.y)*(1. - step(1.5, uCrease.y))*(1. - uLast);
float lineT = exp(-dT/.8), lineB = exp(-dB/.8);
float memory = 1.;                                           // creases never fully disappear
crease += (ridgeT*lineT + ridgeB*lineB)*(memory*.9 + .9*max(bendT, bendB));
crease -= (valT*lineT + valB*lineB)*(memory*.9 + 1.1*max(bendT, bendB));
ao += valT*(sin(bendT))*exp(-dT/(Hp*.28))*.42 + valB*sin(bendB)*exp(-dB/(Hp*.28))*.42;

The second answer has the mill's mark in it. Where a panel faces away from the window, light comes through the sheet, and through the crane watermark more strongly, because the paper is thinner there.

paper.js — the watermark, as triangles
float craneSd(vec2 p){
  float d = sdTri(p, vec2(8., 52.), vec2(50., 44.), vec2(44., 62.));      // body + tail
  d = min(d, sdTri(p, vec2(44., 62.), vec2(92., 50.), vec2(58., 46.)));
  d = min(d, sdTri(p, vec2(30., 50.), vec2(64., 6.), vec2(56., 50.)));     // raised wing
  d = min(d, sdTri(p, vec2(46., 50.), vec2(30., 26.), vec2(56., 48.)));
  d = min(d, sdTri(p, vec2(86., 52.), vec2(96., 22.), vec2(90., 52.)));    // neck
  d = min(d, sdTri(p, vec2(94., 22.), vec2(100., 26.), vec2(95., 27.)));   // head
  d = min(d, sdTri(p, vec2(10., 54.), vec2(2., 30.), vec2(14., 52.)));     // tail point
  return d;
}
paper.js — light through the paper
// translucency: light through the sheet where it faces away from the window, brighter in the thin watermark
float wm = 0.;
if (uMark > .5){
  vec2 mp = (sp - uMarkPos)/1.15;
  float sd = craneSd(mp);
  wm = (1. - smoothstep(-.8, 1.4, sd))*(.75 + .25*fb.r);
}
float through = clamp(-ndl, 0., 1.)*g;
col += vec3(1., .93, .78)*(through*(.10 + .30*wm) + feather*.03);
col += vec3(.020, .018, .012)*wm - vec3(.010, .012, .016)*wm*(1. - through);

The text has to ride those panels and stay sharp, so it stays DOM. Each panel gets an aria-hidden, inert copy of the answer with every button, link and paragraph turned into a plain element, shifted so the panel shows its own two lines.

app.js — a copy without semantics
function cloneHTML(it) {
  const t = document.createElement('div'); t.innerHTML = it.ansIn.innerHTML;
  t.querySelectorAll('button, a, p').forEach((n) => {
    const r = document.createElement(n.tagName === 'P' ? 'div' : 'span');
    r.className = (n.className ? n.className + ' ' : '') + (n.tagName === 'P' ? 'p' : n.tagName === 'A' ? 'a' : 'btn');
    if (n.getAttribute('aria-pressed')) r.dataset.pressed = n.getAttribute('aria-pressed');
    r.innerHTML = n.innerHTML; n.replaceWith(r);
  });
  t.querySelectorAll('[id],[role],[tabindex]').forEach((n) => { n.removeAttribute('id'); n.removeAttribute('role'); n.removeAttribute('tabindex'); });
  return t.innerHTML;
}

The GL uses the CSS projection, so a panel's four projected corners give a CSS matrix3d that puts the slice exactly on the mesh: a homography from a rectangle to a quad.

app.js — rectangle to quad
function homography(W, H, q) {
  const [x0, y0] = q[0], [x1, y1] = q[1], [x2, y2] = q[2], [x3, y3] = q[3];
  const dx1 = x1 - x2, dx2 = x3 - x2, dx3 = x0 - x1 + x2 - x3, dy1 = y1 - y2, dy2 = y3 - y2, dy3 = y0 - y1 + y2 - y3;
  let g = 0, h = 0;
  const den = dx1 * dy2 - dx2 * dy1;
  if (Math.abs(dx3) > 1e-9 || Math.abs(dy3) > 1e-9) { g = (dx3 * dy2 - dx2 * dy3) / den; h = (dx1 * dy3 - dx3 * dy1) / den; }
  const a = x1 - x0 + g * x1, b = x3 - x0 + h * x3, c = x0, d = y1 - y0 + g * y1, e = y3 - y0 + h * y3, f = y0;
  return `matrix3d(${a / W},${d / W},0,${g / W},${b / H},${e / H},0,${h / H},0,0,1,0,${c},${f},0,1)`;
}
app.js — every slice on its panel
const O = eyeOf(it), W = G.listW;
for (const p of it.panels) {
  const s = it.slices[p.k];
  const q = [proj(O, 0, p.y0, p.z0), proj(O, W, p.y0, p.z0), proj(O, W, p.y1, p.z1), proj(O, 0, p.y1, p.z1)];
  const alpha = panelAlpha(p.b);
  // a panel turned away from the eye shows the back of the sheet (no print)
  const ny = -p.sg * Math.sin(p.b), nz = Math.cos(p.b);
  const vy = O[1] - (p.y0 + p.y1) / 2, vz = G.F - (p.z0 + p.z1) / 2;
  const facing = ny * vy + nz * vz > 0;
  if (alpha < 0.02 || !facing || q[3][1] - q[0][1] < 0.6) { if (s._v !== 0) { s._v = 0; s.style.opacity = '0'; } continue; }
  s.style.transform = homography(W, G.Hp, q);
  const o = alpha.toFixed(3); if (s._v !== o) { s._v = o; s.style.opacity = o; }
}

While the sheet moves, the slices ride it and the real answer waits underneath; at rest the slices fade and the real, untransformed answer is what you read and select. A card lifts under the mouse and sinks when pressed.

app.js — hover
it.btn.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') { it.hoverT = 1; kick(); } });

The crane is its own small canvas: flat-folded facets, vermilion wings raised in a V with a valley fold each.

crane.js — the wings
// wings: broad triangles raised in a V, a valley fold along each
for (const sg of [1, -1]) {
  const Wt = [.18, .78, 1.02 * sg], Wm = [-.02, .40, .52 * sg], Wf = [-.40, .26, .42 * sg], Wb = [.44, .30, .40 * sg];
  add('Rf', Wm, Wt, RED, 2 * sg); add('Rb', Wt, Wm, RED3, 2 * sg); add('Rf', 'Rb', Wm, RED2, 1 * sg); add('Rf', Wf, Wm, RED2, 1 * sg); add('Rb', Wm, Wb, RED3, 1 * sg); add(Wb, Wm, Wt, RED, 2 * sg);

It floats for a few seconds after the page loads and turns to follow the pointer, then eases still. When nothing moves, nothing is drawn. Without WebGL2 the CSS paper of stage 1 stays.

app.js — the crane follows the pointer
addEventListener('pointermove', (e) => {
  if (!crane || RM || e.pointerType === 'touch') return;
  const r = el.craneBox.getBoundingClientRect();
  const nx = Math.max(-1, Math.min(1, (e.clientX - (r.left + r.width / 2)) / (innerWidth * 0.6)));
  const ny = Math.max(-1, Math.min(1, (e.clientY - (r.top + r.height / 2)) / (innerHeight * 0.7)));
  C.yawT = 0.45 + nx * 0.3; C.pitchT = 0.4 + ny * 0.12;
  if (performance.now() > C.until - 6000) C.until = performance.now() + 6000;
  C.ampT = 1; kick();
}, { passive: true });
app.js — sleep at rest
const craneOn = craneFrame(now, dt);
if (active || craneOn) kick();
else { setFaqHeight(finalHeight()); }
app.js — WebGL2, or CSS paper
function startGL() {
  try { renderer = createRenderer(el.gl); } catch (e) { console.warn('[dv70] paper', e); renderer = null; }
  html.classList.toggle('no-gl', !renderer);
}
Stage 2 · the paperOpen ↗
The same page with the WebGL2 paper: the second answer unfolding under its navy card in 3D, its panels at an angle with the text riding them, crease lines and blind light on the paper, the cards below pushed down, and the folded 3D crane floating on the left.

3 · The finish

The open question and the "Helpful?" answers are remembered under two versioned keys, and older keys of the component are wiped before the first paint, where ?nogl can also ask for the CSS paper.

index.html — wipe old keys
// storage: versioned keys only (dv70.v1.open, dv70.v1.helpful); anything older is wiped. The theme is the page's own (light).
try {
  const keep = ['dv70.v1.open', 'dv70.v1.helpful'];
  for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && /^dv70/.test(k) && !keep.includes(k)) localStorage.removeItem(k); }
} catch (e) {}
app.js — reopen the last question
const st = store.get(KEY_OPEN);
const open = st === null ? 1 : Math.max(-1, Math.min(items.length - 1, parseInt(st, 10)));

A lost WebGL context drops to the CSS paper instead of an empty space, and a small test hook reports the state.

app.js — a lost context
el.gl.addEventListener('webglcontextlost', (e) => { e.preventDefault(); renderer = null; html.classList.add('no-gl'); items.forEach((it) => syncText(it, false)); layout(); });
app.js — the test hook
window.__faq = {
  ready,
  get busy() { return !!raf; },
Stage 3 · the finished FAQOpen ↗
The finished origami FAQ: the second answer unfolding crease by crease under its navy card, the text riding the panels, the folded crane floating beside the headline.
Stage 3 · on a phoneOpen ↗
The origami FAQ on a phone: Tsuru and the menu in the bar, the headline Questions, neatly folded. with the folded crane above the questions, the second card navy with its answer open beneath it on a creased cotton sheet.

Make it yours

  • Your questions are the .item blocks in index.html: a button and a region each. The panel count follows the answer's height, two lines per panel.
  • Your fold is in app.js: REST is the angle the sheet keeps at rest, the spring's stiffness and damping set how it lands.
  • Your paper is in paper.js: the dye colour, the fibre bake and the watermark, which is a list of triangles you can redraw as your own mark.
  • Accessibility is already there: buttons in headings with aria-expanded and aria-controls, labelled regions, ↑ / ↓ / Home / End, a dashed focus ring, a status line for the feedback, 44 px targets on touch, prefers-reduced-motion (instant toggles, a still crane) and the CSS paper without WebGL2.

Tsuru Paper Mill is made up. Everything on the page was made in code for this project: the paper and the crane are rendered by the shaders, the fallback is CSS.

The one idea worth keeping

Keep the words in the DOM and let the material carry them. If the 3D uses the same projection as CSS, every panel's corners turn into one matrix3d, and the text folds with the paper while staying perfectly sharp.