diff --git a/layers/components/atoms/Button/Circle.vue b/layers/components/atoms/Button/Circle.vue
index 0c76db0..6b143a6 100644
--- a/layers/components/atoms/Button/Circle.vue
+++ b/layers/components/atoms/Button/Circle.vue
@@ -21,7 +21,6 @@ const componentTag = computed((): string => {
return 'button'
}
})
-
const componentProps = computed(() => {
switch (props.type) {
case 'link':
@@ -34,7 +33,9 @@ const componentProps = computed(() => {
-
+
+
+
{{ props.srOnly }}
@@ -51,4 +52,8 @@ const componentProps = computed(() => {
.btn-circle:deep(svg) {
@apply w-[20px] h-[20px] md:w-[24px] md:h-[24px];
}
+
+.icon {
+ @apply transition-transform duration-300 ease-spring;
+}
diff --git a/layers/components/atoms/Button/Play.vue b/layers/components/atoms/Button/Play.vue
index eedc27d..946a705 100644
--- a/layers/components/atoms/Button/Play.vue
+++ b/layers/components/atoms/Button/Play.vue
@@ -22,7 +22,9 @@ const handlePlayClick = () => {
:style="{ backgroundColor: props.bgColor }"
@click="handlePlayClick"
>
-
+
+
+
Play
@@ -34,4 +36,10 @@ const handlePlayClick = () => {
after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:rounded-[50%] after:opacity-0 after:transition-opacity after:duration-300 after:ease-in-out
hover:after:opacity-10;
}
+.btn-play:hover .icon {
+ @apply scale-[1.08];
+}
+.icon {
+ @apply transition-transform duration-300 ease-spring;
+}
diff --git a/layers/components/atoms/Button/index.vue b/layers/components/atoms/Button/index.vue
index 07b8b7c..146ead8 100644
--- a/layers/components/atoms/Button/index.vue
+++ b/layers/components/atoms/Button/index.vue
@@ -29,7 +29,7 @@ const componentTag = computed((): string => {
case 'download':
return props.href ? 'a' : 'button'
case 'internal':
- return 'AtomsLocaleLink'
+ return props.href ? 'AtomsLocaleLink' : 'button'
default:
return 'button'
}
@@ -49,9 +49,12 @@ const componentProps = computed(() => {
}
if (props.type === 'internal') {
- return {
- to: props.href,
+ if (props.href) {
+ return {
+ to: props.href,
+ }
}
+ return {}
}
if (props.type === 'download') {
@@ -87,17 +90,17 @@ const componentProps = computed(() => {
@@ -121,6 +124,18 @@ const componentProps = computed(() => {
@apply before:border-[rgba(0,0,0,0.1)]
hover:before:border-[#999];
}
+.icon {
+ @apply transition-transform duration-300 ease-spring;
+}
+.btn-base:hover .icon-internal {
+ @apply translate-x-[3px];
+}
+.btn-base:hover .icon-external {
+ @apply scale-[1.08];
+}
+.btn-base:hover .icon-download {
+ @apply translate-y-[3px];
+}
.btn-base.disabled .btn-content {
@apply opacity-50;
diff --git a/layers/components/blocks/Button/Home.vue b/layers/components/blocks/Button/Home.vue
index bad5983..cfd9293 100644
--- a/layers/components/blocks/Button/Home.vue
+++ b/layers/components/blocks/Button/Home.vue
@@ -31,4 +31,7 @@ const analytics = {
@apply fixed top-3 right-3 mt-[calc(var(--scroll-position,48px)+48px)] bg-black/20 shadow-[0_1.667px_3.333px_0_rgba(0,0,0,0.06)] backdrop-blur-[12.5px] z-[100]
sm:top-5 md:top-6 md:right-8 md:mt-[calc(var(--scroll-position,64px)+64px)];
}
+.btn-home:hover :deep(.icon) {
+ @apply scale-[1.08];
+}
diff --git a/layers/components/blocks/Button/ScrollTop.vue b/layers/components/blocks/Button/ScrollTop.vue
index 94fef90..7e73f0d 100644
--- a/layers/components/blocks/Button/ScrollTop.vue
+++ b/layers/components/blocks/Button/ScrollTop.vue
@@ -36,4 +36,7 @@ const handleScrollToTop = () => {
.btn-top {
@apply bg-[image:var(--button-top)] bg-center bg-cover bg-no-repeat;
}
+.btn-top:hover :deep(.icon) {
+ @apply -translate-y-[3px];
+}
diff --git a/layers/components/blocks/Button/SlideArrows.vue b/layers/components/blocks/Button/SlideArrows.vue
index b96e7a9..9acdba9 100644
--- a/layers/components/blocks/Button/SlideArrows.vue
+++ b/layers/components/blocks/Button/SlideArrows.vue
@@ -45,3 +45,13 @@ const handleArrowClick = (direction: 'prev' | 'next') => {
+
+
diff --git a/layers/components/layouts/EventNavigation.vue b/layers/components/layouts/EventNavigation.vue
index d6120a8..f023273 100644
--- a/layers/components/layouts/EventNavigation.vue
+++ b/layers/components/layouts/EventNavigation.vue
@@ -9,6 +9,7 @@ const { locale } = useI18n()
const { sendLog } = useAnalytics()
const gameDomain = getGameDomain()
+
const analytics = {
action_type: 'click',
click_sarea: 'EventNavigation',
@@ -132,6 +133,9 @@ onMounted(async () => {
@apply absolute top-3 right-[-40px] bg-black/20 shadow-[0_1.667px_3.333px_0_rgba(0,0,0,0.06)] backdrop-blur-[12.5px] rotate-180
sm:top-5 md:top-6 md:right-[-48px];
}
+.btn-control:hover :deep(.icon) {
+ @apply translate-x-[3px];
+}
.event-navigation.is-closed {
@apply translate-x-[calc(-100%+20px)] sm:translate-x-[calc(-100%+40px)];
diff --git a/layers/components/widgets/ButtonList.vue b/layers/components/widgets/ButtonList.vue
index faf9e51..ab5b7ee 100644
--- a/layers/components/widgets/ButtonList.vue
+++ b/layers/components/widgets/ButtonList.vue
@@ -122,7 +122,8 @@ const handleButtonClick = (button: PageDataResourceGroup) => {
diff --git a/layers/components/widgets/Description.vue b/layers/components/widgets/Description.vue
index ad29677..d967160 100644
--- a/layers/components/widgets/Description.vue
+++ b/layers/components/widgets/Description.vue
@@ -7,7 +7,7 @@ const props = defineProps<{
-
+
diff --git a/layers/components/widgets/MainTitle.vue b/layers/components/widgets/MainTitle.vue
index b8a3156..67fa244 100644
--- a/layers/components/widgets/MainTitle.vue
+++ b/layers/components/widgets/MainTitle.vue
@@ -7,7 +7,7 @@ const props = defineProps<{
-
+
diff --git a/layers/components/widgets/SubTitle.vue b/layers/components/widgets/SubTitle.vue
index e04c3bd..382dc34 100644
--- a/layers/components/widgets/SubTitle.vue
+++ b/layers/components/widgets/SubTitle.vue
@@ -12,7 +12,7 @@ const props = withDefaults(defineProps(), {
-
+
diff --git a/layers/components/widgets/VideoPlay.vue b/layers/components/widgets/VideoPlay.vue
index 853d093..1ea163d 100644
--- a/layers/components/widgets/VideoPlay.vue
+++ b/layers/components/widgets/VideoPlay.vue
@@ -22,6 +22,7 @@ const handleVideoPlayClick = () => {
{
+ // 전역 상태 관리
+ const animatedItems = new Set()
+ const sectionObservers = new Map()
+ const sectionItems = new Map>()
+
+ /**
+ * 섹션의 motion-item들을 애니메이션
+ */
+ const animateSectionItems = (section: SectionElement): void => {
+ const items = sectionItems.get(section)
+ if (!items?.size) return
+
+ const newItems = Array.from(items).filter(item => !animatedItems.has(item))
+ if (!newItems.length) return
+
+ // 애니메이션 실행
+ newItems.forEach(item => animatedItems.add(item))
+
+ animate(
+ newItems,
+ ANIMATION_CONFIG.target as DOMKeyframesDefinition,
+ {
+ delay: stagger(ANIMATION_CONFIG.stagger),
+ duration: ANIMATION_CONFIG.duration,
+ easing: ANIMATION_CONFIG.easing,
+ } as AnimationOptions
+ )
+ }
+
+ /**
+ * 섹션이 viewport에 있는지 확인
+ */
+ const isSectionInViewport = (section: SectionElement): boolean => {
+ const rect = section.getBoundingClientRect()
+ const windowHeight =
+ window.innerHeight || document.documentElement.clientHeight
+ const visibleHeight =
+ Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0)
+ const sectionHeight = rect.height
+
+ return visibleHeight / sectionHeight >= INTERSECTION_CONFIG.threshold
+ }
+
+ /**
+ * 섹션에 IntersectionObserver 등록
+ */
+ const observeSection = (section: SectionElement): void => {
+ if (sectionObservers.has(section)) return
+
+ const observer = new IntersectionObserver(entries => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ animateSectionItems(section)
+ }
+ }
+ }, INTERSECTION_CONFIG)
+
+ observer.observe(section)
+ sectionObservers.set(section, observer)
+ }
+
+ /**
+ * 섹션에 아이템 등록 및 초기 애니메이션 체크
+ */
+ const registerItem = (el: HTMLElement, section: SectionElement): void => {
+ // 섹션의 아이템 목록에 추가
+ if (!sectionItems.has(section)) {
+ sectionItems.set(section, new Set())
+ observeSection(section)
+ }
+ sectionItems.get(section)!.add(el)
+
+ // 이미 viewport에 있는 경우 즉시 체크 (여러 번 체크)
+ if (!isSectionInViewport(section)) return
+
+ // 즉시 실행 및 재확인
+ for (const delay of RETRY_DELAYS) {
+ if (delay === 0) {
+ requestAnimationFrame(() => {
+ if (isSectionInViewport(section)) {
+ animateSectionItems(section)
+ }
+ })
+ } else {
+ setTimeout(() => {
+ if (isSectionInViewport(section)) {
+ animateSectionItems(section)
+ }
+ }, delay)
+ }
+ }
+ }
+
+ /**
+ * 섹션에서 아이템 제거 및 정리
+ */
+ const unregisterItem = (el: HTMLElement, section: SectionElement): void => {
+ const items = sectionItems.get(section)
+ if (!items) return
+
+ items.delete(el)
+ animatedItems.delete(el)
+
+ // 섹션에 아이템이 없으면 observer 정리
+ if (items.size === 0) {
+ const observer = sectionObservers.get(section)
+ observer?.disconnect()
+ sectionObservers.delete(section)
+ sectionItems.delete(section)
+ }
+ }
+
+ /**
+ * 초기 스타일 설정
+ */
+ const setInitialStyles = (el: HTMLElement): void => {
+ el.style.opacity = ANIMATION_CONFIG.initial.opacity.toString()
+ el.style.transform = `translateY(${ANIMATION_CONFIG.initial.translateY})`
+ }
+
+ /**
+ * 가장 가까운 section 찾기
+ */
+ const findSection = (el: HTMLElement): SectionElement | null => {
+ 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 null
+ }
+ return section as SectionElement
+ }
+
+ // v-motion-stagger 디렉티브
+ nuxtApp.vueApp.directive('motion-stagger', {
+ mounted(el: HTMLElement) {
+ setInitialStyles(el)
+
+ const section = findSection(el)
+ if (!section) return
+
+ registerItem(el, section)
+ },
+
+ unmounted(el: HTMLElement) {
+ const section = el.closest('section') as SectionElement | null
+ if (!section) return
+
+ unregisterItem(el, section)
+ },
+ })
+
+ // 페이지 이동 시 정리
+ if (import.meta.client) {
+ nuxtApp.hook('page:finish', () => {
+ const currentSections = new Set(
+ Array.from(document.querySelectorAll('section')) as SectionElement[]
+ )
+
+ for (const [section, observer] of sectionObservers.entries()) {
+ if (!currentSections.has(section)) {
+ observer.disconnect()
+ sectionObservers.delete(section)
+ sectionItems.delete(section)
+ }
+ }
+ })
+ }
+})
diff --git a/layers/templates/FxPreregist01/index.vue b/layers/templates/FxPreregist01/index.vue
index 6e1c4c9..54df06c 100644
--- a/layers/templates/FxPreregist01/index.vue
+++ b/layers/templates/FxPreregist01/index.vue
@@ -247,7 +247,7 @@ const handlePreregistClick = () => {
:resources-data="preSubTitleData"
class="title-sm mt-2"
/>
-
+
-
+
{
/>
diff --git a/layers/templates/GrBoard01/index.vue b/layers/templates/GrBoard01/index.vue
index 86b9d0f..6947aa7 100644
--- a/layers/templates/GrBoard01/index.vue
+++ b/layers/templates/GrBoard01/index.vue
@@ -124,6 +124,7 @@ const getArticleUrl = (articleId: string) => {
{
:resources-data="subTitleData"
class="title-sm max-w-[944px] mt-2"
/>
-