CSS를 이용한 3D Card UI 제작기
카드에 마우스를 올리면 뒤집히면서 상세 정보를 보여주는 3D 애니메이션을 구현해봤어요. 처음에는 복잡해 보였는데, CSS의 3D Transform 속성들을 활용하니 생각보다 간단하게 구현할 수 있었습니다.
이번 글에서는 실제 프로젝트에서 사용한 코드를 바탕으로 3D 카드 뒤집기 애니메이션을 구현하는 과정을 공유해볼게요.
프로젝트를 진행하면서 동물 도감 카드를 보여주는 페이지를 만들게 되었어요. 처음에는 클릭하면 모달이나 사이드바로 상세 정보를 보여주는 방식을 고려했는데, 사용자 경험 측면에서 더 직관적이고 재미있는 방법을 찾고 싶었습니다.
그래서 카드에 마우스를 올리면 뒤집히면서 상세 정보를 보여주는 3D 애니메이션을 선택했어요. 이렇게 하면:
3D 카드 뒤집기 애니메이션을 구현하려면 CSS의 3D Transform 속성들을 이해해야 해요. 처음에는 용어들이 생소했는데, 하나씩 알아보니 그렇게 복잡하지 않았어요.
perspective - 원근감 설정perspective는 3D 공간의 원근감을 설정하는 속성이에요. 값이 클수록 원근감이 약해지고, 작을수록 강해집니다.
perspective: 1000px;
이 값을 설정하지 않으면 3D 효과가 제대로 보이지 않아요. 제가 처음에 이걸 빼먹어서 애니메이션이 평면적으로 보였던 경험이 있습니다.
값을 어떻게 선택해야 할까요?
perspective 값은 시점과 카드 사이의 거리를 의미해요. 값이 작을수록 가까이서 보는 것처럼 왜곡이 강하고, 값이 클수록 멀리서 보는 것처럼 자연스럽습니다.
제 프로젝트에서는 1000px을 사용했는데, 카드 크기와 비율에 맞게 조정하면 더 좋은 효과를 얻을 수 있을 것 같아요.
transform-style: preserve-3d - 3D 공간 유지자식 요소들이 3D 공간에서 변환되도록 유지하는 속성이에요. 이게 없으면 자식 요소들이 평면으로 렌더링되어 3D 효과가 사라집니다.
transform-style: preserve-3d;
왜 이 속성이 필요한가요?
부모 요소에 transform을 적용하면, 기본적으로 자식 요소들은 부모의 변환된 평면 위에서만 움직여요. 즉, 부모가 회전해도 자식들은 그 평면에 붙어있는 것처럼 보입니다.
하지만 카드 뒤집기 애니메이션에서는 앞면과 뒷면이 각각 독립적으로 3D 공간에서 회전해야 해요. preserve-3d를 사용하면 자식 요소들도 3D 공간에서 자유롭게 변환될 수 있습니다.
기본값 flat과의 차이
flat (기본값): 자식 요소들이 부모의 평면에 평행하게 렌더링됨preserve-3d: 자식 요소들이 3D 공간에서 독립적으로 변환됨이 속성을 빼먹으면 카드가 뒤집히는 게 아니라 그냥 사라지는 것처럼 보일 수 있어요. 제가 처음에 이걸 놓쳐서 애니메이션이 제대로 작동하지 않았던 경험이 있습니다.
backface-visibility: hidden - 뒷면 숨기기카드의 뒷면을 숨기는 속성이에요. 이게 없으면 카드를 뒤집을 때 앞면과 뒷면이 겹쳐서 보이는 문제가 발생해요.
backface-visibility: hidden;
왜 뒷면을 숨겨야 할까요?
카드를 뒤집을 때, 앞면이 180도 회전하면 그 뒷면이 보이게 되는데, 동시에 뒷면 카드도 정면으로 돌아와서 보이게 됩니다. 이렇게 되면 두 면이 겹쳐서 보이는 문제가 발생해요.
backface-visibility: hidden을 사용하면
성능상의 이점
처음에 이 속성을 빼먹었을 때, 카드가 반쯤 뒤집혔을 때 앞면과 뒷면이 동시에 보여서 정말 이상하게 보였어요. 이 속성 하나로 해결되었습니다.
rotateY() - Y축 회전Y축을 기준으로 회전시키는 함수예요. 180도 회전하면 카드가 뒤집히는 효과를 만들 수 있어요.
transform: rotateY(180deg);
실제 구현은 크게 3단계로 나눌 수 있어요.
카드의 앞면과 뒷면을 같은 공간에 겹쳐서 배치해야 해요. 그래서 absolute 포지셔닝을 사용했습니다.
<div className="perspective-1000">
<div className="preserve-3d">
{/* 앞면 카드 */}
<div className="backface-hidden">...</div>
{/* 뒷면 카드 */}
<div className="backface-hidden rotate-y-180">...</div>
</div>
</div>
전역 CSS 파일에 3D 애니메이션에 필요한 클래스들을 정의했어요.
/* 3D Flip Animation */
.perspective-1000 {
perspective: 1000px;
}
.preserve-3d {
transform-style: preserve-3d;
}
.backface-hidden {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
/* Hover 시 카드 뒤집기 */
.perspective-1000:hover .preserve-3d {
transform: rotateY(180deg);
}
.rotate-y-180 {
transform: rotateY(180deg);
}
이제 React 컴포넌트에서 이 구조를 적용했어요.
전체 코드를 보면서 어떻게 구현했는지 설명해볼게요.
export function AnimalCard({ animal, animalDetail }: AnimalCardProps) {
return (
<div className="group relative h-full aspect-3/4 perspective-1000">
<div className="preserve-3d relative h-full w-full transition-transform duration-700">
{/* 앞면 카드 */}
<div className="backface-hidden absolute inset-0 ...">
{/* 앞면 내용 */}
</div>
{/* 뒷면 카드 */}
<div className="backface-hidden absolute inset-0 rotate-y-180 ...">
{/* 뒷면 내용 */}
</div>
</div>
</div>
);
}
앞면에는 기본 정보를 보여줘요. 번호, 카테고리, 이미지, 이름 정도가 들어갑니다.
<div className="backface-hidden absolute inset-0 flex h-full w-full flex-col overflow-hidden rounded-3xl p-2 border border-white/20 bg-stone-900 text-white">
<div className="relative flex items-center justify-between gap-2 px-4 pt-4 pb-2">
<span className="text-sm font-semibold text-white">
#{String(animal.number).padStart(3, '0')}
</span>
<span className="text-sm font-semibold text-white">
{animal.category}
</span>
</div>
<div className="relative mt-2 w-full px-4">
<div className="aspect-square overflow-hidden rounded-2xl border border-white/20 bg-stone-800 relative shadow-[0_0_5px_rgba(0,229,255,1),0_0_10px_rgba(255,255,255,1)]">
{hasImage ? (
<Image
src={animal.image!}
alt={animal.name}
width={240}
height={240}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-6xl">
{emoji}
</div>
)}
</div>
</div>
<div className="relative mt-4 flex flex-1 flex-col gap-2 px-4 pb-4">
<h3 className="text-base font-bold text-white">{animal.name}</h3>
<div className="mt-auto flex items-center justify-between text-xs font-semibold">
<span className="text-white/70">설명 보기 ></span>
</div>
</div>
</div>
뒷면에는 상세 정보를 보여줘요. 태그, 설명, 통계 정보 등이 들어갑니다.
<div className="backface-hidden absolute inset-0 h-full w-full rotate-y-180 flex flex-col overflow-hidden rounded-3xl border border-white/20 bg-stone-900 text-white">
<div className="flex flex-col gap-3 p-4">
{hasDetail ? (
<>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-white/60">
#{String(animalDetail.number).padStart(3, '0')}
</span>
</div>
<h3 className="text-lg font-bold text-white">{animalDetail.name}</h3>
{/* 태그들 */}
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex items-center rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-[11px] font-medium text-white">
{animalDetail.category}
</span>
{/* ... */}
</div>
{/* 설명 및 통계 정보 */}
{/* ... */}
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
<div className="text-4xl">{emoji}</div>
<div>
<h3 className="text-lg font-bold text-white mb-2">{animal.name}</h3>
<p className="text-sm text-white/60">
상세 정보가 준비 중입니다.
</p>
</div>
</div>
)}
</div>
</div>
애니메이션이 어떻게 동작하는지 설명해볼게요.
기본 상태
rotateY(0deg) - 정면을 보고 있음rotate-y-180 클래스로 rotateY(180deg) - 이미 뒤집혀 있음backface-visibility: hidden으로 뒷면이 보이지 않음Hover 시
rotateY(180deg)로 회전0deg + 180deg = 180deg → 뒤집혀서 숨겨짐180deg + 180deg = 360deg = 0deg → 정면으로 돌아와서 보임부드러운 전환
transition-transform duration-700으로 700ms 동안 부드럽게 전환왜 이렇게 복잡하게 구현했을까요?
처음에는 단순히 앞면만 회전시키면 되지 않을까 생각했어요. 하지만 그렇게 하면 문제가 있었습니다.
문제점
해결 방법
각도 계산의 핵심
0deg → 부모가 180deg 회전 → 180deg (뒤집힘)180deg → 부모가 180deg 회전 → 360deg = 0deg (정면)이 원리를 이해하면 다른 3D 애니메이션도 쉽게 구현할 수 있을 거예요.
구현하면서 몇 가지 문제를 겪었어요. 같은 문제를 겪으실 분들을 위해 공유해볼게요.
처음에 카드의 높이가 0으로 나오는 문제가 있었어요. aspect-3/4를 사용했는데도 높이가 제대로 계산되지 않았습니다.
해결 방법:
aspect-3/4 클래스 추가h-full 클래스로 높이 명시<div className="group relative h-full aspect-3/4 perspective-1000">
{/* ... */}
</div>
데이터가 없을 때 뒷면이 아예 렌더링되지 않아서, 카드를 뒤집으면 빈 공간이 보였어요.
해결 방법
{hasDetail && ...}) 제거{hasDetail ? (
// 상세 정보 표시
) : (
// 기본 메시지 표시
)}
처음에는 애니메이션 속도가 너무 빨라서 부자연스러웠어요.
해결 방법
duration-700으로 애니메이션 시간을 700ms로 조정transition-transform으로 transform 속성만 애니메이션 적용 (성능 최적화)일부 브라우저에서 backface-visibility가 제대로 작동하지 않았어요.
해결 방법
-webkit-backface-visibility 접두사 추가.backface-hidden {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
애니메이션 성능을 최적화하기 위해 몇 가지 팁을 공유해볼게요.
transform 속성을 사용하면 GPU 가속을 받을 수 있어요. left, top 같은 속성보다 훨씬 부드럽습니다.
/* ✅ 좋은 예 */
transform: rotateY(180deg);
/* ❌ 나쁜 예 */
left: 100px;
왜 top, left는 나쁜 예일까요?
처음에는 top이나 left 속성으로 애니메이션을 구현하려고 했는데, 성능 문제가 있었어요. 이유를 설명해볼게요.
레이아웃 재계산 (Reflow) 발생
top, left 같은 속성을 변경하면 브라우저가 레이아웃을 다시 계산해야 해요GPU 가속의 차이
transform 속성은 GPU에서 처리되어요실제 차이를 느낄 수 있는 경우
top, left를 사용하면 애니메이션이 버벅일 수 있어요transform을 사용하면 수십 개의 카드가 동시에 애니메이션되어도 부드럽게 동작합니다제가 실제로 테스트해봤는데, 카드 20개 정도에서 left 속성을 사용하면 프레임이 떨어지는 걸 확인할 수 있었어요. transform으로 바꾸니 훨씬 부드러워졌습니다.
애니메이션이 많은 경우 will-change 속성을 사용하면 브라우저가 미리 최적화할 수 있어요. 하지만 과도하게 사용하면 오히려 성능이 떨어질 수 있으니 주의하세요.
.preserve-3d {
transform-style: preserve-3d;
will-change: transform; /* 선택적 사용 */
}
언제 사용해야 할까요?
will-change는 브라우저에게 "이 요소가 곧 변환될 거예요"라고 미리 알려주는 속성이에요. 브라우저가 미리 GPU 레이어를 준비해두면 애니메이션이 더 부드러워질 수 있어요.
사용해야 하는 경우
사용하지 말아야 하는 경우
주의사항
will-change는 메모리를 더 사용해요. GPU 레이어를 미리 생성하기 때문입니다will-change: auto로 되돌리는 게 좋아요제 프로젝트에서는 카드가 많지 않아서 will-change를 사용하지 않았어요. 필요하다고 판단될 때만 추가하는 게 좋을 것 같습니다.
모든 속성을 애니메이션하지 말고, 필요한 속성만 지정하는 게 좋아요.
/* ✅ 좋은 예 */
transition: transform 700ms;
/* ❌ 나쁜 예 */
transition: all 700ms;
왜 all을 사용하면 안 될까요?
처음에는 간단하게 transition: all을 사용했는데, 예상치 못한 문제가 생겼어요.
의도하지 않은 속성까지 애니메이션됨
all을 사용하면 transform뿐만 아니라 color, background, border 등 모든 속성이 애니메이션 대상이 됩니다성능 문제
width, height, margin, padding 같은 레이아웃 속성까지 애니메이션되면 레이아웃 재계산이 발생합니다명시적으로 지정하는 게 좋은 이유
실제로 all을 사용했을 때와 transform만 지정했을 때의 차이를 확인해봤는데, 특히 모바일에서 성능 차이가 확실히 느껴졌어요.
3D 카드 뒤집기 애니메이션을 구현하면서 CSS의 3D Transform 속성들을 제대로 이해하게 되었어요. 처음에는 복잡해 보였지만, 핵심 개념만 이해하면 생각보다 간단하게 구현할 수 있었습니다.
이번 경험을 통해 배운 점
perspective, transform-style, backface-visibility의 역할혹시 비슷한 기능을 구현하실 때 이 글이 도움이 되면 좋겠어요. 궁금한 점이 있으시면 댓글로 남겨주세요!