fix.모션 수정

This commit is contained in:
clkim
2026-01-26 11:19:36 +09:00
parent ac235118ea
commit 4cd42d017c
9 changed files with 207 additions and 110 deletions

View File

@@ -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(() => {
<template>
<component :is="componentTag" v-bind="componentProps" class="btn-circle">
<slot />
<span class="icon">
<slot />
</span>
<span class="sr-only">{{ props.srOnly }}</span>
</component>
</template>
@@ -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;
}
</style>

View File

@@ -22,7 +22,9 @@ const handlePlayClick = () => {
:style="{ backgroundColor: props.bgColor }"
@click="handlePlayClick"
>
<AtomsIconsArrowRightFill />
<span class="icon">
<AtomsIconsArrowRightFill />
</span>
<span class="sr-only">Play</span>
</button>
</template>
@@ -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;
}
</style>

View File

@@ -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(() => {
<AtomsIconsLongArrowRightLine
v-if="props.type === 'internal'"
:color="props.textColor"
class="icon"
class="icon icon-internal"
/>
<AtomsIconsWebLinkLine
v-if="props.type === 'external'"
:color="props.textColor"
class="icon"
class="icon icon-external"
/>
<AtomsIconsDownloadLine
v-if="props.type === 'download'"
:color="props.textColor"
class="icon"
class="icon icon-download"
/>
</span>
</component>
@@ -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;

View File

@@ -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];
}
</style>

View File

@@ -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];
}
</style>

View File

@@ -45,3 +45,13 @@ const handleArrowClick = (direction: 'prev' | 'next') => {
</AtomsButtonCircle>
</div>
</template>
<style scoped>
.splide__arrow--prev:hover :deep(.icon) {
@apply -translate-x-[3px];
}
.splide__arrow--next:hover :deep(.icon) {
@apply translate-x-[3px];
}
</style>

View File

@@ -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)];

View File

@@ -1,152 +1,199 @@
import { animate, stagger } from 'motion-v'
import type { DOMKeyframesDefinition, AnimationOptions } from 'motion-v'
// 상수 정의
const ANIMATION_CONFIG = {
initial: {
opacity: 0,
translateY: '40px',
},
target: {
opacity: 1,
y: 0,
},
stagger: 0.1,
duration: 0.6,
easing: 'ease',
} as const
const INTERSECTION_CONFIG = {
threshold: 0.2,
} as const
const RETRY_DELAYS = [0, 100, 300] as const
// 타입 정의
type SectionElement = HTMLElement & { tagName: 'SECTION' }
export default defineNuxtPlugin(nuxtApp => {
// 전역 상태 관리
const animatedItems = new Set<Element>()
const sectionObservers = new Map<Element, IntersectionObserver>()
const sectionItems = new Map<Element, Set<Element>>()
const animatedItems = new Set<HTMLElement>()
const sectionObservers = new Map<SectionElement, IntersectionObserver>()
const sectionItems = new Map<SectionElement, Set<HTMLElement>>()
// 섹션의 motion-item들을 애니메이션
const animateSectionItems = (section: Element) => {
/**
* 섹션의 motion-item들을 애니메이션
*/
const animateSectionItems = (section: SectionElement): void => {
const items = sectionItems.get(section)
if (!items || items.size === 0) return
if (!items?.size) return
const newItems = Array.from(items).filter(item => !animatedItems.has(item))
if (newItems.length === 0) return
if (!newItems.length) return
// 애니메이션 실행
newItems.forEach(item => animatedItems.add(item))
animate(
newItems,
{ opacity: 1, y: 0 } as DOMKeyframesDefinition,
ANIMATION_CONFIG.target as DOMKeyframesDefinition,
{
delay: stagger(0.2),
duration: 0.5,
easing: [0.22, 1, 0.36, 1],
delay: stagger(ANIMATION_CONFIG.stagger),
duration: ANIMATION_CONFIG.duration,
easing: ANIMATION_CONFIG.easing,
} as AnimationOptions
)
}
// 섹션에 IntersectionObserver 등록
const observeSection = (section: Element) => {
/**
* 섹션이 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 => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animateSectionItems(section)
}
})
},
{ threshold: 0.2 }
)
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
animateSectionItems(section)
}
}
}, INTERSECTION_CONFIG)
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
/**
* 섹션에 아이템 등록 및 초기 애니메이션 체크
*/
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) {
// 초기 스타일 설정 (애니메이션 전)
el.style.opacity = '0'
el.style.transform = 'translateY(30px)'
setInitialStyles(el)
// 가장 가까운 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)
}
sectionItems.get(section)!.add(el)
// 이미 viewport에 있는 경우 즉시 체크 (여러 번 체크)
const inViewport = isSectionInViewport(section)
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')
const section = findSection(el)
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)
}
}
registerItem(el, section)
},
animatedItems.delete(el)
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<Element>(
Array.from(document.querySelectorAll('section'))
const currentSections = new Set<SectionElement>(
Array.from(document.querySelectorAll('section')) as SectionElement[]
)
sectionObservers.forEach((observer, section) => {
for (const [section, observer] of sectionObservers.entries()) {
if (!currentSections.has(section)) {
observer.disconnect()
sectionObservers.delete(section)
sectionItems.delete(section)
}
})
}
})
}
})

View File

@@ -11,7 +11,6 @@ export default {
md: '1024px', // PC: 1024px ~ 1439px
lg: '1440px', // Large PC: 1440px+
},
spacing: {},
colors: {
'theme-foreground': 'var(--foreground)',
'theme-foreground-10': 'var(--foreground-10)',
@@ -26,6 +25,9 @@ export default {
'theme-foreground-gray-750': 'var(--foreground-gray-750)',
'theme-foreground-gray-500': 'var(--foreground-gray-500)',
},
transitionTimingFunction: {
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
},
},
},
} satisfies Config