diff --git a/website/deck.js b/website/deck.js
new file mode 100644
index 0000000..0290c47
--- /dev/null
+++ b/website/deck.js
@@ -0,0 +1,375 @@
+// The material deck: one collectible card per animated showcase, held as a
+// hand. Cards tilt toward the pointer under a foil, play their loop on hover,
+// and open in an inspector where they turn over to show their timeline.
+import { SHOWCASES } from './showcases.js';
+
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+const $ = (s, el = document) => el.querySelector(s);
+const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
+const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
+
+/* ---------- Colour ---------- */
+
+const rgb = (h) => {
+ const n = parseInt(h.slice(1), 16);
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+};
+const hex = (c) => `#${c.map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0')).join('')}`;
+const mix = (a, b, t) => hex(rgb(a).map((v, i) => v + (rgb(b)[i] - v) * t));
+const lum = (h) => {
+ const [r, g, b] = rgb(h).map((v) => {
+ v /= 255;
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+};
+const sat = (h) => {
+ const c = rgb(h);
+ return (Math.max(...c) - Math.min(...c)) / 255;
+};
+
+/** The card's colours: its scene background, plus the most vivid ink for the foil rim. */
+function tones(s) {
+ const c1 = s.bg[0];
+ const accent = [...s.palette].slice(1).sort((a, b) => sat(b) - sat(a))[0] || '#8b72ff';
+ const dark = lum(c1) < 0.32;
+ return {
+ '--c1': c1,
+ '--c1l': mix(c1, '#ffffff', dark ? 0.16 : 0.45),
+ '--c1d': mix(c1, '#000000', dark ? 0.35 : 0.12),
+ '--fg': dark ? '#f6f2ea' : '#16141c',
+ '--chip': dark ? 'rgba(255,255,255,0.12)' : 'rgba(20,16,28,0.08)',
+ '--rimA': mix(accent, '#ffffff', 0.55),
+ '--rimB': mix(accent, '#000000', 0.12),
+ };
+}
+
+const fmt = (v) => (Math.abs(v) >= 10 || Number.isInteger(v) ? String(Math.round(v)) : String(+v.toFixed(2)));
+
+/* ---------- Card markup ---------- */
+
+function front(s) {
+ const stats = s.settings
+ .map((st) => {
+ const v = clamp((st.value - st.min) / (st.max - st.min), 0, 1);
+ const v2 = st.to == null ? null : clamp((st.to - st.min) / (st.max - st.min), 0, 1);
+ return `
${esc(st.label)} ${fmt(st.value)}${st.to == null ? '' : `→${fmt(st.to)}`} `;
+ })
+ .join('');
+ const sw = s.palette.slice(0, 5).map((c) => ` `).join('');
+ return `
+
+
${esc(s.title)} No.${String(s.no).padStart(2, '0')}/${SHOWCASES.length}
+
+
${esc(s.material)} ${s.duration.toFixed(1)} s · ${s.layers} layers
+
+
${esc(s.flavor)}
+
+
`;
+}
+
+function lanes(track, D) {
+ if (track.start != null) {
+ const l = (track.start / D) * 100, w = Math.max(1.5, ((track.end - track.start) / D) * 100);
+ return ` `;
+ }
+ const p = track.period ? (track.period / D) * 100 : 100;
+ return ` `;
+}
+
+function back(s) {
+ const MAX = 7;
+ const rows = s.rows.slice(0, MAX).map((r) => {
+ const tracks = r.tracks.length ? r.tracks.map((t) => `${lanes(t, s.duration)}
`).join('') : '
';
+ return ``;
+ });
+ const more = s.rows.length > MAX ? `and ${s.rows.length - MAX} more rows
` : '';
+ const ticks = Array.from({ length: Math.round(s.duration) + 1 }, (_, i) => `${i}s `).join('');
+ const all = s.rows.flatMap((r) => r.tracks);
+ const count = (k) => all.filter((t) => t.kind === k).length;
+ const kinds = [['enter', 'Comes in'], ['leave', 'Then'], ['loop', 'Loop'], ['param', 'Setting'], ['flow', 'Flows']].filter(([k]) => count(k));
+ const legend = kinds.map(([k, label]) => ` ${label} ${count(k)} `).join('');
+ const facts = `${s.layers} Layers
${s.rows.length} Moving
${all.length} Tracks
${s.duration * 30} Frames
`;
+ return `
+
+
${esc(s.title)} Timeline · ${s.duration.toFixed(1)} s
+
+ ${more}
+
${legend}
+ ${facts}
+
+
`;
+}
+
+function makeCard(s, i) {
+ const el = document.createElement('article');
+ el.className = 'tcard';
+ el.tabIndex = 0;
+ el.dataset.i = i;
+ el.setAttribute('role', 'button');
+ el.setAttribute('aria-label', `${s.title}: ${s.material}. Open card`);
+ for (const [k, v] of Object.entries(tones(s))) el.style.setProperty(k, v);
+ el.innerHTML = ``;
+ return el;
+}
+
+/* ---------- Foil texture ---------- */
+
+function sparkle() {
+ const c = document.createElement('canvas');
+ c.width = c.height = 90;
+ const g = c.getContext('2d');
+ const img = g.createImageData(90, 90);
+ for (let i = 0; i < 90 * 90; i++) {
+ const r = Math.random();
+ img.data[i * 4 + 3] = r < 0.035 ? 255 : r < 0.12 ? 190 : 90;
+ }
+ g.putImageData(img, 0, 0);
+ document.documentElement.style.setProperty('--sparkle', `url(${c.toDataURL()})`);
+}
+
+/* ---------- Tilt, eased toward the pointer ---------- */
+
+function tilter(el, range) {
+ const st = { rx: 0, ry: 0, tx: 0, ty: 0, raf: 0 };
+ const step = () => {
+ st.rx += (st.tx - st.rx) * 0.16;
+ st.ry += (st.ty - st.ry) * 0.16;
+ el.style.setProperty('--rx', `${st.rx.toFixed(2)}deg`);
+ el.style.setProperty('--ry', `${st.ry.toFixed(2)}deg`);
+ st.raf = Math.abs(st.tx - st.rx) + Math.abs(st.ty - st.ry) > 0.05 ? requestAnimationFrame(step) : 0;
+ };
+ const kick = () => st.raf || (st.raf = requestAnimationFrame(step));
+ return {
+ move(e) {
+ const r = el.getBoundingClientRect();
+ const px = clamp((e.clientX - r.left) / r.width, 0, 1), py = clamp((e.clientY - r.top) / r.height, 0, 1);
+ if (!reduced) {
+ st.tx = (0.5 - py) * range;
+ st.ty = (px - 0.5) * range * 1.2;
+ }
+ el.style.setProperty('--gx', `${(px * 100).toFixed(1)}%`);
+ el.style.setProperty('--gy', `${(py * 100).toFixed(1)}%`);
+ el.style.setProperty('--fx', `${(px * 100).toFixed(1)}%`);
+ el.style.setProperty('--fy', `${(py * 100).toFixed(1)}%`);
+ el.style.setProperty('--hov', '1');
+ kick();
+ },
+ leave() {
+ st.tx = st.ty = 0;
+ el.style.setProperty('--hov', '0');
+ kick();
+ },
+ };
+}
+
+function playIn(card, on) {
+ const v = $('video', card);
+ if (!v) return;
+ if (on && !reduced) {
+ if (!v.src) v.src = v.dataset.src;
+ v.onplaying = () => v.classList.add('playing');
+ v.play().catch(() => {});
+ } else {
+ v.pause();
+ }
+}
+
+/* ---------- The hand ---------- */
+
+export function deck() {
+ sparkle();
+ const hand = $('[data-hand]');
+ const cards = SHOWCASES.map((s, i) => makeCard(s, i));
+ cards.forEach((c) => hand.append(c));
+ const tilts = cards.map((c) => tilter(c, 16));
+ const narrow = () => matchMedia('(max-width: 720px)').matches;
+ let hover = -1;
+
+ const layout = () => {
+ if (narrow()) return;
+ const n = cards.length;
+ const step = innerWidth < 980 ? 4.4 : 5.4;
+ cards.forEach((c, i) => {
+ const base = (i - (n - 1) / 2) * step;
+ let a = base, lift = 0, sc = 1, z = i + 1;
+ if (hover >= 0) {
+ const d = i - hover;
+ if (d === 0) {
+ a = base * 0.55;
+ lift = -70;
+ sc = 1.12;
+ z = 60;
+ } else a = base + Math.sign(d) * Math.max(0, 3.6 - Math.abs(d) * 0.9);
+ }
+ c.style.setProperty('--a', `${a.toFixed(2)}deg`);
+ c.style.setProperty('--lift', `${lift}px`);
+ c.style.setProperty('--sc', sc);
+ c.style.setProperty('--z', z);
+ });
+ };
+
+ const setHover = (i) => {
+ if (i === hover) return;
+ if (hover >= 0) {
+ playIn(cards[hover], false);
+ tilts[hover].leave();
+ }
+ hover = i;
+ if (i >= 0) playIn(cards[i], true);
+ layout();
+ };
+
+ cards.forEach((c, i) => {
+ c.style.setProperty('--delay', `${i * 55}ms`);
+ c.addEventListener('pointerenter', (e) => e.pointerType === 'mouse' && setHover(i));
+ c.addEventListener('pointermove', (e) => {
+ if (e.pointerType !== 'mouse') return;
+ setHover(i);
+ tilts[i].move(e);
+ });
+ c.addEventListener('focus', () => setHover(i));
+ c.addEventListener('click', () => inspector.open(i, c));
+ c.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ inspector.open(i, c);
+ }
+ });
+ });
+ hand.addEventListener('pointerleave', () => setHover(-1));
+ hand.addEventListener('focusout', (e) => !hand.contains(e.relatedTarget) && setHover(-1));
+ addEventListener('resize', layout);
+ layout();
+
+ // Deal the hand when it comes into view, then drop the stagger so hovers are quick.
+ new IntersectionObserver(
+ ([en], io) => {
+ if (!en.isIntersecting) return;
+ hand.classList.add('dealt');
+ io.disconnect();
+ setTimeout(() => cards.forEach((c) => c.style.setProperty('--delay', '0ms')), 1400);
+ },
+ { threshold: 0.3 },
+ ).observe(hand);
+
+ // On phones the hand is a swipeable row: the card in the middle plays.
+ const centre = new IntersectionObserver(
+ (entries) => {
+ if (!narrow()) return;
+ for (const en of entries) playIn(en.target, en.isIntersecting);
+ },
+ { root: hand, threshold: 0.75 },
+ );
+ cards.forEach((c) => centre.observe(c));
+
+ const inspector = makeInspector(cards);
+}
+
+/* ---------- Inspector ---------- */
+
+function makeInspector(cards) {
+ const root = $('[data-inspector]');
+ const stage = $('[data-stage]', root);
+ let index = -1, card = null, tilt = null, source = null, swiped = false;
+
+ const angleOf = (el) => parseFloat(el.style.getPropertyValue('--a')) || 0;
+
+ const mount = (i) => {
+ const s = SHOWCASES[i];
+ card = makeCard(s, i);
+ card.removeAttribute('role');
+ card.setAttribute('aria-label', `${s.title}. Click to turn over`);
+ stage.replaceChildren(card);
+ tilt = tilter(card, 22);
+ card.addEventListener('pointermove', (e) => tilt.move(e));
+ card.addEventListener('pointerleave', () => tilt.leave());
+ card.addEventListener('click', () => (swiped ? (swiped = false) : flip()));
+ playIn(card, true);
+ };
+
+ const flip = () => card?.classList.toggle('flipped');
+
+ const flyFrom = (el, reverse = false) => {
+ const a = el.getBoundingClientRect(), b = stage.getBoundingClientRect();
+ const dx = a.left + a.width / 2 - (b.left + b.width / 2), dy = a.top + a.height / 2 - (b.top + b.height / 2);
+ const from = `translate(${dx}px, ${dy}px) scale(${a.width / b.width}) rotate(${angleOf(el)}deg)`;
+ const frames = [{ transform: from }, { transform: 'none' }];
+ return stage.animate(reverse ? frames.reverse() : frames, { duration: reduced ? 1 : reverse ? 420 : 640, easing: reverse ? 'cubic-bezier(.5,0,.75,0)' : 'cubic-bezier(.2,.9,.25,1.08)', fill: 'both' });
+ };
+
+ const open = (i, el) => {
+ index = i;
+ source = el;
+ mount(i);
+ root.hidden = false;
+ document.documentElement.style.overflow = 'hidden';
+ void root.offsetWidth;
+ root.classList.add('open');
+ el.style.visibility = 'hidden';
+ flyFrom(el);
+ $('[data-flip]', root).focus({ preventScroll: true });
+ };
+
+ const close = async () => {
+ if (index < 0) return;
+ root.classList.remove('open');
+ playIn(card, false);
+ const target = cards[index];
+ const visible = target.getBoundingClientRect();
+ if (visible.bottom > 0 && visible.top < innerHeight) {
+ card.classList.remove('flipped');
+ // Never let a stalled animation (a background tab) keep the page locked.
+ await Promise.race([flyFrom(target, true).finished.catch(() => {}), new Promise((r) => setTimeout(r, 600))]);
+ }
+ target.style.visibility = '';
+ stage.getAnimations().forEach((a) => a.cancel());
+ root.hidden = true;
+ document.documentElement.style.overflow = '';
+ target.focus({ preventScroll: true });
+ index = -1;
+ source = null;
+ };
+
+ const go = (d) => {
+ if (index < 0) return;
+ cards[index].style.visibility = '';
+ const next = (index + d + cards.length) % cards.length;
+ const out = card;
+ out.animate([{ transform: 'none', opacity: 1 }, { transform: `translateX(${-d * 60}px) rotate(${-d * 8}deg)`, opacity: 0 }], { duration: 220, easing: 'ease-in', fill: 'forwards' });
+ setTimeout(() => {
+ playIn(out, false);
+ index = next;
+ cards[next].style.visibility = 'hidden';
+ mount(next);
+ card.animate([{ transform: `translateX(${d * 60}px) rotate(${d * 8}deg)`, opacity: 0 }, { transform: 'none', opacity: 1 }], { duration: 420, easing: 'cubic-bezier(.2,.9,.25,1.1)' });
+ }, reduced ? 0 : 200);
+ };
+
+ root.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', close));
+ $('[data-flip]', root).addEventListener('click', flip);
+ $('[data-prev]', root).addEventListener('click', () => go(-1));
+ $('[data-next]', root).addEventListener('click', () => go(1));
+ addEventListener('keydown', (e) => {
+ if (index < 0) return;
+ if (e.key === 'Escape') close();
+ else if (e.key === 'ArrowRight') go(1);
+ else if (e.key === 'ArrowLeft') go(-1);
+ else if (e.key === 'f' || e.key === 'F') flip();
+ });
+
+ // Swipe between cards on touch screens.
+ let sx = null;
+ stage.addEventListener('pointerdown', (e) => e.pointerType !== 'mouse' && (sx = e.clientX));
+ stage.addEventListener('pointerup', (e) => {
+ if (sx == null) return;
+ const dx = e.clientX - sx;
+ sx = null;
+ if (Math.abs(dx) > 50) {
+ swiped = true;
+ go(dx < 0 ? 1 : -1);
+ }
+ });
+
+ return { open, close };
+}
diff --git a/website/index.html b/website/index.html
index 70f71ea..d61ee35 100644
--- a/website/index.html
+++ b/website/index.html
@@ -73,41 +73,21 @@
-
-
+
+
-
Made with Morphotype
-
Every frame on this page came out of the app.
-
-
-
-
- Neon sign Neon material, with flicker and glow keyframed.
-
-
-
- Holo card A holographic shift that ramps over the clock.
-
-
-
- Liquid chrome Chrome that flows by itself, with orbiting sparkles.
-
-
-
- Sticker pack Group motion, staggered one by one.
-
-
-
- Pop 3D Extrusion whose angle turns as it plays.
-
+
The material deck
+
Ten cards, dealt straight from the app.
+
Each card is a real template with its real settings. Tilt one to catch the foil, then click it to turn it over.
+
What it does
-
A whole type studio, in one small app.
+
A whole type studio, in one small app.
-
-
+
+
-
Ten materials, all moving
-
Open any of these in Learn and take it apart.
+
Motion, frame for frame
+
The timeline you edit is the one that plays.
+
These are the real tracks from each showcase. Drag across the timeline to scrub.
-
-
-
-
-
-
-
+
+
+
+
Animated · Shape shifter
+
0.00 s · f0
+
+
+
+
+
+
-
+
Two modes
-
Design for logos and posts. Type for whole fonts.
+
Design for logos and posts. Type for whole fonts.
@@ -222,5 +210,17 @@
Get Morphotype.
+
+
+