Motion pass: view transitions, skeletons, micro-interactions
- Router uses the View Transitions API to cross-fade placeholder -> content on navigation (falls back to a plain swap where unsupported or when the user prefers reduced motion) - Shaped shimmer skeletons for the Pokémon / move / item detail pages (src/components/skeletons.js) so there's no layout shift on load - Views fade+rise in; progress ring draws its arc from empty on mount; caught / seen / favourite toggles pop when switched on - Offline indicator pill (online/offline events) + 'Back online' toast - All new motion gated behind prefers-reduced-motion Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
4ddc427149
commit
1d35f2d433
@ -28,6 +28,12 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
|
||||
const next = entry(species.id);
|
||||
caughtBtn.setAttribute('aria-pressed', String(next.caught));
|
||||
card.classList.toggle('is-caught', next.caught);
|
||||
if (next.caught && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
caughtBtn.animate(
|
||||
[{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }, { transform: 'scale(1)' }],
|
||||
{ duration: 260, easing: 'cubic-bezier(.34,1.4,.64,1)' },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -37,11 +37,25 @@ export function ProgressRing() {
|
||||
const value = el('span', { class: 'ring__value' });
|
||||
const node = el('div', { class: 'ring' }, svg, el('div', { class: 'ring__label' }, value));
|
||||
|
||||
function update({ caught = 0, seen = 0, total = 1 }) {
|
||||
const safeTotal = Math.max(total, 1);
|
||||
caughtArc.setAttribute('stroke-dashoffset', String(CIRC * (1 - caught / safeTotal)));
|
||||
seenArc.setAttribute('stroke-dashoffset', String(CIRC * (1 - Math.max(seen, caught) / safeTotal)));
|
||||
value.replaceChildren(String(caught), el('small', {}, `/${total}`));
|
||||
let first = true;
|
||||
function update(stats) {
|
||||
const draw = () => {
|
||||
const { caught = 0, seen = 0, total = 1 } = stats;
|
||||
const safeTotal = Math.max(total, 1);
|
||||
caughtArc.setAttribute('stroke-dashoffset', String(CIRC * (1 - caught / safeTotal)));
|
||||
seenArc.setAttribute(
|
||||
'stroke-dashoffset',
|
||||
String(CIRC * (1 - Math.max(seen, caught) / safeTotal)),
|
||||
);
|
||||
value.replaceChildren(String(caught), el('small', {}, `/${total}`));
|
||||
};
|
||||
// Defer the first draw a frame so the arc animates in from empty.
|
||||
if (first) {
|
||||
first = false;
|
||||
requestAnimationFrame(draw);
|
||||
} else {
|
||||
draw();
|
||||
}
|
||||
}
|
||||
|
||||
return { node, update };
|
||||
|
||||
50
src/components/skeletons.js
Normal file
50
src/components/skeletons.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { el } from '../lib/dom.js';
|
||||
|
||||
const bar = (w, h = 14) =>
|
||||
el('span', { class: 'sk-bar', style: `width:${w};height:${h}px` });
|
||||
|
||||
const facts = () =>
|
||||
el(
|
||||
'div',
|
||||
{ class: 'pfacts' },
|
||||
...Array.from({ length: 6 }, () =>
|
||||
el('div', { class: 'fact' }, bar('42px', 9), bar('72px', 15)),
|
||||
),
|
||||
);
|
||||
|
||||
/** Placeholder shown while a Pokémon detail page loads. */
|
||||
export function detailSkeleton() {
|
||||
return el(
|
||||
'section',
|
||||
{ class: 'view pdetail sk' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'phero sk-hero' },
|
||||
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: 'sk-art' }),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'psheet' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'psheet__tabs' },
|
||||
...Array.from({ length: 5 }, () => bar('58px', 18)),
|
||||
),
|
||||
el('div', { class: 'ppanel' }, bar('92%'), bar('84%'), bar('66%'), facts()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Placeholder for move / item detail pages. */
|
||||
export function lookupSkeleton() {
|
||||
return el(
|
||||
'section',
|
||||
{ class: 'view lookup sk' },
|
||||
bar('84px', 16),
|
||||
el('div', { class: 'sk-hero sk-hero--sm' }),
|
||||
facts(),
|
||||
el('div', { class: 'detail__section' }, bar('90px', 16), bar('96%'), bar('70%')),
|
||||
);
|
||||
}
|
||||
16
src/main.js
16
src/main.js
@ -49,6 +49,22 @@ const updateSW = registerSW({
|
||||
},
|
||||
});
|
||||
|
||||
// --- Offline indicator -------------------------------------------------
|
||||
const netPill = el('div', { class: 'netpill', role: 'status' }, 'Offline');
|
||||
function syncNet() {
|
||||
if (navigator.onLine) {
|
||||
netPill.remove();
|
||||
} else if (!netPill.isConnected) {
|
||||
document.body.append(netPill);
|
||||
}
|
||||
}
|
||||
window.addEventListener('offline', syncNet);
|
||||
window.addEventListener('online', () => {
|
||||
syncNet();
|
||||
showToast('Back online.', 'Dismiss');
|
||||
});
|
||||
syncNet();
|
||||
|
||||
function showToast(message, actionLabel, onAction) {
|
||||
const toast = el(
|
||||
'div',
|
||||
|
||||
@ -5,12 +5,13 @@ import { SearchView } from './views/SearchView.js';
|
||||
import { SettingsView } from './views/SettingsView.js';
|
||||
import { MoveDetail } from './views/MoveDetail.js';
|
||||
import { ItemDetail } from './views/ItemDetail.js';
|
||||
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
||||
|
||||
const routes = [
|
||||
{ pattern: /^#?\/?$/, view: () => DexGrid() },
|
||||
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])) },
|
||||
{ pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])) },
|
||||
{ pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])) },
|
||||
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])), skeleton: detailSkeleton },
|
||||
{ pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])), skeleton: lookupSkeleton },
|
||||
{ pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])), skeleton: lookupSkeleton },
|
||||
{ pattern: /^#\/search$/, view: () => SearchView() },
|
||||
{ pattern: /^#\/settings$/, view: () => SettingsView() },
|
||||
];
|
||||
@ -23,6 +24,10 @@ function resolve(hash) {
|
||||
return { route: routes[0], match: [] };
|
||||
}
|
||||
|
||||
const canAnimate = () =>
|
||||
typeof document.startViewTransition === 'function' &&
|
||||
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
let rerenderCurrent = null;
|
||||
|
||||
/** Re-run the active route (e.g. after the selected game changed). */
|
||||
@ -33,31 +38,38 @@ export function rerender() {
|
||||
export function initRouter(host) {
|
||||
let current = null;
|
||||
|
||||
function mount(node, animate) {
|
||||
if (current) current.dispatchEvent(new CustomEvent('view:teardown'));
|
||||
current = node;
|
||||
if (animate && canAnimate()) {
|
||||
document.startViewTransition(() => host.replaceChildren(node));
|
||||
} else {
|
||||
host.replaceChildren(node);
|
||||
}
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const hash = location.hash || '#/';
|
||||
const { route, match } = resolve(hash);
|
||||
|
||||
if (current) {
|
||||
current.dispatchEvent(new CustomEvent('view:teardown'));
|
||||
current.remove();
|
||||
current = null;
|
||||
}
|
||||
|
||||
const pending = el('div', { class: 'view view--loading' }, 'Loading…');
|
||||
host.replaceChildren(pending);
|
||||
// Views manage their own scroll: detail pages jump to the top, the feed
|
||||
// and search restore where the user left off.
|
||||
// Instant feedback: a shaped skeleton for detail pages, a light
|
||||
// placeholder otherwise.
|
||||
mount(
|
||||
route.skeleton
|
||||
? route.skeleton()
|
||||
: el('div', { class: 'view view--loading' }, 'Loading…'),
|
||||
false,
|
||||
);
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
try {
|
||||
const node = await route.view(match);
|
||||
// Guard against a fast second navigation while awaiting.
|
||||
if ((location.hash || '#/') !== hash) return;
|
||||
current = node;
|
||||
host.replaceChildren(node);
|
||||
if ((location.hash || '#/') !== hash) return; // superseded
|
||||
// Skeleton/placeholder -> content morphs via the View Transitions API.
|
||||
mount(node, true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
host.replaceChildren(
|
||||
mount(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'view view--error' },
|
||||
@ -65,6 +77,7 @@ export function initRouter(host) {
|
||||
el('p', {}, err.message || String(err)),
|
||||
el('a', { href: '#/', class: 'button' }, 'Back to the dex'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1718,3 +1718,110 @@
|
||||
.gamecard--all.is-active {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* ================= Motion: transitions, skeletons, indicators ======== */
|
||||
.view:not(.sk):not(.view--loading) {
|
||||
animation: viewIn 0.26s ease both;
|
||||
}
|
||||
@keyframes viewIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 0.26s;
|
||||
}
|
||||
|
||||
/* ---- Skeletons ---------------------------------------------- */
|
||||
.sk-bar {
|
||||
display: block;
|
||||
border-radius: 7px;
|
||||
margin: 7px 0;
|
||||
background-color: var(--surface-2);
|
||||
background-image: linear-gradient(
|
||||
100deg,
|
||||
transparent 20%,
|
||||
color-mix(in srgb, var(--text) 9%, transparent) 45%,
|
||||
transparent 70%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: shimmer 1.25s linear infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
from { background-position: 220% 0; }
|
||||
to { background-position: -120% 0; }
|
||||
}
|
||||
.sk-hero {
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 20px 44px;
|
||||
}
|
||||
.sk-hero--sm {
|
||||
min-height: 100px;
|
||||
padding: 0;
|
||||
}
|
||||
.sk-art {
|
||||
width: min(230px, 56vw);
|
||||
height: min(230px, 56vw);
|
||||
margin: 16px auto 0;
|
||||
border-radius: 26px;
|
||||
background: color-mix(in srgb, var(--text) 6%, var(--surface-2));
|
||||
}
|
||||
.sk .psheet {
|
||||
margin-top: -28px;
|
||||
}
|
||||
.sk .psheet__tabs {
|
||||
gap: 12px;
|
||||
border: none;
|
||||
}
|
||||
.sk .ppanel .sk-bar {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.sk .pfacts .sk-bar {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
/* ---- Online / offline indicator ------------------------------ */
|
||||
.netpill {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(var(--nav-size) + env(safe-area-inset-bottom) + 12px);
|
||||
transform: translateX(-50%);
|
||||
z-index: 45;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #333842;
|
||||
box-shadow: var(--shadow);
|
||||
animation: viewIn 0.25s ease both;
|
||||
}
|
||||
.netpill::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #ffb454;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.netpill {
|
||||
bottom: 20px;
|
||||
left: calc(50% + 110px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.view:not(.sk):not(.view--loading),
|
||||
.netpill {
|
||||
animation: none;
|
||||
}
|
||||
.sk-bar {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,6 +135,12 @@ export async function PokemonDetail(nationalId) {
|
||||
onclick: () => {
|
||||
toggle(nationalId, field);
|
||||
syncTrack();
|
||||
if (entry(nationalId)[field] && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
b.animate(
|
||||
[{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }],
|
||||
{ duration: 240, easing: 'cubic-bezier(.34,1.4,.64,1)' },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
b.append(label);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user