feat. motion 적용

This commit is contained in:
clkim
2026-01-13 13:56:31 +09:00
parent 352d76a61c
commit 2947adc66e
15 changed files with 239 additions and 3 deletions

View File

@@ -119,6 +119,7 @@ const handleButtonClick = (button: PageDataResourceGroup) => {
<template>
<div
v-if="buttonList.length"
v-motion-stagger
class="flex flex-wrap justify-center gap-3 md:gap-4"
>
<template v-for="(button, index) in buttonList" :key="index">

View File

@@ -7,7 +7,7 @@ const props = defineProps<{
</script>
<template>
<p class="description">
<p v-motion-stagger class="description">
<BlocksVisualContent :resources-data="props.resourcesData" />
</p>
</template>

View File

@@ -7,7 +7,7 @@ const props = defineProps<{
</script>
<template>
<h2>
<h2 v-motion-stagger>
<BlocksVisualContent :resources-data="props.resourcesData" />
</h2>
</template>

View File

@@ -12,7 +12,7 @@ const props = withDefaults(defineProps<Props>(), {
</script>
<template>
<component :is="props.tag">
<component :is="props.tag" v-motion-stagger>
<BlocksVisualContent :resources-data="props.resourcesData" />
</component>
</template>

View File

@@ -18,6 +18,7 @@ const handleVideoPlayClick = () => {
<template>
<AtomsButtonPlay
v-motion-stagger
:tracking="props.resourcesData.tracking"
@click="handleVideoPlayClick"
/>

View File

@@ -0,0 +1,168 @@
import { animate, stagger } from 'motion-v'
import type { DOMKeyframesDefinition, AnimationOptions } from 'motion-v'
export default defineNuxtPlugin(nuxtApp => {
// 전역 상태 관리
const animatedItems = new Set<Element>()
const sectionObservers = new Map<Element, IntersectionObserver>()
const sectionItems = new Map<Element, Set<Element>>()
// 섹션의 motion-item들을 애니메이션
const animateSectionItems = (section: Element) => {
const items = sectionItems.get(section)
if (!items || items.size === 0) return
const newItems = Array.from(items).filter(item => !animatedItems.has(item))
if (newItems.length === 0) return
// 애니메이션 실행
newItems.forEach(item => animatedItems.add(item))
animate(
newItems,
{ opacity: 1, y: 0 } as DOMKeyframesDefinition,
{
delay: stagger(0.2),
duration: 0.5,
easing: [0.22, 1, 0.36, 1],
} as AnimationOptions
)
}
// 섹션에 IntersectionObserver 등록
const observeSection = (section: Element) => {
if (sectionObservers.has(section)) return
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animateSectionItems(section)
}
})
},
{ threshold: 0.2 }
)
observer.observe(section)
sectionObservers.set(section, observer)
}
// 섹션이 viewport에 있는지 확인
const isSectionInViewport = (section: Element): boolean => {
const rect = section.getBoundingClientRect()
const windowHeight =
window.innerHeight || document.documentElement.clientHeight
// threshold 0.2를 고려한 체크
const visibleHeight =
Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0)
const sectionHeight = rect.height
return visibleHeight / sectionHeight >= 0.2
}
// v-motion-stagger 디렉티브
nuxtApp.vueApp.directive('motion-stagger', {
mounted(el: HTMLElement) {
// 초기 스타일 설정 (애니메이션 전)
el.style.opacity = '0'
el.style.transform = 'translateY(30px)'
// 디버깅: 요소 등록 확인
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.log('[motion-stagger] Element mounted:', el.tagName)
}
// 가장 가까운 section 찾기
const section = el.closest('section')
if (!section) {
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.warn('[motion-stagger] No section found for element:', el)
}
return
}
// 섹션의 아이템 목록에 추가
if (!sectionItems.has(section)) {
sectionItems.set(section, new Set())
observeSection(section)
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.log('[motion-stagger] New section registered')
}
}
sectionItems.get(section)!.add(el)
// 이미 viewport에 있는 경우 즉시 체크 (여러 번 체크)
const inViewport = isSectionInViewport(section)
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.log('[motion-stagger] Section in viewport:', inViewport)
}
if (inViewport) {
// 즉시 실행
requestAnimationFrame(() => {
animateSectionItems(section)
})
// 추가로 100ms 후 재확인 (비동기 렌더링 대응)
setTimeout(() => {
if (isSectionInViewport(section)) {
animateSectionItems(section)
}
}, 100)
// 추가로 300ms 후 재확인 (느린 비동기 데이터 대응)
setTimeout(() => {
if (isSectionInViewport(section)) {
animateSectionItems(section)
}
}, 300)
}
},
unmounted(el: Element) {
const section = el.closest('section')
if (!section) return
// 아이템 제거
const items = sectionItems.get(section)
if (items) {
items.delete(el)
// 섹션에 아이템이 없으면 observer 정리
if (items.size === 0) {
const observer = sectionObservers.get(section)
if (observer) {
observer.disconnect()
sectionObservers.delete(section)
}
sectionItems.delete(section)
}
}
animatedItems.delete(el)
},
})
// 페이지 이동 시 정리
if (import.meta.client) {
nuxtApp.hook('page:finish', () => {
// 현재 페이지에 없는 섹션들 정리
const currentSections = new Set<Element>(
Array.from(document.querySelectorAll('section'))
)
sectionObservers.forEach((observer, section) => {
if (!currentSections.has(section)) {
observer.disconnect()
sectionObservers.delete(section)
sectionItems.delete(section)
}
})
})
}
})

View File

@@ -124,6 +124,7 @@ const getArticleUrl = (articleId: string) => {
<ClientOnly>
<WidgetsSlideDefault
v-if="slideLength > 0"
v-motion-stagger
v-bind="splideOptions"
:slide-item-length="slideLength"
:arrows-data="arrowsData"

View File

@@ -91,6 +91,7 @@ const getVideoSrc = (item: PageDataTemplateComponent) => {
/>
<AtomsVideo
v-if="hasComponentGroup(item, 'video')"
v-motion-stagger
:src="getVideoSrc(item)"
:play="currentSlideIndex === index"
class="aspect-[16/10] w-[258px] mt-8 md:w-[496px] md:mt-10"

View File

@@ -89,6 +89,7 @@ onBeforeUnmount(() => {
class="title-md max-w-[944px]"
/>
<WidgetsSlideThumbnail
v-motion-stagger
:thumbnail-data="slideData"
:pagination-data="paginationData"
:drag="false"

View File

@@ -70,6 +70,7 @@ const handleSplideMove = (_splide: SplideType, newIndex: number) => {
/>
<WidgetsSlideCenterFocus
v-if="slideData"
v-motion-stagger
:slide-item-size="slideItemSize"
:slide-item-length="slideData?.length"
:arrows-data="arrowsData"

View File

@@ -80,6 +80,7 @@ const handleSplideMove = (
/>
<WidgetsSlideCenterHighlight
v-if="slideData"
v-motion-stagger
:slide-item-size="slideItemSize"
:slide-item-length="slideData?.length"
:arrows-data="arrowsData"

View File

@@ -101,6 +101,7 @@ const slideItemSize = {
<WidgetsVideoPlay v-if="videoPlayData" :resources-data="videoPlayData" />
<WidgetsSlideCenterHighlight
v-if="slideData && slideData.length > 0"
v-motion-stagger
:slide-item-size="slideItemSize"
:slide-item-length="slideData.length"
:arrows-data="arrowsData"

View File

@@ -67,6 +67,7 @@ export default defineNuxtConfig({
'@nuxtjs/tailwindcss',
'nuxt-gtag',
'@nuxtjs/device',
'motion-v/nuxt',
],
extends: ['./layers'],
alias: {

View File

@@ -36,6 +36,7 @@
"@vueuse/core": "^13.6.0",
"@vueuse/nuxt": "^13.6.0",
"h3": "^1.15.4",
"motion-v": "^1.8.1",
"nuxt": "^4.0.3",
"nuxt-gtag": "^4.0.0",
"pinia": "^2.3.1",

58
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
h3:
specifier: ^1.15.4
version: 1.15.4
motion-v:
specifier: ^1.8.1
version: 1.8.1(@vueuse/core@13.9.0(vue@3.5.21(typescript@5.9.2)))(vue@3.5.21(typescript@5.9.2))
nuxt:
specifier: ^4.0.3
version: 4.1.1(@parcel/watcher@2.5.1)(@types/node@24.3.1)(@vue/compiler-sfc@3.5.21)(db0@0.3.2)(eslint@9.35.0(jiti@2.5.1))(ioredis@5.7.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.50.0)(terser@5.44.0)(typescript@5.9.2)(vite@7.1.4(@types/node@24.3.1)(jiti@2.5.1)(terser@5.44.0)(yaml@2.8.1))(vue-tsc@3.0.7(typescript@5.9.2))(yaml@2.8.1)
@@ -2799,6 +2802,20 @@ packages:
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
framer-motion@12.25.0:
resolution: {integrity: sha512-mlWqd0rApIjeyhTCSNCqPYsUAEhkcUukZxH3ke6KbstBRPcxhEpuIjmiUQvB+1E9xkEm5SpNHBgHCapH/QHTWg==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
@@ -2935,6 +2952,9 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@@ -3385,6 +3405,18 @@ packages:
mocked-exports@0.1.1:
resolution: {integrity: sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==}
motion-dom@12.24.11:
resolution: {integrity: sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==}
motion-utils@12.24.10:
resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==}
motion-v@1.8.1:
resolution: {integrity: sha512-OS6ve/vdNlrKTmCAHy+lxujIVTggjs9nbzl1auWiewy49FthkpCs5Wwpf40+55Ko3mbTajlKadkTlQYysrnL4A==}
peerDependencies:
'@vueuse/core': '>=10.0.0'
vue: '>=3.0.0'
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -7688,6 +7720,12 @@ snapshots:
fraction.js@4.3.7: {}
framer-motion@12.25.0:
dependencies:
motion-dom: 12.24.11
motion-utils: 12.24.10
tslib: 2.8.1
fresh@0.5.2: {}
fresh@2.0.0: {}
@@ -7839,6 +7877,8 @@ snapshots:
he@1.2.0: {}
hey-listen@1.0.8: {}
hookable@5.5.3: {}
http-assert@1.5.0:
@@ -8293,6 +8333,24 @@ snapshots:
mocked-exports@0.1.1: {}
motion-dom@12.24.11:
dependencies:
motion-utils: 12.24.10
motion-utils@12.24.10: {}
motion-v@1.8.1(@vueuse/core@13.9.0(vue@3.5.21(typescript@5.9.2)))(vue@3.5.21(typescript@5.9.2)):
dependencies:
'@vueuse/core': 13.9.0(vue@3.5.21(typescript@5.9.2))
framer-motion: 12.25.0
hey-listen: 1.0.8
motion-dom: 12.24.11
vue: 3.5.21(typescript@5.9.2)
transitivePeerDependencies:
- '@emotion/is-prop-valid'
- react
- react-dom
mrmime@2.0.1: {}
ms@2.1.3: {}