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

3D 회전을 구현하기 위해 다음과 같은 속성에 대한 이해가 필요하다.
이 프로젝트에선 편지 봉투가 열리는 애니메이션을 구현했지만, 위 속성을 활용한다면 간단하고 다양한 애니메이션 구현이 가능하다. (페이지를 넘긴다든지, 상자가 열린다든지, 문을 연다든지, ...)
perspective 는 3D 회전 시 원근감을 설정하는 속성으로 다음과 같은 특징이 있다.
px 등) 으로 설정아래 동일한 애니메이션에서 perspective 값만 조절했을 때의 차이를 볼 수 있다. 200px 일 때는 극적으로 회전하는 반면 2000px 일 때는 거의 원근감이 느껴지지 않는다.
<div style={{ perspective: '200px' }}>
{children}
</div>

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

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 (화면 밖으로)
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-style 자식 요소들이 3D 공간에서 어떻게 배치되는지를 결정하는 속성이다.
transform-style: flat; /* 기본값 */
transform-style: preserve-3d; /* 3D 공간 유지 */
여기서 자식 요소들이 3D 공간을 유지한다는 것은 translateZ() 가 제대로 동작한다는 것을 의미한다. 관련된 데모 예시는 여기에 많으니 참고하자.
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 를 사용했다. 기능 정의에 의해 두 가지 상태 isOpen 과 isExpanded 가 필요했고, 각각은 봉투 여부와 컨텐츠 (편지) 확장 여부를 나타낸다.
interface EnvelopeContextType {
isOpen: boolean;
isExpanded: boolean;
toggleOpen: () => void;
expand: () => void;
close: () => void;
}
isOpen : 봉투 열림 여부isExpanded : 컨텐츠 확장 여부 (= 편지 꺼냄 여부)toggleOpen : 봉투 열기/닫기expand : 컨텐츠 확장close : 완전히 닫기 위 기능에 필요한 상태는 이렇게 정리할 수 있다.
isOpen | isExpanded | |
|---|---|---|
| 닫힘 | false | false |
| 봉투만 열림 | true | false |
| 편지 꺼냄 | true | true |
해당 컴포넌트에는 다양한 레이어가 겹쳐있기 때문에 편지, 봉투, 씰 각각의 z-index 설정에 주의하자.
| 요소 | z-index | 특징 |
|---|---|---|
| 꺼낸 편지 | 50 | 가장 위 |
| 씰 (스티커) | 30 | 항상 봉투 위 |
| 봉투 | 15 | 기본 레이어 |
| 접힌 편지 | 1 | 봉투 아래 숨김 |
컴파운드 컴포넌트 패턴을 적용하여 외부에서 컴포넌트를 조합해 사용할 수 있도록 했다. 또한 Context API를 사용하기 때문에 컴포넌트 간 상태 공유가 가능하다.
다음과 같이 각각의 컴포넌트를 구현하고, Envelope 객체로 export 하면 외부에서 <Envelope.Container> 와 같이 사용할 수 있다.
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>
이제 각각의 컴포넌트 구현 방법을 살펴보자.
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>
);
};
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'
},
}
z-index 로 봉투 위에 보이도록 설정transition 설정으로 부드럽게 꺼내지도록 설정 top 을 지정하여 윗 부분만 보이도록 배치max-height 와 overflow 설정으로 아래 부분이 잘리도록 함 z-index 로 봉투 아래에 가려지도록 설정transition 설정하지 않음이 컴포넌트는 실제 봉투 이미지를 나타내는 컴포넌트로, 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;
마지막으로 편지 봉투 위에 위치한 씰 컴포넌트는 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() 속성이 핵심이었다는 점 . . . (급 마무리)