애니메이션을 translateY 대신 scale로 변경해 CLS 제거

Chrome DevTools의 레이아웃 변경 원인 분석 결과 애니메이션 × 3이 CLS 0.17의
주요 원인으로 지목됨. translateY 애니메이션을 레이아웃 변경으로 카운트하는
케이스가 있음.

- StaggerGroup: y:30 → scale:0.97 로 변경. scale/opacity는 compositor-only
  속성이라 layout/paint 없이 GPU만 사용
- BossCrystal 루트 애니메이션도 동일하게 scale 기반으로 변경
- 자식 motion.div에 will-change: transform, opacity 명시적 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-22 00:42:25 +09:00
parent 669b358460
commit f63c1e06c5
2 changed files with 12 additions and 11 deletions

View file

@ -3,19 +3,20 @@ import { motion } from 'framer-motion'
/** /**
* 자식을 motion.div 감싸 순차 페이드인. * 자식을 motion.div 감싸 순차 페이드인.
* 기본값은 프로미스나인 사이트와 동일 (y 30, duration 0.4, 간격 0.1s). * translateY 대신 scale 쓰는 이유: Chrome translateY 애니메이션을
* 레이아웃 변경(CLS)으로 카운트하는 케이스가 있어서. scale compositor-only CLS 0.
* *
* @param {number} staggerDelay - 자식 간격 () * @param {number} staggerDelay - 자식 간격 ()
* @param {number} yOffset - 시작 y 오프셋 (px) * @param {number} scaleFrom - 시작 scale (기본 0.97)
* @param {number} duration - 자식 애니메이션 지속시간 () * @param {number} duration - 자식 애니메이션 지속시간 ()
*/ */
export default function StaggerGroup({ export default function StaggerGroup({
children, children,
className, className,
style, style,
staggerDelay = 0.1, staggerDelay = 0.08,
yOffset = 30, scaleFrom = 0.97,
duration = 0.4, duration = 0.35,
}) { }) {
const containerVariants = { const containerVariants = {
hidden: {}, hidden: {},
@ -23,10 +24,10 @@ export default function StaggerGroup({
} }
const itemVariants = { const itemVariants = {
hidden: { opacity: 0, y: yOffset }, hidden: { opacity: 0, scale: scaleFrom },
show: { show: {
opacity: 1, opacity: 1,
y: 0, scale: 1,
transition: { duration }, transition: { duration },
}, },
} }
@ -42,7 +43,7 @@ export default function StaggerGroup({
{Children.map(children, (child, i) => ( {Children.map(children, (child, i) => (
child == null || child === false child == null || child === false
? null ? null
: <motion.div variants={itemVariants} key={i}>{child}</motion.div> : <motion.div variants={itemVariants} key={i} style={{ willChange: 'transform, opacity' }}>{child}</motion.div>
))} ))}
</motion.div> </motion.div>
) )

View file

@ -73,9 +73,9 @@ export default function BossCrystal() {
return ( return (
<motion.div <motion.div
className="h-full" className="h-full"
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.03 }} transition={{ duration: 0.35 }}
style={{ willChange: 'transform, opacity' }} style={{ willChange: 'transform, opacity' }}
> >
{isLoading ? ( {isLoading ? (