perf(frontend): drop the motion library for CSS keyframes and small FLIP/presence hooks
This commit is contained in:
parent
7172652ac3
commit
d629ccf496
13 changed files with 213 additions and 135 deletions
|
|
@ -12,7 +12,6 @@
|
|||
"@tanstack/react-query": "^5.103.2",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"motion": "^13.4.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.15",
|
||||
|
|
@ -340,8 +339,6 @@
|
|||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"framer-motion": ["framer-motion@13.4.3", "", { "dependencies": { "motion-dom": "^13.4.2", "motion-utils": "^13.3.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-mPQe1GtRHWS30zRGaQAfmlIg0baA0jRhkpbFPT1d+s9bZPjMW6pJSjWM6ArmUEjzMgEm089ZL1wd8aPv1/5DJQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
|
@ -476,12 +473,6 @@
|
|||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"motion": ["motion@13.4.3", "", { "dependencies": { "framer-motion": "^13.4.3", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-Js4rk+VUxSRMKOFT4hOMyMKlxyF11ee9jomiLT3uuwMOU88EoSro0ZEbTYes4m457pqZQ2G3RsLdHQzoEDyBCQ=="],
|
||||
|
||||
"motion-dom": ["motion-dom@13.4.2", "", { "dependencies": { "motion-utils": "^13.3.0" } }, "sha512-mY293Iyj4rOQo5sDxUwPYLOKiveLQrJoK1tKfG8V1H3MMGMfan3s2i7NBW18WnD39DD79ogyiL/1fmy22GLG5w=="],
|
||||
|
||||
"motion-utils": ["motion-utils@13.3.0", "", {}, "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
|
||||
|
|
@ -572,8 +563,6 @@
|
|||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||
|
||||
"undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="],
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
"@tanstack/react-query": "^5.103.2",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"motion": "^13.4.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.15",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { MotionConfig, motion } from 'motion/react'
|
||||
import { lazy, Suspense, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
|
|
@ -9,16 +8,14 @@ import { useActiveTheme } from '@features/themes/useActiveTheme'
|
|||
import { ParticleField } from '../../effects/ParticleField'
|
||||
import { useInView } from '../../effects/useInView'
|
||||
import { useIdleReady } from '../../effects/useIdleReady'
|
||||
import '@shared/motion/motion.css'
|
||||
import '@features/landing/styles/landing.css'
|
||||
|
||||
// The backdrop is decorative and its first draw is heavy, so it loads and paints after the headline.
|
||||
const VoxelScene = lazy(() => import('../../effects/VoxelScene').then((m) => ({ default: m.VoxelScene })))
|
||||
|
||||
const rise = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 24 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] as const },
|
||||
})
|
||||
/** Entrance: the `lv-rise` keyframes, staggered by `delay` seconds. */
|
||||
const rise = (delay: number) => ({ ['--d' as string]: `${delay}s` })
|
||||
|
||||
function DownloadIcon() {
|
||||
return (
|
||||
|
|
@ -43,7 +40,7 @@ export function Hero() {
|
|||
]
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<>
|
||||
<section
|
||||
ref={sectionRef}
|
||||
aria-labelledby="hero-title"
|
||||
|
|
@ -59,20 +56,18 @@ export function Hero() {
|
|||
|
||||
<div className="relative mx-auto grid w-full max-w-[1120px] items-center gap-12 px-4 pb-20 pt-12 sm:px-6 lg:py-16">
|
||||
<div className="flex min-w-0 flex-col items-start gap-6">
|
||||
<motion.span
|
||||
{...rise(0)}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1 font-code text-xs text-frost backdrop-blur"
|
||||
<span style={rise(0)} className="lv-rise inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1 font-code text-xs text-frost backdrop-blur"
|
||||
>
|
||||
<span className="lv-live-dot" aria-hidden="true" />
|
||||
{t('hero.badge')}
|
||||
</motion.span>
|
||||
<motion.h1 {...rise(0.08)} id="hero-title" className="lv-hero-title font-display font-bold">
|
||||
</span>
|
||||
<h1 style={rise(0.08)} id="hero-title" className="lv-rise lv-hero-title font-display font-bold">
|
||||
{t('hero.headlineLead')} <span className="lv-hero-accent">{t('hero.headlineAccent')}</span>
|
||||
</motion.h1>
|
||||
<motion.p {...rise(0.16)} className="max-w-[34rem] text-lg leading-relaxed text-snow/80">
|
||||
</h1>
|
||||
<p style={rise(0.16)} className="lv-rise max-w-[34rem] text-lg leading-relaxed text-snow/80">
|
||||
{t('hero.lead')}
|
||||
</motion.p>
|
||||
<motion.div {...rise(0.24)} className="flex flex-wrap items-center gap-3">
|
||||
</p>
|
||||
<div style={rise(0.24)} className="lv-rise flex flex-wrap items-center gap-3">
|
||||
<Link to="/download" className="lv-cta-primary lv-cta-lg">
|
||||
<DownloadIcon />
|
||||
{t('hero.download')}
|
||||
|
|
@ -80,34 +75,29 @@ export function Hero() {
|
|||
<a href="/register" className="lv-cta-quiet-lg">
|
||||
{t('hero.register')}
|
||||
</a>
|
||||
</motion.div>
|
||||
<motion.p {...rise(0.3)} className="-mt-2 font-code text-xs text-frost">
|
||||
</div>
|
||||
<p style={rise(0.3)} className="lv-rise -mt-2 font-code text-xs text-frost">
|
||||
{t('hero.downloadHint')}
|
||||
</motion.p>
|
||||
<motion.dl {...rise(0.36)} className="mt-2 flex flex-wrap gap-x-8 gap-y-3">
|
||||
</p>
|
||||
<dl style={rise(0.36)} className="lv-rise mt-2 flex flex-wrap gap-x-8 gap-y-3">
|
||||
{facts.map((f) => (
|
||||
<div key={f.label} className="flex flex-col">
|
||||
<dt className="order-2 text-sm text-frost">{f.label}</dt>
|
||||
<dd className="lv-fact font-display text-3xl font-bold text-snow">{f.n}</dd>
|
||||
</div>
|
||||
))}
|
||||
</motion.dl>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.94, y: 30 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.9, delay: 0.2, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="flex min-w-0 flex-col items-center gap-4"
|
||||
>
|
||||
<div className="lv-rise-panel flex min-w-0 flex-col items-center gap-4">
|
||||
<ClickGuiWindow paused={!inView} />
|
||||
<p className="flex items-center gap-2 text-sm text-frost">
|
||||
<span className="lv-live-dot" aria-hidden="true" />
|
||||
{t('hero.tryIt')}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</MotionConfig>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
|
||||
import { type MouseEvent, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ButtonLink } from '@shared/ui/Button'
|
||||
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
|
||||
import { RollText } from '@shared/motion/RollText'
|
||||
import '@shared/motion/motion.css'
|
||||
import { themeToCssVars } from '@features/clickgui/themeVars'
|
||||
import { PRESETS } from '@features/themes/presets.generated'
|
||||
import { argbToCss, type ThemeColors } from '@features/themes/types'
|
||||
|
|
@ -38,27 +39,22 @@ export function ThemeStrip() {
|
|||
}
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<>
|
||||
<section
|
||||
ref={sectionRef}
|
||||
aria-labelledby="themes-title"
|
||||
style={themeToCssVars(theme)}
|
||||
className="lv-bleed lv-paint relative isolate overflow-hidden"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{wave ? (
|
||||
<motion.span
|
||||
key={wave.key}
|
||||
aria-hidden="true"
|
||||
className="lv-paint-wave"
|
||||
style={{ left: wave.x, top: wave.y }}
|
||||
initial={{ scale: 0, opacity: 0.8 }}
|
||||
animate={{ scale: 1, opacity: [0.8, 0.6, 0] }}
|
||||
transition={{ duration: 0.9, ease: 'easeOut' }}
|
||||
onAnimationComplete={() => setWave((w) => (w?.key === wave.key ? null : w))}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{wave ? (
|
||||
<span
|
||||
key={wave.key}
|
||||
aria-hidden="true"
|
||||
className="lv-paint-wave lv-wave-run"
|
||||
style={{ left: wave.x, top: wave.y }}
|
||||
onAnimationEnd={() => setWave((w) => (w?.key === wave.key ? null : w))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="lv-paint-header" aria-hidden="true">
|
||||
<div className="mx-auto flex h-full max-w-[1120px] items-center gap-3 px-4 sm:px-6">
|
||||
|
|
@ -79,18 +75,7 @@ export function ThemeStrip() {
|
|||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<p className="text-sm" style={{ color: 'var(--gui-text-muted)' }}>{t('themeStrip.current')}</p>
|
||||
<div className="relative h-[clamp(3.6rem,9vw,7.5rem)] overflow-hidden" aria-live="polite">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.p
|
||||
key={theme.id}
|
||||
className="lv-paint-name"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '-100%' }}
|
||||
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{theme.name}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
<RollText value={theme.name} className="lv-paint-name" />
|
||||
</div>
|
||||
<ul aria-label={t('themeStrip.palette')} className="grid grid-cols-8 overflow-hidden rounded-ctl ring-1 ring-white/10">
|
||||
{PALETTE_KEYS.map((k) => (
|
||||
|
|
@ -137,6 +122,6 @@ export function ThemeStrip() {
|
|||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</MotionConfig>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { MotionConfig, motion } from 'motion/react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import { Backdrop } from '@features/clickgui/parts/Backdrop'
|
||||
import '@features/clickgui/parts/clickgui.css'
|
||||
import { themeToCssVars } from '@features/clickgui/themeVars'
|
||||
import { useSharedThumb } from '@shared/motion/useSharedThumb'
|
||||
import { useActiveTheme } from '@features/themes/useActiveTheme'
|
||||
import './auth.css'
|
||||
|
||||
|
|
@ -19,6 +19,11 @@ function nextStep(from: string | undefined): 'link' | 'configs' | 'account' | 's
|
|||
if (from.startsWith('/showcase')) return 'showcase'
|
||||
}
|
||||
|
||||
function AuthThumb() {
|
||||
const ref = useSharedThumb<HTMLSpanElement>('lv-auth-thumb', 380)
|
||||
return <span ref={ref} className="lv-auth-thumb" />
|
||||
}
|
||||
|
||||
function useClock(): string {
|
||||
const format = () => new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
const [now, setNow] = useState(format)
|
||||
|
|
@ -44,7 +49,7 @@ export function AuthScreen({ mode, title, hints, children }: { mode: Mode; title
|
|||
const step = nextStep(state?.from)
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<>
|
||||
<section aria-labelledby="auth-title" className="lv-auth-screen" style={themeToCssVars(theme)}>
|
||||
<div className="lv-auth-world" aria-hidden="true">
|
||||
<Backdrop />
|
||||
|
|
@ -64,13 +69,7 @@ export function AuthScreen({ mode, title, hints, children }: { mode: Mode; title
|
|||
aria-current={m === mode ? 'page' : undefined}
|
||||
className="lv-auth-tab"
|
||||
>
|
||||
{m === mode ? (
|
||||
<motion.span
|
||||
layoutId="lv-auth-thumb"
|
||||
className="lv-auth-thumb"
|
||||
transition={{ duration: 0.38, ease: [0.22, 1, 0.36, 1] }}
|
||||
/>
|
||||
) : null}
|
||||
{m === mode ? <AuthThumb /> : null}
|
||||
<span className="lv-auth-tab-text">{t(`tabs.${m}`)}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
|
@ -95,6 +94,6 @@ export function AuthScreen({ mode, title, hints, children }: { mode: Mode; title
|
|||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</MotionConfig>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
35
frontend/src/shared/motion/RollText.tsx
Normal file
35
frontend/src/shared/motion/RollText.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
51
frontend/src/shared/motion/motion.css
Normal file
51
frontend/src/shared/motion/motion.css
Normal 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; }
|
||||
}
|
||||
21
frontend/src/shared/motion/usePresence.ts
Normal file
21
frontend/src/shared/motion/usePresence.ts
Normal 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' }
|
||||
}
|
||||
29
frontend/src/shared/motion/useSharedThumb.ts
Normal file
29
frontend/src/shared/motion/useSharedThumb.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue