Animation pass: shared-element morph, sliding tabs, count-ups

New lib/anim.js (countUp, fadeSlideIn, segThumb), all reduced-motion
aware:

- Shared-element morph: tapping a card tags its art with
  view-transition-name; the detail skeleton now renders the real sprite
  at hero size (router passes the match, animates the skeleton mount), so
  the sprite flies from the grid into the hero and the skeleton→content
  swap is seamless.
- Segmented controls (Team / Search / Breeding) get a sliding pill that
  measures each button and eases between them.
- Count-ups: the dex-feed progress number and the detail Stats total
  tick up.
- Detail tab content fades/rises on switch.
- Global :active scale-press on buttons/chips/rows, plus soft
  background/border transitions.
- Hero sprite has a slow idle bob.
- Router swallows the AbortError when a fast follow-up nav skips an
  in-flight view transition.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-10 09:22:08 -04:00
parent 2e4d9ab329
commit a437f539fc
10 changed files with 232 additions and 23 deletions

View File

@ -45,6 +45,12 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
}, },
}); });
const art = el(
'div',
{ class: 'card__art' },
Sprite(species.id, { style: spriteStyle, versionGroup, alt: species.name, size: 116 }),
);
const card = el( const card = el(
'a', 'a',
{ {
@ -52,17 +58,19 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
href: `#/pokemon/${species.id}`, href: `#/pokemon/${species.id}`,
dataset: data, dataset: data,
style: `--type-main:${typeHex(mainType)}`, style: `--type-main:${typeHex(mainType)}`,
onclick: () => {
// Tag this sprite so the router's view transition morphs it into
// the detail hero.
for (const n of document.querySelectorAll('.card__art.is-morph')) n.classList.remove('is-morph');
art.classList.add('is-morph');
},
}, },
el('span', { class: 'card__ghost', 'aria-hidden': 'true' }, num), el('span', { class: 'card__ghost', 'aria-hidden': 'true' }, num),
el('span', { class: 'card__spot', 'aria-hidden': 'true' }), el('span', { class: 'card__spot', 'aria-hidden': 'true' }),
state.note && state.note.trim() state.note && state.note.trim()
? el('span', { class: 'card__notemark', title: 'Has a note', 'aria-hidden': 'true' }, '📝') ? el('span', { class: 'card__notemark', title: 'Has a note', 'aria-hidden': 'true' }, '📝')
: null, : null,
el( art,
'div',
{ class: 'card__art' },
Sprite(species.id, { style: spriteStyle, versionGroup, alt: species.name, size: 116 }),
),
caughtBtn, caughtBtn,
el( el(
'div', 'div',

View File

@ -1,4 +1,5 @@
import { el } from '../lib/dom.js'; import { el } from '../lib/dom.js';
import { countUp } from '../lib/anim.js';
const NS = 'http://www.w3.org/2000/svg'; const NS = 'http://www.w3.org/2000/svg';
@ -34,10 +35,13 @@ export function ProgressRing() {
svg.setAttribute('class', 'ring__svg'); svg.setAttribute('class', 'ring__svg');
svg.append(track, seenArc, caughtArc); svg.append(track, seenArc, caughtArc);
const value = el('span', { class: 'ring__value' }); const num = el('span', { class: 'ring__num' }, '0');
const tail = el('small', {});
const value = el('span', { class: 'ring__value' }, num, tail);
const node = el('div', { class: 'ring' }, svg, el('div', { class: 'ring__label' }, value)); const node = el('div', { class: 'ring' }, svg, el('div', { class: 'ring__label' }, value));
let first = true; let first = true;
let shown = 0;
function update(stats) { function update(stats) {
const draw = () => { const draw = () => {
const { caught = 0, seen = 0, total = 1 } = stats; const { caught = 0, seen = 0, total = 1 } = stats;
@ -47,7 +51,9 @@ export function ProgressRing() {
'stroke-dashoffset', 'stroke-dashoffset',
String(CIRC * (1 - Math.max(seen, caught) / safeTotal)), String(CIRC * (1 - Math.max(seen, caught) / safeTotal)),
); );
value.replaceChildren(String(caught), el('small', {}, `/${total}`)); tail.textContent = `/${total}`;
countUp(num, caught, { from: shown, duration: first ? 700 : 350 });
shown = caught;
}; };
// Defer the first draw a frame so the arc animates in from empty. // Defer the first draw a frame so the arc animates in from empty.
if (first) { if (first) {

View File

@ -1,4 +1,6 @@
import { el } from '../lib/dom.js'; import { el } from '../lib/dom.js';
import { Sprite } from './Sprite.js';
import { settings } from '../store/settings.js';
const bar = (w, h = 14) => const bar = (w, h = 14) =>
el('span', { class: 'sk-bar', style: `width:${w};height:${h}px` }); el('span', { class: 'sk-bar', style: `width:${w};height:${h}px` });
@ -13,7 +15,21 @@ const facts = () =>
); );
/** Placeholder shown while a Pokémon detail page loads. */ /** Placeholder shown while a Pokémon detail page loads. */
export function detailSkeleton() { export function detailSkeleton(match) {
const id = match && Number(match[1]);
// Real sprite in the hero spot (cheap, usually cached) so the tapped
// card's art can morph straight into it.
const art = id
? el(
'div',
{ class: 'sk-art sk-art--live', style: 'view-transition-name: pkmn-sprite' },
Sprite(id, {
style: settings.get().spriteStyle === 'default' ? 'official' : settings.get().spriteStyle,
alt: '',
size: 220,
}),
)
: el('div', { class: 'sk-art' });
return el( return el(
'section', 'section',
{ class: 'view pdetail sk' }, { class: 'view pdetail sk' },
@ -22,7 +38,7 @@ export function detailSkeleton() {
{ class: 'phero sk-hero' }, { class: 'phero sk-hero' },
el('div', { class: 'phero__top' }, bar('72px', 34), bar('130px', 14), bar('34px', 34)), el('div', { class: 'phero__top' }, bar('72px', 34), bar('130px', 14), bar('34px', 34)),
el('div', { class: 'phero__head' }, bar('55%', 30)), el('div', { class: 'phero__head' }, bar('55%', 30)),
el('div', { class: 'sk-art' }), art,
), ),
el( el(
'div', 'div',

62
src/lib/anim.js Normal file
View File

@ -0,0 +1,62 @@
import { prefersReducedMotion } from '../store/settings.js';
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
/**
* Tween a number into `node`'s text. Snaps straight to the value when the
* user prefers reduced motion. Returns a cancel fn.
*/
export function countUp(node, to, { from = 0, duration = 550, decimals = 0, prefix = '', suffix = '', ease = easeOutCubic } = {}) {
const fmt = (v) => `${prefix}${decimals ? v.toFixed(decimals) : Math.round(v)}${suffix}`;
if (prefersReducedMotion() || duration <= 0 || from === to) {
node.textContent = fmt(to);
return () => {};
}
let raf = 0;
const t0 = performance.now();
const tick = (now) => {
const p = Math.min(1, (now - t0) / duration);
node.textContent = fmt(from + (to - from) * ease(p));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}
/**
* Add a sliding pill behind an already-built `.seg` (its `.seg__btn`
* children can be unequal widths the thumb measures them). Returns a
* setter `setActive(index)`. The move itself is a CSS transition, so
* reduced-motion users just get an instant jump.
*/
export function segThumb(seg, _count, active = 0) {
const thumb = document.createElement('div');
thumb.className = 'seg__thumb';
seg.prepend(thumb);
let current = active;
const place = (i) => {
current = i;
const b = seg.querySelectorAll('.seg__btn')[i];
if (!b || !b.offsetWidth) return;
thumb.style.width = `${b.offsetWidth}px`;
thumb.style.transform = `translateX(${b.offsetLeft - 3}px)`;
};
requestAnimationFrame(() => place(active));
if (document.fonts?.ready) document.fonts.ready.then(() => place(current));
return place;
}
/**
* A short WAAPI fade + rise on an element that was just inserted. No-op
* under reduced motion.
*/
export function fadeSlideIn(node, { duration = 240, dy = 8 } = {}) {
if (prefersReducedMotion() || !node.animate) return;
node.animate(
[
{ opacity: 0, transform: `translateY(${dy}px)` },
{ opacity: 1, transform: 'none' },
],
{ duration, easing: 'cubic-bezier(.22,1,.36,1)' },
);
}

View File

@ -58,7 +58,12 @@ export function initRouter(host) {
if (current) current.dispatchEvent(new CustomEvent('view:teardown')); if (current) current.dispatchEvent(new CustomEvent('view:teardown'));
current = node; current = node;
if (animate && canAnimate()) { if (animate && canAnimate()) {
document.startViewTransition(() => host.replaceChildren(node)); const t = document.startViewTransition(() => host.replaceChildren(node));
// A fast follow-up navigation skips the in-flight transition; that's
// expected — swallow the AbortError from every promise it exposes.
t?.ready?.catch(() => {});
t?.finished?.catch(() => {});
t?.updateCallbackDone?.catch(() => {});
} else { } else {
host.replaceChildren(node); host.replaceChildren(node);
} }
@ -69,12 +74,14 @@ export function initRouter(host) {
const { route, match } = resolve(hash); const { route, match } = resolve(hash);
// Instant feedback: a shaped skeleton for detail pages, a light // Instant feedback: a shaped skeleton for detail pages, a light
// placeholder otherwise. // placeholder otherwise. Detail skeletons animate in so the tapped
// card's sprite can morph into the hero (shared-element transition).
const isPokemon = /^#\/pokemon\/\d+$/.test(hash);
mount( mount(
route.skeleton route.skeleton
? route.skeleton() ? route.skeleton(match)
: el('div', { class: 'view view--loading' }, 'Loading…'), : el('div', { class: 'view view--loading' }, 'Loading…'),
false, isPokemon,
); );
window.scrollTo(0, 0); window.scrollTo(0, 0);

View File

@ -1838,6 +1838,22 @@
border-radius: 26px; border-radius: 26px;
background: color-mix(in srgb, var(--text) 6%, var(--surface-2)); background: color-mix(in srgb, var(--text) 6%, var(--surface-2));
} }
/* Holds the real sprite at hero size so the card hero morph lands
without a size jump when the content swaps in. */
.sk-art--live {
width: min(300px, 64vw);
height: min(300px, 64vw);
border-radius: 0;
background: none;
display: grid;
place-items: center;
filter: drop-shadow(0 16px 24px rgba(0, 0, 0, 0.28));
}
.sk-art--live img {
width: 100%;
height: 100%;
object-fit: contain;
}
.sk .psheet { .sk .psheet {
margin-top: -28px; margin-top: -28px;
} }
@ -3277,3 +3293,71 @@
.shiny__new { .shiny__new {
align-self: flex-start; align-self: flex-start;
} }
/* ================= Micro-interactions ============================ */
/* Tactile press feedback low specificity so any element's own
:active / transition wins if it has one. */
:where(.button, .seg__btn, .feed-chip, .breed__chip, .formbar__pill,
.flavor__pill, .lineup__add, .lineup__clear, .shiny__step, .prog__row,
.tchart__row, .tchart__col, .gamecard, .picker__row, .weakspot__chip) {
transition: transform 0.09s var(--ease-spring), background-color 0.16s ease,
color 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
}
:where(.button, .seg__btn, .feed-chip, .breed__chip, .formbar__pill,
.flavor__pill, .lineup__add, .shiny__step, .prog__row, .tchart__row,
.tchart__col, .gamecard):active {
transform: scale(0.96);
}
.card:active {
transform: scale(0.975);
}
/* The dex-feed ring number pops when it changes. */
.ring__num {
display: inline-block;
font-variant-numeric: tabular-nums;
}
/* Hero sprite: a slow idle bob. Frozen by the reduced-motion kill-switch. */
.phero__art img {
animation: heroFloat 4.2s ease-in-out infinite;
}
@keyframes heroFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
/* Sliding thumb behind the active segment (JS sets --seg-i / --seg-n). */
.seg {
position: relative;
}
.seg__thumb {
position: absolute;
top: 3px;
bottom: 3px;
left: 3px;
width: 0;
border-radius: 999px;
background: var(--surface);
box-shadow: var(--shadow);
transition: transform 0.28s var(--ease-spring), width 0.28s var(--ease-spring);
pointer-events: none;
}
.seg__btn {
position: relative;
z-index: 1;
}
.seg:has(.seg__thumb) .seg__btn.is-active {
background: none;
box-shadow: none;
}
/* Shared-element morph: the tapped card's art flies to the hero. */
.card__art.is-morph {
view-transition-name: pkmn-sprite;
}
/* Isolate the morphing sprite so only it animates, not the whole page. */
::view-transition-group(pkmn-sprite) {
animation-duration: 0.34s;
animation-timing-function: var(--ease-spring);
}

View File

@ -6,6 +6,7 @@ import { getPokemon } from '../data/api.js';
import { prettify } from '../data/pokedex-resolver.js'; import { prettify } from '../data/pokedex-resolver.js';
import { Sprite } from '../components/Sprite.js'; import { Sprite } from '../components/Sprite.js';
import { openPokemonPicker } from '../components/PokemonPicker.js'; import { openPokemonPicker } from '../components/PokemonPicker.js';
import { segThumb } from '../lib/anim.js';
const EGG_LABEL = { const EGG_LABEL = {
water1: 'Water 1', water2: 'Water 2', water3: 'Water 3', water1: 'Water 1', water2: 'Water 2', water3: 'Water 3',
@ -29,13 +30,14 @@ export async function BreedingView() {
let group = groups.includes(ui.get().breedGroup) ? ui.get().breedGroup : groups[0]; let group = groups.includes(ui.get().breedGroup) ? ui.get().breedGroup : groups[0];
let target = snap.speciesById.get(ui.get().breedTarget) || null; let target = snap.speciesById.get(ui.get().breedTarget) || null;
const SEG = [
['groups', 'Egg groups'],
['compat', 'Can breed with'],
];
const seg = el( const seg = el(
'div', 'div',
{ class: 'seg' }, { class: 'seg' },
...[ ...SEG.map(([id, label], i) =>
['groups', 'Egg groups'],
['compat', 'Can breed with'],
].map(([id, label]) =>
el( el(
'button', 'button',
{ {
@ -44,7 +46,10 @@ export async function BreedingView() {
onclick: () => { onclick: () => {
mode = id; mode = id;
ui.set({ breedMode: id }); ui.set({ breedMode: id });
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', ['groups', 'compat'][i] === id)); seg
.querySelectorAll('.seg__btn')
.forEach((b, j) => b.classList.toggle('is-active', SEG[j][0] === id));
setThumb(i);
render(); render();
}, },
}, },
@ -52,6 +57,7 @@ export async function BreedingView() {
), ),
), ),
); );
const setThumb = segThumb(seg, SEG.length, Math.max(0, SEG.findIndex(([id]) => id === mode)));
const body = el('div', { class: 'breedview__body' }); const body = el('div', { class: 'breedview__body' });

View File

@ -18,6 +18,7 @@ import { Locations } from '../components/Locations.js';
import { FlavorText } from '../components/FlavorText.js'; import { FlavorText } from '../components/FlavorText.js';
import { typeHex } from '../lib/type-color.js'; import { typeHex } from '../lib/type-color.js';
import { buzz } from '../lib/haptics.js'; import { buzz } from '../lib/haptics.js';
import { countUp, fadeSlideIn } from '../lib/anim.js';
import { offensiveSummary } from '../data/type-chart.js'; import { offensiveSummary } from '../data/type-chart.js';
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
@ -158,6 +159,7 @@ export async function PokemonDetail(nationalId) {
let shiny = shinyAvailable && st.showShiny; let shiny = shinyAvailable && st.showShiny;
const artHolder = el('div', { const artHolder = el('div', {
class: `phero__art${boxedArt ? ' phero__art--boxed' : ''}`, class: `phero__art${boxedArt ? ' phero__art--boxed' : ''}`,
style: 'view-transition-name: pkmn-sprite',
}); });
const shinyBtn = shinyAvailable const shinyBtn = shinyAvailable
? el('button', { ? el('button', {
@ -341,6 +343,7 @@ export async function PokemonDetail(nationalId) {
const allGames = st.versionGroup === 'all'; const allGames = st.versionGroup === 'all';
let statBars = []; let statBars = [];
let statTotalNode = null;
// ---- Form-dependent content (rebuilt when a form is selected) ----- // ---- Form-dependent content (rebuilt when a form is selected) -----
function formFactsNode(p) { function formFactsNode(p) {
@ -420,13 +423,15 @@ export async function PokemonDetail(nationalId) {
const stat = (name) => p.stats.find((s) => s.stat.name === name)?.base_stat ?? 0; const stat = (name) => p.stats.find((s) => s.stat.name === name)?.base_stat ?? 0;
const total = STAT_NAMES.reduce((sum, n) => sum + stat(n), 0); const total = STAT_NAMES.reduce((sum, n) => sum + stat(n), 0);
statBars = STAT_NAMES.map((n) => StatBar(n, stat(n))); statBars = STAT_NAMES.map((n) => StatBar(n, stat(n)));
statTotalNode = el('span', { class: 'statbar__value' }, String(total));
statTotalNode.dataset.total = String(total);
return [ return [
el('div', { class: 'pstats' }, ...statBars), el('div', { class: 'pstats' }, ...statBars),
el( el(
'div', 'div',
{ class: 'statbar statbar--total' }, { class: 'statbar statbar--total' },
el('span', { class: 'statbar__label' }, 'Total'), el('span', { class: 'statbar__label' }, 'Total'),
el('span', { class: 'statbar__value' }, String(total)), statTotalNode,
el('div', { class: 'statbar__track' }), el('div', { class: 'statbar__track' }),
), ),
el('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'), el('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'),
@ -599,6 +604,7 @@ export async function PokemonDetail(nationalId) {
tabButtons[i].setAttribute('aria-selected', String(active)); tabButtons[i].setAttribute('aria-selected', String(active));
}); });
body.replaceChildren(tab.node); body.replaceChildren(tab.node);
fadeSlideIn(tab.node);
if (tab.id === 'stats') animateStats(); if (tab.id === 'stats') animateStats();
ui.set({ detailTab: tab.id }); ui.set({ detailTab: tab.id });
} }
@ -615,6 +621,10 @@ export async function PokemonDetail(nationalId) {
fill.dataset.animated = '1'; fill.dataset.animated = '1';
}); });
} }
if (statTotalNode && !statTotalNode.dataset.counted) {
statTotalNode.dataset.counted = '1';
countUp(statTotalNode, Number(statTotalNode.dataset.total), { duration: 650 });
}
}); });
} }

View File

@ -6,6 +6,7 @@ import { entry } from '../store/selection.js';
import { prettify } from '../data/pokedex-resolver.js'; import { prettify } from '../data/pokedex-resolver.js';
import { Sprite } from '../components/Sprite.js'; import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js'; import { TypeChip } from '../components/TypeChip.js';
import { segThumb } from '../lib/anim.js';
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim(); const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim();
@ -53,7 +54,7 @@ export async function SearchView() {
const seg = el( const seg = el(
'div', 'div',
{ class: 'seg' }, { class: 'seg' },
...TABS.map((t) => ...TABS.map((t, i) =>
el( el(
'button', 'button',
{ {
@ -62,7 +63,10 @@ export async function SearchView() {
onclick: () => { onclick: () => {
tab = t.id; tab = t.id;
ui.set({ searchTab: t.id }); ui.set({ searchTab: t.id });
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', TABS[i].id === tab)); seg
.querySelectorAll('.seg__btn')
.forEach((b, j) => b.classList.toggle('is-active', TABS[j].id === tab));
setThumb(i);
syncUI(); syncUI();
run(); run();
}, },
@ -71,6 +75,7 @@ export async function SearchView() {
), ),
), ),
); );
const setThumb = segThumb(seg, TABS.length, Math.max(0, TABS.findIndex((t) => t.id === tab)));
const filters = el('div', { class: 'lookup__filters' }); const filters = el('div', { class: 'lookup__filters' });
const note = el('p', { class: 'search__note' }); const note = el('p', { class: 'search__note' });

View File

@ -13,6 +13,7 @@ import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js'; import { TypeChip } from '../components/TypeChip.js';
import { CompareTable } from '../components/CompareTable.js'; import { CompareTable } from '../components/CompareTable.js';
import { openPokemonPicker } from '../components/PokemonPicker.js'; import { openPokemonPicker } from '../components/PokemonPicker.js';
import { segThumb } from '../lib/anim.js';
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim(); const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim();
@ -35,7 +36,7 @@ export async function TeamView() {
const seg = el( const seg = el(
'div', 'div',
{ class: 'seg' }, { class: 'seg' },
...MODES.map(([id, label]) => ...MODES.map(([id, label], i) =>
el( el(
'button', 'button',
{ {
@ -44,7 +45,10 @@ export async function TeamView() {
onclick: () => { onclick: () => {
mode = id; mode = id;
ui.set({ teamMode: id }); ui.set({ teamMode: id });
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', MODES[i][0] === id)); seg
.querySelectorAll('.seg__btn')
.forEach((b, j) => b.classList.toggle('is-active', MODES[j][0] === id));
setThumb(i);
render(); render();
}, },
}, },
@ -52,6 +56,7 @@ export async function TeamView() {
), ),
), ),
); );
const setThumb = segThumb(seg, MODES.length, Math.max(0, MODES.findIndex(([id]) => id === mode)));
const lineup = el('div', { class: 'lineup' }); const lineup = el('div', { class: 'lineup' });
const body = el('div', { class: 'teamview__body' }); const body = el('div', { class: 'teamview__body' });