[React/CSS] 편지 봉투 열리는 애니메이션 만들기

@eunjios·2025년 12월 2일
post-thumbnail

목차


서론

크리스마스 어드벤트 캘린더 프로젝트 를 진행하며 편지 봉투가 열리는 3D 애니메이션을 구현했다. CSS 3D transform 기본 개념부터 실제 코드까지 정리해본다.



3D 회전 애니메이션 개념

3D 회전을 구현하기 위해 다음과 같은 속성에 대한 이해가 필요하다.

  • perspective
  • transform
  • transform-origin
  • transform-style
  • transition

이 프로젝트에선 편지 봉투가 열리는 애니메이션을 구현했지만, 위 속성을 활용한다면 간단하고 다양한 애니메이션 구현이 가능하다. (페이지를 넘긴다든지, 상자가 열린다든지, 문을 연다든지, ...)


perspective

perspective 는 3D 회전 시 원근감을 설정하는 속성으로 다음과 같은 특징이 있다.

  • 값이 작을수록 원근감이 강하게 느껴짐
  • 속성값은 길이 값 (px 등) 으로 설정

아래 동일한 애니메이션에서 perspective 값만 조절했을 때의 차이를 볼 수 있다. 200px 일 때는 극적으로 회전하는 반면 2000px 일 때는 거의 원근감이 느껴지지 않는다.

200px

<div style={{ perspective: '200px' }}>
  {children}
</div>

2000px

<div style={{ perspective: '2000px' }}>
  {children}
</div>


transfrom

transfrom은 회전, 크기 조정, 기울기, 이동 등을 조절하는 속성으로 다양한 속성값을 지원한다. 대표적인 값들은 다음과 같다. (더 다양한 속성값은 여기서 확인)

이동

transform: translate(12px, 50%);        /* x축으로 12px, y축으로 50% 이동 */
transform: translate3d(12px, 50%, 3em); /* x축으로 12px, y축으로 50%, z축으로 3em 이동 */
transform: translateZ(2px)              /* z축으로만 이동 (앞으로) */ 

크기 조절

transform: scale(2, 0.5);      /* x축으로 2배, y축으로 0.5배 크기 조절 */
transfrom: scale3d(2, 1, 0.5); /* 3D 공간에서 크기 조절 */
transform: scaleX(2);          /* x축으로만 2배 확대 */

회전

transform: rotate(0.5turn);  /* 2D 평면에서 180도 회전 */
transform: rotateX(10deg);   /* x축을 중심으로 회전 */
transform: rotateY(10deg);   /* y축을 중심으로 회전 */
transform: rotateZ(10deg);   /* z축을 중심으로 회전 (2D rotate 와 동일) */
transform: rotate3d(1, 2, 3, 10deg); /* 벡터 [1, 2, 3] 을 중심으로 10도 회전 */

참고로 회전축은 다음과 같다.

        Y (↑)
        |
        |
        |__________ X (→)
       /
      /
     Z (화면 밖으로)

transform-origin

transformOrigin 은 transform 의 중심이 되는 점을 나타내는 속성이다. 회전, 크기 조절, 기울기 등을 어느 점을 중심으로 변형할지를 결정한다. 더 다양한 속성값은 여기서 확인할 수 있다.

transform-origin: 2px;          /* x축 위치 지정, y축은 자동으로 중앙 (50%) */
transform-origin: bottom;       /* x축 50%, y축은 bottom */

transform-origin: left 2px;     /* x축 왼쪽 (0%), y축은 위에서 2px */
transform-origin: 2px 30% 10px; /* x축 왼쪽에서 2px y축 위에서 30%, y축 10px */

여기서 x축 키워드 left 는 0%, center 는 50%, right 는 100% 를 나타내고, y축 키워드 top 은 0%, center 는 50%, bottom 은 100% 를 나타낸다.

transform-origin 값이 달라짐에 따라 rotateZ() 는 다음과 같이 회전된다. 편의를 위해 transform origin 값은 빨간색 점으로 표시했다.

transform-origin: top left;

  • transform-origin: top left
  • transform: rotateZ(180deg)

transform-origin: center;

  • transform-origin: center
  • transform: rotateZ(360deg)

transform-style

transform-style 자식 요소들이 3D 공간에서 어떻게 배치되는지를 결정하는 속성이다.

transform-style: flat;        /* 기본값 */
transform-style: preserve-3d; /* 3D 공간 유지 */
  • flat : 자식 요소들이 평면 (2D) 로 압축됨
  • preserve-3d : 자식 요소들이 3D 공간을 유지

여기서 자식 요소들이 3D 공간을 유지한다는 것은 translateZ() 가 제대로 동작한다는 것을 의미한다. 관련된 데모 예시는 여기에 많으니 참고하자.


transition

transition 속성은 CSS 속성 값이 변할 때 부드러운 애니메이션 효과를 주는 속성이다. 예를 들어, transform 이 rotateZ(0deg) 에서 rotateZ(180deg) 로 변할 때 회전하는 애니메이션을 주려면 transition: transform 1s ease-out 등으로 트랜지션 설정을 해주면 된다.

개별 속성으로 트랜지션을 지정할 수 있지만 대부분 shorthand 속성을 사용한다. 우선 개별 속성은 다음과 같다:

  • transition-property : 어떤 속성에 트랜지션을 적용할지 지정
  • transition-duration : 애니메이션이 완료되는 시간
  • transition-timing-function : 애니메이션의 속도 곡선 지정
  • transition-delay : 애니메이션 시작 전 대기 시간

transition 속성을 한 번에 (shorthand 로) 지정할 땐 property duration timing-function delay 순서대로 값을 지정한다.

transition: opacity 0.5s;  /* 투명도를 0.5초 동안 애니메이션 전환 */
transition: all 0.3s ease; /* 모든 속성에 동일한 애니메이션 전환 */



프로젝트에 적용하기

기능 정의

이제 본론으로 들어가 편지 봉투가 열리는 애니메이션을 구현해보자. 다음과 같이 봉투 뚜껑이 열리는 애니메이션이 필요한데 위에서 다룬 속성들을 활용하면 쉽게(?) 구현이 가능하다.

우선 3D 로 열려야 하기 때문에 perspective 속성이 필요하고, x축 회전을 위해 transform: rotateX()transition 속성을 사용하면 된다.

편지 봉투에 대한 기능 정의는 다음과 같다:

  • 초기 상태는 편지 봉투가 닫힘
  • 유저가 해당 봉투를 클릭 시 편지 봉투가 열림
  • 열린 상태에서 봉투 뚜껑을 클릭 시 편지 봉투가 닫힘
  • 열린 상태에서 봉투 바디를 클릭 시 편지가 확장됨
  • 편지가 확장된 상태에서 봉투 클릭 시 닫힘 (초기 상태)

상태 관리 (Context API)

컴포넌트 간 상태 공유를 위해 Context API 를 사용했다. 기능 정의에 의해 두 가지 상태 isOpenisExpanded 가 필요했고, 각각은 봉투 여부와 컨텐츠 (편지) 확장 여부를 나타낸다.

interface EnvelopeContextType {
  isOpen: boolean;
  isExpanded: boolean;
  toggleOpen: () => void;
  expand: () => void;
  close: () => void;
}
  • isOpen : 봉투 열림 여부
  • isExpanded : 컨텐츠 확장 여부 (= 편지 꺼냄 여부)
  • toggleOpen : 봉투 열기/닫기
  • expand : 컨텐츠 확장
  • close : 완전히 닫기

상태 흐름

위 기능에 필요한 상태는 이렇게 정리할 수 있다.

isOpenisExpanded
닫힘falsefalse
봉투만 열림truefalse
편지 꺼냄truetrue

z-index

해당 컴포넌트에는 다양한 레이어가 겹쳐있기 때문에 편지, 봉투, 씰 각각의 z-index 설정에 주의하자.

요소z-index특징
꺼낸 편지50가장 위
씰 (스티커)30항상 봉투 위
봉투15기본 레이어
접힌 편지1봉투 아래 숨김



컴포넌트 구조

Compound Component Pattern

컴파운드 컴포넌트 패턴을 적용하여 외부에서 컴포넌트를 조합해 사용할 수 있도록 했다. 또한 Context API를 사용하기 때문에 컴포넌트 간 상태 공유가 가능하다.

다음과 같이 각각의 컴포넌트를 구현하고, Envelope 객체로 export 하면 외부에서 <Envelope.Container> 와 같이 사용할 수 있다.

  • Container: Envelope 의 메인 wrapper
  • Content: Envelope 의 컨텐츠 (= 봉투 안 내용물)
  • Envelope: Envelope 이미지 (= 편지 봉투)
  • Seal: 편지 봉투 스티커
export const Envelope = {
  Container: EnvelopeContainer,
  Content: EnvelopeContent,
  Envelope: EnvelopeEnvelope,
  Seal: EnvelopeSeal,
}
// Envelope 사용 예시
<Envelope.Container>
  <Envelope.Content>
    <div>편지 내용 ...</div>
  </Envelope.Content>
  <Envelope.Envelope />
  <Envelope.Seal day={1} />
</Envelope.Container>

실제 프로젝트에서는 이런 식으로 사용했다:

<Envelope.Container>
  <Envelope.Content>
    <Letter.Container>
      <Letter.Content fixedHeight>{text}</Letter.Content>
      <Letter.Footer from={from} date={date} />
    </Letter.Container>
  </Envelope.Content>
  <Envelope.Envelope />
  <Envelope.Seal day={day} />
</Envelope.Container>

이제 각각의 컴포넌트 구현 방법을 살펴보자.


EnvelopeContainer

Context API 로 상태를 공유하여 자식 컴포넌트들에서도 해당 값과 함수를 사용할 수 있도록 한다. 또한 3D 회전 애니메이션 효과를 위해 핵심 스타일은 다음과 같이 지정했다.

  • relative : 자식 요소들의 위치의 기준
  • perspective : 3D 회전 효과를 위해 원근감 설정
  • 너비 및 높이는 고정 값으로 설정
const EnvelopeContainer = ({ children }: Props) => {
  const [isOpen, setIsOpen] = useState(false);
  const [isExpanded, setIsExpanded] = useState(false);

  // ...
  
  return (
    <EnvelopeContext.Provider value={{ /* ... */ }}>
      <div 
        className="bg-letter-300 relative h-56 w-80 shadow-lg" 
        style={{ perspective: '1000px' }}
      >
        {children}
      </div>
    </EnvelopeContext.Provider>
  );
};

EnvelopeContent

Context API 로 현재 Envelope 의 상태에 따라 컨텐츠 스타일을 다르게 했다. 편지가 확장된 (꺼내진) 상태일 때는 정중앙에 배치하고, 그러지 않을 때는 height 를 제한하고 위에 배치하고자 했다.

const EnvelopeContent = ({ children }: { children: React.ReactNode }) => {
  const { isExpanded } = useEnvelope();

  return (
    <span
      className="absolute left-1/2 w-72"
      style={isExpanded ? envelopeContentStyles.expanded : envelopeContentStyles.collapsed}
    >
      {children}
    </span>
  );
};
export const envelopeContentStyles = {
  expanded: {
    top: '50%',
    transform: 'translateX(-50%) translateY(-50%)',  // 정중앙 배치
    maxHeight: 'none',
    overflow: 'visible',
    zIndex: 50,
    transition: 'top 0.6s ease-out, transform 0.6s ease-out, ...'
  },
  collapsed: {
    top: '12px',
    transform: 'translateX(-50%) translateY(0)',  // 상단 배치
    maxHeight: '200px',
    overflow: 'hidden',
    zIndex: 1,
    transition: 'none'
  },
}
  • 편지 꺼냄 (expanded)
    • 위치는 정중앙 배치
    • z-index 로 봉투 위에 보이도록 설정
    • 편지 꺼낼 땐 transition 설정으로 부드럽게 꺼내지도록 설정
  • 편지 넣음 (collapsed)
    • top 을 지정하여 윗 부분만 보이도록 배치
    • max-heightoverflow 설정으로 아래 부분이 잘리도록 함
    • z-index 로 봉투 아래에 가려지도록 설정
    • 넣을 땐 transition 설정하지 않음

EnvelopeEnvelope

이 컴포넌트는 실제 봉투 이미지를 나타내는 컴포넌트로, svg 파일 작업이 필요하다. 본인은 Figma 를 사용 중이었기 때문에 간단히 도형을 조합하여 편지 body 와 뚜껑을 별개의 파일로 저장해두었다.

const EnvelopeEnvelope = () => {
  const { isOpen, isExpanded, toggleOpen, close, expand } = useEnvelope();

  // 편지 뚜껑 클릭 핸들러
  const clickHandler = (e: React.MouseEvent) => {
    e.stopPropagation();
    if (!isOpen) {
      toggleOpen(); // 봉투 열기
    } else if (!isExpanded) {
      expand(); // 편지 꺼내기
    } else {
      close(); // 완전히 닫기
    }
  };

  return (
    <div className="relative" style={{ zIndex: 10 }}>
      {/* 봉투 몸통 */}
      <button className="relative" style={{ zIndex: 15 }} onClick={clickHandler}>
        <Image src="/svg/envelope-body.svg" alt="편지봉투" width={320} height={224} />
      </button>
      
      {/* 봉투 뚜껑 (3D 회전) */}
      <button
        className="absolute transition-all duration-400 ease-out"
        style={{
          top: 0,
          left: 0,
          transformOrigin: '160px 0px',
          transform: isOpen ? 'rotateX(180deg)' : 'rotateX(0deg)',
          transformStyle: 'preserve-3d',
          zIndex: 15,
        }}
        onClick={toggleOpen}
      >
        <Image src="/svg/envelope-top.svg" alt="편지봉투" width={320} height={224} />
      </button>
    </div>
  );
};

복잡해 보이지만 로직 자체는 간단하다. 회전축을 뚜껑 상단 중앙으로 설정하고 봉투가 열렸을 땐 rotateX(180deg) 로, 닫혔을 땐 rotateX(0deg) 로 설정하여 애니메이션 효과를 줬다. 핵심 스타일은 다음과 같다:

transform-origin: '160px 0px';
transform: rotateX(180deg);
transform-style: preserve-3d;

EnvelopeSeal

마지막으로 편지 봉투 위에 위치한 씰 컴포넌트는 position 을 absolute 로 설정하여 부모 요소 기준으로 상대 위치를 지정했다. 또한 z-index 도 높게 설정하여 봉투 가장 위에 표시하였다.

const EnvelopeSeal = ({ day }: { day: number }) => {
  return (
    <span 
      className="absolute top-24 left-1/2 -translate-x-1/2" 
      style={{ zIndex: 30 }}
    >
      <Icon number={day} size={68} />
    </span>
  );
};


전체 코드

'use client';

import Image from 'next/image';
import { createContext, useContext, useState } from 'react';

import { envelopeContentStyles } from './Envelope.constants';
import { Icon } from '../Icon/Icon';

interface EnvelopeContextType {
  isOpen: boolean;
  isExpanded: boolean;
  toggleOpen: () => void;
  expand: () => void;
  close: () => void;
}

const EnvelopeContext = createContext<EnvelopeContextType | undefined>(undefined);

const useEnvelope = () => {
  const context = useContext(EnvelopeContext);
  if (!context) {
    throw new Error('Envelope components must be used within EnvelopeContainer');
  }
  return context;
};

interface Props {
  children: React.ReactNode;
}

const EnvelopeContainer = ({ children }: Props) => {
  const [isOpen, setIsOpen] = useState(false);
  const [isExpanded, setIsExpanded] = useState(false);

  const toggleOpen = () => {
    setIsOpen((prev) => {
      if (prev) {
        setIsExpanded(false);
        return false;
      } else {
        return true;
      }
    });
  };

  const expand = () => {
    setIsExpanded(true);
  };

  const close = () => {
    setIsExpanded(false);
    setIsOpen(false);
  };

  return (
    <EnvelopeContext.Provider value={{ isOpen, isExpanded, toggleOpen, expand, close }}>
      <div className="bg-letter-300 relative h-56 w-80 shadow-lg" style={{ perspective: '1000px' }}>
        {children}
      </div>
    </EnvelopeContext.Provider>
  );
};

const EnvelopeContent = ({ children }: { children: React.ReactNode }) => {
  const { isExpanded } = useEnvelope();

  return (
    <span
      className="absolute left-1/2 w-72"
      style={isExpanded ? envelopeContentStyles.expanded : envelopeContentStyles.collapsed}
    >
      {children}
    </span>
  );
};

const EnvelopeEnvelope = () => {
  const { isOpen, isExpanded, toggleOpen, close, expand } = useEnvelope();

  const clickHandler = (e: React.MouseEvent) => {
    e.stopPropagation();
    if (!isOpen) {
      toggleOpen();
    } else if (!isExpanded) {
      expand();
    } else {
      close();
    }
  };

  const toggle = (e: React.MouseEvent) => {
    toggleOpen();
  };

  return (
    <div className="relative" style={{ zIndex: 10 }}>
      {/* 봉투 body */}
      <button className="relative" style={{ zIndex: 15 }} onClick={clickHandler}>
        <Image priority src="/svg/envelope-body.svg" alt="편지봉투" width={320} height={224} />
      </button>
      {/* 뚜껑 */}
      <button
        className="absolute transition-all duration-400 ease-out"
        style={{
          top: 0,
          left: 0,
          transformOrigin: '160px 0px',
          transform: isOpen ? 'rotateX(180deg)' : 'rotateX(0deg)',
          transformStyle: 'preserve-3d',
          zIndex: 15,
        }}
        onClick={toggle}
      >
        <Image priority src="/svg/envelope-top.svg" alt="편지봉투" width={320} height={224} />
      </button>
    </div>
  );
};

const EnvelopeSeal = ({ day }: { day: number }) => {
  return (
    <span className="absolute top-24 left-1/2 -translate-x-1/2" style={{ zIndex: 30 }}>
      <Icon number={day} size={68} />
    </span>
  );
};

export const Envelope = {
  Container: EnvelopeContainer,
  Content: EnvelopeContent,
  Envelope: EnvelopeEnvelope,
  Seal: EnvelopeSeal,
};


결론

이렇게 CSS 3D transform 속성들을 조합하여 복잡한 라이브러리 없이도 자연스러운 애니메이션을 구현할 수 있었다. 사실 이전에는 이렇게 애니메이션을 직접 구현해 본 경험이 거의 없었는데 이번 기회로 다시 정리할 수 있어서 좋았다. 아무튼 perspective transform-origin transform: rotateX() 속성이 핵심이었다는 점 . . . (급 마무리)

참고 자료

profile
🗒️

0개의 댓글