feature 페이지 Suspense 스피너 제거 + 섹션 순차 페이드인 애니메이션

- FeaturePage / AdminFeaturePage의 Suspense fallback 스피너를 null로 변경
- components/common/StaggerGroup: 자식을 각 motion.div로 감싸 순차
  페이드인 (staggerChildren 0.07s, duration 0.35s, ease 0.22,1,0.36,1)
- Liberation(Genesis/Destiny), Symbol 페이지 root를 StaggerGroup으로 교체
- BossCrystal은 grid 레이아웃 특성상 root 전체를 motion.div로 감싸 fade-in
- hover prefetch와 함께 chunk 로드 시 깜빡임 없이 자연스럽게 등장

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-22 00:13:15 +09:00
parent 7ebfe4a449
commit d1764dea94
7 changed files with 58 additions and 21 deletions

View file

@ -0,0 +1,39 @@
import { Children } from 'react'
import { motion } from 'framer-motion'
const containerVariants = {
hidden: {},
show: { transition: { staggerChildren: 0.07 } },
}
const itemVariants = {
hidden: { opacity: 0, y: 10 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.35, ease: [0.22, 1, 0.36, 1] },
},
}
/**
* 자식을 motion.div 감싸 순차 페이드인.
* 레이아웃에 영향 주지 않도록 wrapper div flex/grid 특성이 없어야 하는 자리에서만 사용.
* space-y-* 같은 Tailwind 유틸은 그대로 className 넘겨 유지.
*/
export default function StaggerGroup({ children, className, style }) {
return (
<motion.div
className={className}
style={style}
variants={containerVariants}
initial="hidden"
animate="show"
>
{Children.map(children, (child, i) => (
child == null || child === false
? null
: <motion.div variants={itemVariants} key={i}>{child}</motion.div>
))}
</motion.div>
)
}

View file

@ -11,11 +11,7 @@ export default function FeaturePage() {
}
return (
<Suspense fallback={
<div className="flex items-center justify-center pt-20">
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
</div>
}>
<Suspense fallback={null}>
<Component />
</Suspense>
)

View file

@ -49,14 +49,7 @@ export default function AdminFeaturePage() {
}
return (
<Suspense fallback={
<div className="flex items-center justify-center pt-20">
<div
className="w-6 h-6 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }}
/>
</div>
}>
<Suspense fallback={null}>
<Component />
</Suspense>
)

View file

@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect } from 'react'
import { useQuery, useQueries } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { api } from '../../../api/client'
import { useLayout } from '../../../components/pc/Layout'
import CharacterPanel from './user/CharacterPanel'
@ -70,7 +71,12 @@ export default function BossCrystal() {
const isMaxReached = currentSelectedCount >= MAX_PER_CHARACTER
return (
<div className="h-full">
<motion.div
className="h-full"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
>
{isLoading ? (
<div
className="rounded-2xl border p-16 text-center"
@ -117,6 +123,6 @@ export default function BossCrystal() {
</div>
</div>
)}
</div>
</motion.div>
)
}

View file

@ -16,6 +16,7 @@ import PointsInput from './components/PointsInput'
import WeeklyDefault from './components/WeeklyDefault'
import DatePicker from '../../../components/common/DatePicker'
import ConfirmDialog from '../../../components/common/ConfirmDialog'
import StaggerGroup from '../../../components/common/StaggerGroup'
export default function Destiny() {
const calcMode = useLiberationStore((s) => s.destinyCalcMode)
@ -69,7 +70,7 @@ export default function Destiny() {
}
return (
<>
<StaggerGroup className="space-y-6">
{/* 계산 모드 탭 */}
<div
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
@ -212,6 +213,6 @@ export default function Destiny() {
confirmText="초기화"
destructive
/>
</>
</StaggerGroup>
)
}

View file

@ -24,6 +24,7 @@ import ProgressBar from './components/ProgressBar'
import WeeklyDefault from './components/WeeklyDefault'
import DatePicker from '../../../components/common/DatePicker'
import ConfirmDialog from '../../../components/common/ConfirmDialog'
import StaggerGroup from '../../../components/common/StaggerGroup'
export default function Genesis() {
const calcMode = useLiberationStore((s) => s.genesisCalcMode)
@ -93,7 +94,7 @@ export default function Genesis() {
}
return (
<>
<StaggerGroup className="space-y-6">
{/* 계산 모드 탭 */}
<div
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
@ -238,6 +239,6 @@ export default function Genesis() {
confirmText="초기화"
destructive
/>
</>
</StaggerGroup>
)
}

View file

@ -9,6 +9,7 @@ import { formatMesoKorean } from '../../../utils/formatting'
import { formatKoreanDate, computeCompletion, TYPE_ORDER, eventBonusForType } from '../utils'
import CharacterCard from './user/CharacterCard'
import SymbolCard from './user/SymbolCard'
import StaggerGroup from '../../../components/common/StaggerGroup'
export default function Symbol() {
const { setFullscreen } = useLayout()
@ -193,7 +194,7 @@ export default function Symbol() {
}, [symbols, progress, selectedChar?.event_skill])
return (
<div className="space-y-6 pb-10 max-w-5xl mx-auto">
<StaggerGroup className="space-y-6 pb-10 max-w-5xl mx-auto">
{/* 캐릭터 조회 */}
<div
className="rounded-2xl border p-5 space-y-4"
@ -349,6 +350,6 @@ export default function Symbol() {
</div>
</div>
</div>
</div>
</StaggerGroup>
)
}