logo

DowanKim

3. 스크롤 하면 요소들이 순서대로 천천히 밑에서 위로 나타나게 해주세요

2025년 8월 5일

졸업논문 대체 웹사이트

image.png

어바웃 페이지의 각 섹션들에 대해서, 이전에 구현한 커스텀 스크롤 시스템과 함께 각 요소들이 순서대로 천천히 밑에서 올라오는듯한 효과를 구현해야 합니다.

전체적인 구현 흐름은 다음과 같습니다.

1. IntersectionObserver로 뷰포트 진입 감지 → 성능 효율적 감지

2. isVisible 상태로 애니메이션 제어 → 보일 때만 트리거

3. translateY + opacity 조합 → 아래에서 위로 페이드인

4. delayMs로 순차 등장 → 시각적 흐름 개선

5. asChild 패턴으로 래퍼 없이 적용 → 절대 배치 요소 레이아웃 유지

6. prefers-reduced-motion 대응 → 접근성 확보

7. willChange로 성능 최적화 → 브라우저 최적화 힌트

아래는 Reveal 래퍼 컴포넌트를 구현한 과정과 문제해결 과정입니다.


Reveal 컴포넌트 적용 과정

목표

스크롤로 섹션이 뷰포트에 들어올 때 요소가 페이드인과 함께 아래에서 위로 등장하는 애니메이션을 적용합니다.


1단계: IntersectionObserver로 가시성 감지

문제: 스크롤 이벤트로 감지하면 성능 저하

scroll 이벤트는 빈번하게 발생하고, 각 요소의 위치 계산이 필요해 비용이 큽니다.

해결: IntersectionObserver API 사용

useEffect(() => { const targetEl = asChild ? childRef.current : wrapperRef.current; if (!targetEl) return; const observer = new IntersectionObserver( entries => { entries.forEach(entry => { if (entry.isIntersecting) { setIsVisible(true); if (once) observer.unobserve(entry.target); } else if (!once) { setIsVisible(false); } }); }, { threshold } ); observer.observe(targetEl); return () => observer.disconnect(); }, [threshold, once, asChild]);
  • IntersectionObserver는 브라우저가 뷰포트 교차를 효율적으로 감지합니다.
  • threshold: 0.1로 요소가 10% 보일 때 트리거합니다.
  • once가 true면 한 번만 실행하고 관찰을 해제합니다.

문제: asChild 모드에서 관찰 대상 선택

asChild일 때는 래퍼가 없으므로 자식 요소를 직접 관찰해야 합니다.

해결: 조건부로 targetEl 선택

const targetEl = asChild ? childRef.current : wrapperRef.current;
  • asChild가 false면 wrapperRef, true면 childRef를 사용합니다.

2단계: 애니메이션 스타일 적용

문제: 기본 상태에서 숨김 처리

요소가 보이지 않을 때는 아래에 위치하고 투명해야 합니다.

해결: translateY와 opacity 조합

const animatedStyle: CSSProperties = { transform: `translateY(${isVisible ? 0 : translateYPx}px)`, opacity: isVisible ? 1 : 0, transition: `transform ${durationMs}ms ease-out ${delayMs}ms, opacity ${durationMs}ms ease-out ${delayMs}ms`, willChange: "transform, opacity", ...existingStyle, };
  • translateY: 초기 60px 아래에서 시작해 0으로 이동합니다.
  • opacity: 0에서 1로 전환합니다.
  • willChange: 브라우저에 최적화 힌트를 제공합니다.

문제: transition 타이밍 제어

지연과 지속 시간을 조절해 순차 등장을 구현해야 합니다.

해결: delayMs와 durationMs 파라미터

transition: `transform ${durationMs}ms ease-out ${delayMs}ms, opacity ${durationMs}ms ease-out ${delayMs}ms`,
  • durationMs = 600: 애니메이션 지속 시간
  • delayMs = 200: 지연 시간 (기본값, 조정 가능)

Main 페이지:

<Reveal asChild delayMs={200}> <LogoImg src={Logo} alt="logo" /> </Reveal> <Reveal asChild delayMs={500}> <Major> Design & <br /> Technology </Major> </Reveal> <Reveal asChild delayMs={300}> <Opening> <OpeningText>Opening_오프닝</OpeningText> <OpeningText2> 2025/11/14 <br /> 6pm </OpeningText2> </Opening> </Reveal> <Reveal asChild delayMs={600}> <Place> Design Center Busan <br /> 1F Exhibition hall </Place> </Reveal>
  • 로고(200ms) → Major(500ms) → Opening(300ms) → Place(600ms) 순서로 등장합니다.

3단계: asChild 패턴 구현

문제: 래퍼 div 추가 시 레이아웃 깨짐

Main 페이지의 요소들이 position: absolute로 정밀하게 배치되어 있어 래퍼를 추가하면 위치가 어긋납니다.

예시:

const LogoImg = styled.img` position: absolute; width: 40.56vmin; /* 438px */ height: 9.07vmin; /* 98px */ position: absolute; margin-left: -62.8vmin; /* 403px */ margin-top: 54.1vmin; /* 98px */ object-fit: cover; @media (max-width: 768px) { margin-left: 62.8vmin; margin-top: -54.1vmin; } `;

해결: asChild로 자식 요소에 직접 스타일 주입

if (asChild && isValidElement(children)) { type ChildProps = { style?: CSSProperties }; const child = children as ReactElement<ChildProps>; const existingStyle = (child.props?.style || {}) as CSSProperties; const animatedStyle: CSSProperties = { transform: `translateY(${isVisible ? 0 : translateYPx}px)`, opacity: isVisible ? 1 : 0, transition: `transform ${durationMs}ms ease-out ${delayMs}ms, opacity ${durationMs}ms ease-out ${delayMs}ms`, willChange: "transform, opacity", ...existingStyle, }; const setRef = (node: HTMLElement | null) => { childRef.current = node; }; return cloneElement(child, { style: animatedStyle, ref: setRef, } as unknown as ChildProps); }
  • cloneElement로 자식에 styleref를 주입합니다.
  • ...existingStyle을 마지막에 병합해 기존 스타일을 보존합니다.
  • 래퍼 없이 자식 요소 자체에 애니메이션을 적용합니다.

문제: ref 전달

IntersectionObserver가 자식 요소를 관찰하려면 ref가 필요합니다.

해결: ref 콜백 함수로 전달

const setRef = (node: HTMLElement | null) => { childRef.current = node; };
  • cloneElement로 ref를 주입해 childRef.current에 자식 DOM을 저장합니다.

5단계: 일반 모드 (래퍼 사용)

문제: 블록 요소나 섹션 전체에 애니메이션 적용

섹션 전체를 감싸는 경우 래퍼가 있어도 레이아웃에 영향이 없습니다.

해결: Wrapper styled-component 사용

return ( <Wrapper ref={wrapperRef} $visible={isVisible} $delayMs={delayMs} $durationMs={durationMs} $translateY={translateYPx} > {children} </Wrapper> );

About 페이지에서 섹션 전체 감싸기:

<Section> {" "} {/* 섹션 1: 메인 */} <Reveal> <Main /> </Reveal> </Section>
  • 섹션 전체가 뷰포트에 들어올 때 페이드인됩니다.

6단계: 접근성 고려 (prefers-reduced-motion)

문제: 모션 민감 사용자 지원

일부 사용자는 애니메이션으로 인한 불편을 겪을 수 있습니다.

해결: prefers-reduced-motion 미디어 쿼리

@media (prefers-reduced-motion: reduce) { transition: none; transform: none; opacity: 1; }
  • 시스템 설정에서 모션 감소를 요청한 경우 애니메이션을 비활성화하고 즉시 표시합니다.

7단계: styled-components로 애니메이션 스타일 정의

문제: 일반 모드에서도 동일한 애니메이션 적용

래퍼 모드에서도 asChild와 동일한 효과가 필요합니다.

해결: Wrapper styled-component에 동일 로직 적용

const Wrapper = styled.div<{ $visible: boolean; $delayMs: number; $durationMs: number; $translateY: number; }>` will-change: transform, opacity; transform: translateY(${p => (p.$visible ? 0 : p.$translateY)}px); opacity: ${p => (p.$visible ? 1 : 0)}; transition: transform ${p => p.$durationMs}ms ease-out ${p => p.$delayMs}ms, opacity ${p => p.$durationMs}ms ease-out ${p => p.$delayMs}ms; @media (prefers-reduced-motion: reduce) { transition: none; transform: none; opacity: 1; } `;
  • props로 전달된 값으로 애니메이션을 제어합니다.

이렇게 구현하여, 스크롤 시 요소가 자연스럽게 등장하는 애니메이션을 제공하면서도 레이아웃을 유지하고 접근성을 고려했습니다.