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
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