`;
+ 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 fan ---------- */
+
+/** Where each card sits in the fan, after ref 10: a loose, overlapping spread. */
+const FAN = [
+ { a: -7, y: 34 },
+ { a: 4, y: 52 },
+ { a: -3, y: 6 },
+ { a: 2, y: 0 },
+ { a: 5, y: 44 },
+ { a: -4, y: 16 },
+];
+
+export function deck() {
+ sparkle();
+ const hand = $('[data-hand]');
+ const cards = CARDS.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;
+ // Overlap more when the fan would be wider than the page.
+ const spacing = () => {
+ const w = cards[0].offsetWidth;
+ return Math.min(w * 0.8, (hand.clientWidth - w * 1.15) / (cards.length - 1));
+ };
+ let hover = -1;
+
+ const layout = () => {
+ if (narrow()) return;
+ const n = cards.length;
+ cards.forEach((c, i) => {
+ const f = FAN[i % FAN.length];
+ const on = i === hover;
+ c.style.setProperty('--x', `${((i - (n - 1) / 2) * spacing()).toFixed(1)}px`);
+ c.style.setProperty('--a', `${on ? f.a * 0.35 : f.a}deg`);
+ c.style.setProperty('--lift', `${on ? f.y - 48 : f.y}px`);
+ c.style.setProperty('--sc', on ? 1.07 : 1);
+ c.style.setProperty('--z', on ? 60 : i + 1);
+ });
+ };
+
+ const setHover = (i) => {
+ if (i === hover) return;
+ if (hover >= 0) tilts[hover].leave();
+ hover = i;
+ layout();
+ };
+
+ // Which card the pointer is on, by fixed columns across the fan: lifting a
+ // card never moves the boundaries, so hovering one can't hand off to another.
+ const pick = (e) => {
+ const r = hand.getBoundingClientRect();
+ const n = cards.length, w = spacing();
+ const i = Math.round((e.clientX - (r.left + r.width / 2)) / w + (n - 1) / 2);
+ const edge = Math.abs(e.clientX - (r.left + r.width / 2)) <= w * (n / 2) + cards[0].offsetWidth * 0.1;
+ return edge && i >= 0 && i < n ? i : -1;
+ };
+ hand.addEventListener('pointermove', (e) => {
+ if (e.pointerType !== 'mouse' || narrow()) return;
+ const i = pick(e);
+ setHover(i);
+ if (i >= 0) tilts[i].move(e);
+ });
+ hand.addEventListener('pointerleave', () => !narrow() && setHover(-1));
+ hand.addEventListener('click', (e) => {
+ if (narrow()) return;
+ const i = pick(e);
+ if (i >= 0) inspector.open(i, cards[i]);
+ });
+
+ cards.forEach((c, i) => {
+ c.style.setProperty('--delay', `${i * 70}ms`);
+ c.addEventListener('focus', () => !narrow() && setHover(i));
+ // Phones: the cards are a swipeable row and take their own taps.
+ c.addEventListener('click', () => narrow() && inspector.open(i, c));
+ c.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ inspector.open(i, c);
+ }
+ });
+ });
+ hand.addEventListener('focusout', (e) => !hand.contains(e.relatedTarget) && setHover(-1));
+ addEventListener('resize', layout);
+ layout();
+
+ // Deal the fan 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);
+
+ // The whole fan plays while it's on screen, like v1's cards, and pauses
+ // once it scrolls away. On phones the fan is a swipeable row: only the card
+ // nearest the middle plays.
+ let onScreen = false, playing = -1;
+ const centred = () => {
+ if (!narrow()) return cards.forEach((c) => playIn(c, onScreen));
+ const r = hand.getBoundingClientRect(), mid = r.left + r.width / 2;
+ let best = -1, dist = Infinity;
+ cards.forEach((c, i) => {
+ const b = c.getBoundingClientRect(), d = Math.abs(b.left + b.width / 2 - mid);
+ if (d < dist) (best = i), (dist = d);
+ });
+ const next = onScreen ? best : -1;
+ if (next === playing) return;
+ if (playing >= 0) playIn(cards[playing], false);
+ playing = next;
+ if (next >= 0) playIn(cards[next], true);
+ };
+ new IntersectionObserver(([en]) => ((onScreen = en.isIntersecting), centred()), { threshold: 0.4 }).observe(hand);
+ let settle = 0;
+ hand.addEventListener('scroll', () => (clearTimeout(settle), (settle = setTimeout(centred, 120))), { passive: true });
+
+ 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 = CARDS[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..e7ef057 100644
--- a/website/index.html
+++ b/website/index.html
@@ -15,8 +15,8 @@
-
-
+
+
@@ -73,41 +73,21 @@
-
-
+
+
-
Made with Morphotype
-
Every frame on this page came out of the app.
-
-
-
-
- Neon signNeon material, with flicker and glow keyframed.
-
-
-
- Holo cardA holographic shift that ramps over the clock.
-
-
-
- Liquid chromeChrome that flows by itself, with orbiting sparkles.
-
-
-
- Sticker packGroup motion, staggered one by one.
-
-
-
- Pop 3DExtrusion whose angle turns as it plays.
-
+
The poster deck
+
Puffy, chrome, pixel. Six posters from the app.
+
Each card is a real template with its real settings. Point at one to tilt it, then click to turn it over.
+
What it does
-
A whole type studio, in one small app.
+
A whole type studio, in one small app.
Morph fonts
Blend any two typefaces curve to curve. Letters stay on the baseline and x-height.
@@ -121,29 +101,37 @@
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.
+
Lines that draw themselves on, pixel badges that build back, vinyl that breathes. Under each scene are its real tracks: drag across them to scrub.