GODRICH

CSS 3D 큐브 캐러셀 틸트 9종 — 한 줄 복붙으로

css 3d 큐브 회전, 원형으로 도는 캐러셀, 커서를 올리면 기우는 카드까지 — perspective 와 preserve-3d 로만 만든 입체 CSS 효과 아홉 개를 모았습니다. 아홉 개 전부 transform 만 움직이고, JS 는 한 줄도 쓰지 않습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 입체감을 만드는 세 속성 — perspective(카메라 거리) · transform-style: preserve-3d(자식을 평면으로 안 뭉갬) · backface-visibility(뒷면 숨김) — 을 다루는 순서로 놓았습니다. 앞의 셋(01~03)은 이 세 속성을 가장 단순한 형태로 보여주고, 중간 셋(04~06)은 여러 요소가 겹치는 구조로, 뒤의 셋(07~09)은 무대 전체를 3D 로 세우는 데까지 나갑니다. 항목마다 코드는 핵심 부분만 실었고, 전체 파일은 zip 에 있습니다.

01큐브 회전

4면짜리 큐브가 부모의 perspective 안에서 Y축으로 계속 돕니다. 로고나 짧은 문구를 면마다 다르게 넣어 순서대로 보여줄 때 씁니다.

perspectivetransform-style: preserve-3dbackface-visibility
.cube {
  transform-style: preserve-3d;
  animation: spin $duration $easing infinite;
}
.cube__face {
  position: absolute; inset: 0;
  backface-visibility: hidden;
}
.cube__face--front { transform: translateZ($half); }
.cube__face--right { transform: rotateY(90deg) translateZ($half); }
.cube__face--back  { transform: rotateY(180deg) translateZ($half); }
.cube__face--left  { transform: rotateY(-90deg) translateZ($half); }
@keyframes spin {
  from { transform: rotateY(0deg); }
  to   { transform: rotateY(360deg); }
}

023D 캐러셀

카드 다섯 장을 원 둘레에 세워 놓고 부모 하나만 계속 돌립니다. translateX 로 미는 마퀴와 달리 회전만으로 도는 로고 쇼케이스에 씁니다.

rotateY 배치transform-style: preserve-3d부모만 회전
.ring {
  transform-style: preserve-3d;
  animation: orbit $duration $easing infinite;
}
// 카드 5장을 72deg 간격으로 원 둘레에 세운다 — Sass 에서 먼저 계산해야
// rotateY(0 * 72deg) 같은 무효한 CSS 식이 안 나온다
@for $i from 1 through 5 {
  .ring__card:nth-child(#{$i}) {
    transform: rotateY(#{($i - 1) * $step}) translateZ($radius);
  }
}
@keyframes orbit {
  from { transform: rotateY(0deg); }
  to   { transform: rotateY(360deg); }
}

03호버 틸트

커서를 올리면 카드가 고정된 각도로 살짝 기웁니다. 마우스 좌표를 매 프레임 읽는 JS 없이, :hover 값 하나만 트랜지션합니다.

perspective:hover 고정 각도JS 0줄
.frame { perspective: 600px; }
.tilt {
  transform: rotateX(0deg) rotateY(0deg);
  transition: transform $duration $easing;
}
.tilt:hover,
.tilt:focus-visible,
.tilt:active {
  transform: rotateX(10deg) rotateY(-14deg);
}
@media (hover: none) {
  .tilt:active { transform: rotateX(10deg) rotateY(-14deg); }
}

04책 펼침 효과

표지가 왼쪽 변을 경첩 삼아 열리며 안쪽 페이지를 드러냅니다. 온보딩 가이드나 카탈로그 표지처럼 "펼쳐 보인다"는 느낌이 필요한 곳에 씁니다.

transform-origin: leftbackface-visibility경첩 회전
.book__cover {
  transform-style: preserve-3d;
  transform-origin: left center;
  animation: open $duration $easing infinite;
}
.book__face {
  position: absolute; inset: 0;
  backface-visibility: hidden;
}
.book__face--front { background: $color; }
.book__face--back  { background: darken($color, 14%); transform: rotateY(180deg); }
@keyframes open {
  0%, 15%  { transform: rotateY(0deg); }
  55%      { transform: rotateY(-150deg); }
  85%, 100% { transform: rotateY(0deg); }
}

05플립 갤러리

격자 타일 네 개가 0.15초씩 시차를 두고 차례로 뒤집혀 카테고리를 드러냅니다. 미디어 갤러리나 대시보드 위젯을 소개하는 격자에 씁니다.

backface-visibilityanimation-delay 시차grid
.tile__inner {
  transform-style: preserve-3d;
}
.tile__face {
  position: absolute; inset: 0;
  backface-visibility: hidden;
}
.tile__face--back { transform: rotateY(180deg); }
.tile:nth-child(1) .tile__inner { animation: flip $duration $easing infinite; }
.tile:nth-child(2) .tile__inner { animation: flip $duration $easing infinite; animation-delay: .15s; }
@keyframes flip {
  0%, 15%  { transform: rotateY(0deg); }
  50%      { transform: rotateY(180deg); }
  85%, 100% { transform: rotateY(360deg); }
}

06커버플로우

카드 다섯 장이 같은 keyframes 를 음수 delay 로 어긋나게 돌아, 가운데는 크고 정면으로 양옆은 작고 기울어진 채 흐릅니다. 앨범이나 포트폴리오 커버를 넘기는 슬라이더에 씁니다.

rotateY + translateZ음수 animation-delayJS 0줄
.flow__card {
  animation: flow $duration $easing infinite;
  animation-delay: calc(var(--i) * -0.4s);
}
@keyframes flow {
  0%   { transform: translateX(-130px) translateZ(-90px) rotateY(45deg) scale(.6); }
  40%  { transform: translateX(0) translateZ(0) rotateY(0deg) scale(1); }
  80%  { transform: translateX(130px) translateZ(-90px) rotateY(-45deg) scale(.6); }
  100% { transform: translateX(-130px) translateZ(-90px) rotateY(45deg) scale(.6); }
}

07회전 스테이지

타원 바닥 위에서 오브젝트가 5초에 한 바퀴 천천히 돕니다. 신제품을 360도로 보여주는 이커머스 상세 페이지에 씁니다.

perspectivebackface-visibility타원 바닥 rotateX
.turntable__floor {
  border-radius: 50%;
  transform: translateX(-50%) rotateX(70deg);
}
.turntable__object {
  transform-style: preserve-3d;
  animation: turn $duration $easing infinite;
}
.turntable__face {
  position: absolute; inset: 0;
  backface-visibility: hidden;
}
.turntable__face--back { transform: rotateY(180deg); }
@keyframes turn {
  from { transform: rotateY(0deg); }
  to   { transform: rotateY(360deg); }
}

08입체 텍스트

같은 색을 한 단계씩 어둡게 여덟 겹 겹친 text-shadow 로 글자에 두께를 주고, 아래쪽 변을 축으로 기울었다 돌아옵니다. 랜딩 히어로 타이틀이나 이벤트 배너 제목에 씁니다.

다중 text-shadowperspectiverotateX
@for $i from 1 through 8 {
  $out: list.append($out, #{$i}px #{$i}px darken($color, $i * 4%), comma);
}
.sign {
  text-shadow: $out;
  transform-origin: center bottom;
  animation: tilt $duration $easing infinite;
}
@keyframes tilt {
  0%, 20%  { transform: rotateX(0deg); }
  55%      { transform: rotateX(-34deg); }
  100%     { transform: rotateX(0deg); }
}

093D 룸 스크롤

정면 벽이 없는 방을 9초에 한 바퀴 돌려, 짧은 perspective 로 카메라가 방 안에 들어와 있는 것처럼 둘러보게 합니다. 포트폴리오의 3D 내비게이션 섹션에 씁니다.

짧은 perspectivetransform-style: preserve-3dbackface-visibility
.room-scene { perspective: 260px; }
.room {
  transform-style: preserve-3d;
  animation: look-around $duration $easing infinite;
}
.room__wall {
  position: absolute; inset: 0;
  backface-visibility: hidden;
}
.room__wall--back  { transform: translateZ(-$half); }
.room__wall--left  { transform: rotateY(90deg) translateZ(-$half); }
.room__wall--right { transform: rotateY(-90deg) translateZ(-$half); }
@keyframes look-around {
  from { transform: rotateY(-35deg); }
  to   { transform: rotateY(325deg); }
}

어디서 깨지는가 — 함정 하나

아홉 개 전부 transform-style: preserve-3d 를 회전하는 요소 자신에게 걸어 두는 데는 이유가 있습니다. backface-visibility: hidden 은 그 자체로는 아무 힘이 없고, 조상 중 하나라도 preserve-3d 가 없으면 3D 컨텍스트가 거기서 끊겨 자식이 평면으로 눌려버립니다. 04 책 펼침 효과를 처음 만들 때 앞뒤 .face 요소에만 preserve-3d 를 걸고 정작 회전을 거는 .cover 자체에는 빠뜨렸더니, 안쪽 면이 뒤집히지 않고 그냥 사라졌습니다. 02 3D 캐러셀에서는 또 다른 함정을 겪었습니다. 카드마다 다른 각도를 주려고 rotateY(#{$i} * #{$step}deg) 처럼 SCSS 보간 문자열을 그대로 이어 붙였더니 rotateY(0 * 72deg) 같은 CSS 가 나왔는데, 브라우저는 calc() 없이는 이 곱셈을 읽지 못해 카드가 전부 한자리에 겹쳐 버렸습니다. 값은 Sass 쪽에서 먼저 곱해 완성된 각도 리터럴로 넘겨야 합니다. 06 커버플로우에서는 z-index 를 keyframes 안에 넣었는데, 이 속성은 애니메이션 가능한 속성이 아니라서 중간값으로 보간되지 않고 각 키프레임 경계에서 순간적으로 바뀝니다. 부드럽게 겹치는 것처럼 보이는 건 사실 opacityscale 이 동시에 바뀌어서 눈이 속는 것이고, z-index 자체는 딱딱 전환됩니다. 이 값들이 이미 정리된 아홉 개 파일의 압축 비밀번호는 bfmwjndp 이고, 아래 버튼으로 받을 수 있습니다.

접근성

시스템에서 "동작 줄이기"를 켠 사용자를 위해 아홉 개 전부 prefers-reduced-motion: reduce 를 처리합니다. 회전이나 틸트를 끄되, 각자 정해 둔 각도로 고정해 내용이 갑자기 사라지지 않게 했습니다. 03 호버 틸트만 예외로 transform 자체를 none 으로 되돌립니다 — 눌렀을 때 반응이 없다는 오해를 막기 위해서입니다. perspectivetransform-style, backface-visibility 는 각각 perspective · transform-style · backface-visibility MDN 문서에서 지원 범위를 확인할 수 있습니다.

@media (prefers-reduced-motion: reduce) {
  .cube              { animation: none; transform: rotateY(-28deg); }
  .ring              { animation: none; transform: rotateY(0deg); }
  .tilt, .tilt.is-demo { animation: none; transition: none; transform: none; }
  .book__cover       { animation: none; transform: rotateY(-150deg); }
  .gallery.is-demo .tile__inner { animation: none; transform: rotateY(180deg); }
  .flow__card        { animation: none; transform: translateX(0) translateZ(0) rotateY(0deg) scale(1); opacity: 1; }
  .turntable__object { animation: none; transform: rotateY(-20deg); }
  .sign              { animation: none; transform: rotateX(-20deg); }
  .room              { animation: none; transform: rotateY(-15deg); }
}

3D 효과 말고 다른 CSS 애니메이션은 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.

FAQ

perspective 값은 얼마로 정해야 하나요?

작을수록 카메라가 가까이 다가간 것처럼 왜곡이 커지고, 클수록 밋밋해집니다. 09 3D 룸 스크롤처럼 안에 들어와 있는 느낌이 필요하면 260px 정도로 짧게, 01 큐브 회전처럼 물체 하나를 보여줄 땐 600~800px 정도가 무난합니다. 숫자 하나를 정답으로 외우기보다 항목 아홉 개의 값을 바꿔 가며 눈으로 비교해 보는 것이 좋습니다.

회전이 너무 빨라요.

전부 $duration 변수 하나로 속도를 정합니다. 01 큐브 회전은 2초, 07 회전 스테이지는 5초, 09 3D 룸 스크롤은 9초로 서로 다른데, 요소가 클수록·화면을 넓게 차지할수록 느리게 돌려야 어지럽지 않습니다.

React에서는 회전 배치를 어떻게 넘기나요?

zip의 react/ 폴더는 02·06처럼 카드가 여러 장인 항목을 배열로 map 해 렌더합니다. 카드마다 다른 각도(02)나 지연 시간(06)은 SCSS 변수가 아니라 인라인 style 로 넘기고, 나머지 공통 변수(duration·easing·color) 세 개만 props 로 받습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.