perf(frontend): drop the motion library for CSS keyframes and small FLIP/presence hooks

This commit is contained in:
loki5512344 2026-09-30 16:26:36 +02:00
parent 7172652ac3
commit d629ccf496
Signed by: boba
GPG key ID: 253067914055423B
13 changed files with 213 additions and 135 deletions

View file

@ -1,6 +1,6 @@
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import '@shared/motion/motion.css'
import './block.css'
/** Clicks needed before the block shatters: two crack stages then the burst. */
@ -15,7 +15,6 @@ type Particle = { id: number; dx: string; dy: string }
*/
export function BreakableBlock() {
const { t } = useTranslation('common')
const reduced = useReducedMotion()
const [cracks, setCracks] = useState(0)
const [particles, setParticles] = useState<Particle[]>([])
const [cycle, setCycle] = useState(0) // bump to replay the drop after a burst
@ -44,28 +43,15 @@ export function BreakableBlock() {
return (
<div className="lv-block-world">
<motion.span
key={cycle}
className="lv-block-wrap"
initial={reduced ? false : { y: -220, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={reduced ? { duration: 0 } : { type: 'spring', stiffness: 320, damping: 12 }}
>
<span key={cycle} className="lv-block-wrap lv-drop">
<button type="button" className="lv-block" aria-label={t('notFound.blockAria')} onClick={crack}>
{cracks > 0 && <span className="lv-block-crack" data-stage={String(Math.min(cracks, 2))} aria-hidden="true" />}
</button>
</motion.span>
</span>
<AnimatePresence>
{particles.map((p) => (
<motion.span
key={`${cycle}-${p.id}`}
className="lv-block-particle"
style={{ ['--dx' as string]: p.dx, ['--dy' as string]: p.dy }}
exit={{ opacity: 0 }}
/>
))}
</AnimatePresence>
{particles.map((p) => (
<span key={`${cycle}-${p.id}`} className="lv-block-particle" style={{ ['--dx' as string]: p.dx, ['--dy' as string]: p.dy }} />
))}
</div>
)
}

View file

@ -4,7 +4,7 @@ import { expect, test, vi } from 'vitest'
import { BreakableBlock } from '../BreakableBlock'
import { renderApp } from '@test/render'
// motion's useReducedMotion reads matchMedia, which jsdom lacks.
// prefersReducedMotion reads matchMedia, which jsdom lacks.
vi.stubGlobal('matchMedia', (query: string) => ({
matches: !query.includes('reduce'),
media: query,

View file

@ -1,7 +1,7 @@
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { type RefObject, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { NavLink } from 'react-router'
import { usePresence } from '@shared/motion/usePresence'
import { LanguageSwitch } from '../../i18n/LanguageSwitch'
import type { NavItem } from './NavPill'
import { AccountSlot } from './parts'
@ -21,6 +21,7 @@ const FOCUSABLE = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1
export function MobileMenu({ id, items, open, onClose, toggleRef }: Props) {
const { t } = useTranslation('common')
const sheetRef = useRef<HTMLDivElement>(null)
const { mounted, state } = usePresence(open, 220)
useEffect(() => {
if (!open) return
@ -53,28 +54,20 @@ export function MobileMenu({ id, items, open, onClose, toggleRef }: Props) {
}, [open, onClose, toggleRef])
return (
<MotionConfig reducedMotion="user">
<AnimatePresence>
{open && (
<>
{mounted && (
<>
<motion.div
key="scrim"
<div
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
data-state={state}
onClick={onClose}
className="fixed inset-0 -z-10 bg-abyss/70 backdrop-blur-sm"
className="lv-scrim fixed inset-0 -z-10 bg-abyss/70 backdrop-blur-sm"
/>
<motion.div
key="sheet"
<div
id={id}
ref={sheetRef}
initial={{ opacity: 0, y: -10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
className="mt-2 origin-top rounded-3xl border border-white/10 bg-[rgb(12_20_22/0.95)] p-2 shadow-[0_24px_60px_-20px_rgb(0_0_0/0.8),inset_0_1px_0_rgb(255_255_255/0.06)] backdrop-blur-xl"
data-state={state}
className="lv-sheet mt-2 origin-top rounded-3xl border border-white/10 bg-[rgb(12_20_22/0.95)] p-2 shadow-[0_24px_60px_-20px_rgb(0_0_0/0.8),inset_0_1px_0_rgb(255_255_255/0.06)] backdrop-blur-xl"
>
<nav aria-label={t('nav.mainNav')}>
<ul className="flex flex-col gap-1">
@ -106,10 +99,9 @@ export function MobileMenu({ id, items, open, onClose, toggleRef }: Props) {
<LanguageSwitch />
<AccountSlot onNavigate={onClose} />
</div>
</motion.div>
</div>
</>
)}
</AnimatePresence>
</MotionConfig>
</>
)
}

View file

@ -1,15 +1,26 @@
import { MotionConfig, motion } from 'motion/react'
import { useSharedThumb } from '@shared/motion/useSharedThumb'
import { useTranslation } from 'react-i18next'
import { NavLink } from 'react-router'
export type NavItem = { to: string; label: string }
function NavThumb() {
const ref = useSharedThumb<HTMLSpanElement>('topbar-nav-thumb', 320)
return (
<span
ref={ref}
data-testid="nav-indicator"
aria-hidden="true"
className="absolute inset-0 rounded-full border border-white/25 bg-[linear-gradient(90deg,var(--color-ice),color-mix(in_srgb,var(--color-ice)_72%,white))] shadow-[0_0_18px_-2px_var(--page-accent-glow)]"
/>
)
}
/** The in-game [Modules | Settings] switch as site navigation: an accent thumb slides to the active route. */
export function NavPill({ items, className = '' }: { items: NavItem[]; className?: string }) {
const { t } = useTranslation('common')
return (
<MotionConfig reducedMotion="user">
<nav aria-label={t('nav.mainNav')} className={className}>
<nav aria-label={t('nav.mainNav')} className={className}>
<ul className="flex h-10 items-center rounded-full border border-white/10 bg-black/35 p-1 shadow-[inset_0_1px_2px_rgb(0_0_0/0.35)]">
{items.map((item) => (
<li key={item.to} className="h-full">
@ -23,15 +34,7 @@ export function NavPill({ items, className = '' }: { items: NavItem[]; className
>
{({ isActive }) => (
<>
{isActive && (
<motion.span
layoutId="topbar-nav-thumb"
data-testid="nav-indicator"
aria-hidden="true"
transition={{ type: 'spring', stiffness: 420, damping: 36 }}
className="absolute inset-0 rounded-full border border-white/25 bg-[linear-gradient(90deg,var(--color-ice),color-mix(in_srgb,var(--color-ice)_72%,white))] shadow-[0_0_18px_-2px_var(--page-accent-glow)]"
/>
)}
{isActive && <NavThumb />}
<span className="relative">{item.label}</span>
</>
)}
@ -39,7 +42,6 @@ export function NavPill({ items, className = '' }: { items: NavItem[]; className
</li>
))}
</ul>
</nav>
</MotionConfig>
</nav>
)
}

View file

@ -0,0 +1,35 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { prefersReducedMotion } from './reducedMotion'
import './motion.css'
/**
* Text that rolls upward when `value` changes: the old value slides out over the new one.
* Meant to sit in a positioned, overflow-hidden box; `className` styles both lines.
*/
export function RollText({ value, className = '' }: { value: string; className?: string }) {
const [leaving, setLeaving] = useState<string | null>(null)
const shown = useRef(value)
useLayoutEffect(() => {
if (shown.current === value) return
const old = shown.current
shown.current = value
if (prefersReducedMotion()) return
setLeaving(old)
const id = window.setTimeout(() => setLeaving(null), 450)
return () => window.clearTimeout(id)
}, [value])
return (
<>
{leaving !== null && (
<p key={`out-${leaving}`} aria-hidden="true" className={`${className} lv-roll-out`}>
{leaving}
</p>
)}
<p key={value} className={`${className} ${leaving !== null ? 'lv-roll-in' : ''}`}>
{value}
</p>
</>
)
}

View file

@ -0,0 +1,51 @@
/* Entrance/exit animations that replaced the `motion` library. All are switched off under reduced motion. */
@keyframes lv-rise {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: none; }
}
@keyframes lv-rise-panel {
from { opacity: 0; transform: translateY(30px) scale(0.94); }
to { opacity: 1; transform: none; }
}
@keyframes lv-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes lv-fade-out { from { opacity: 1; } to { opacity: 0; } }
@keyframes lv-sheet-in {
from { opacity: 0; transform: translateY(-10px) scale(0.98); }
to { opacity: 1; transform: none; }
}
@keyframes lv-sheet-out {
from { opacity: 1; transform: none; }
to { opacity: 0; transform: translateY(-8px) scale(0.98); }
}
@keyframes lv-wave-burst {
0% { transform: scale(0); opacity: 0.8; }
40% { opacity: 0.6; }
100% { transform: scale(1); opacity: 0; }
}
@keyframes lv-roll-in { from { transform: translateY(100%); } to { transform: none; } }
@keyframes lv-roll-out { from { transform: none; } to { transform: translateY(-100%); } }
/* A drop with a couple of bounces, standing in for the old spring. */
@keyframes lv-drop {
0% { transform: translateY(-220px); opacity: 0; }
12% { opacity: 1; }
38% { transform: translateY(0); }
55% { transform: translateY(-22px); }
70% { transform: translateY(0); }
82% { transform: translateY(-7px); }
100% { transform: translateY(0); opacity: 1; }
}
.lv-rise { animation: lv-rise 0.7s var(--d, 0s) cubic-bezier(0.22, 1, 0.36, 1) both; }
.lv-rise-panel { animation: lv-rise-panel 0.9s 0.2s cubic-bezier(0.22, 1, 0.36, 1) both; }
.lv-scrim[data-state='open'] { animation: lv-fade-in 0.22s both; }
.lv-scrim[data-state='closed'] { animation: lv-fade-out 0.22s both; }
.lv-sheet[data-state='open'] { animation: lv-sheet-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both; }
.lv-sheet[data-state='closed'] { animation: lv-sheet-out 0.22s cubic-bezier(0.22, 1, 0.36, 1) both; }
.lv-wave-run { animation: lv-wave-burst 0.9s ease-out both; }
.lv-roll-in { animation: lv-roll-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; }
.lv-roll-out { animation: lv-roll-out 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; pointer-events: none; }
.lv-drop { animation: lv-drop 0.9s both; }
@media (prefers-reduced-motion: reduce) {
.lv-rise, .lv-rise-panel, .lv-scrim, .lv-sheet, .lv-wave-run, .lv-roll-in, .lv-roll-out, .lv-drop { animation: none !important; }
}

View file

@ -0,0 +1,21 @@
import { useEffect, useState } from 'react'
import { prefersReducedMotion } from './reducedMotion'
import './motion.css'
/**
* Keeps an element mounted for `ms` after `show` turns false so a CSS exit animation
* (keyed on `data-state="closed"`) can play. Under reduced motion it unmounts at once.
*/
export function usePresence(show: boolean, ms: number): { mounted: boolean; state: 'open' | 'closed' } {
const [mounted, setMounted] = useState(show)
if (show && !mounted) setMounted(true)
useEffect(() => {
if (show || !mounted) return
const id = window.setTimeout(() => setMounted(false), ms)
return () => window.clearTimeout(id)
}, [show, mounted, ms])
if (!show && prefersReducedMotion()) return { mounted: false, state: 'closed' }
return { mounted: show || mounted, state: show ? 'open' : 'closed' }
}

View file

@ -0,0 +1,29 @@
import { useLayoutEffect, useRef } from 'react'
import { prefersReducedMotion } from './reducedMotion'
const lastRect = new Map<string, DOMRect>()
/**
* A "shared layout" thumb without a library: each tab renders its own thumb while active;
* when one mounts it slides in from where the previous thumb with the same `id` sat (FLIP).
*/
export function useSharedThumb<T extends HTMLElement>(id: string, duration = 380) {
const ref = useRef<T>(null)
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const now = el.getBoundingClientRect()
const prev = lastRect.get(id)
lastRect.set(id, now)
if (!prev || !now.width || !now.height || prefersReducedMotion() || typeof el.animate !== 'function') return
const from = `translate(${prev.left - now.left}px, ${prev.top - now.top}px) scale(${prev.width / now.width}, ${prev.height / now.height})`
const anim = el.animate([{ transform: from, transformOrigin: '0 0' }, { transform: 'none', transformOrigin: '0 0' }], {
duration,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
})
return () => anim.cancel()
}, [id, duration])
return ref
}