3D CSS Perspective
처음으로 카드 플립이나 회전하는 UI를 만들어 보려고 CSS의 3D 변환 속성들을 들춰봤다. 막상 써보니 perspective 하나만으로는 의도한 입체감이 나오지 않았다. 자식 요소들이 평평하게 눌려 보이거나, 뒷면이 그대로 비춰서 어색했다. 그때그때 MDN을 뒤져가며 보던 속성들을 한 자리에 모았다.
perspective — 시점 거리부터 잡는다
perspective는 화면을 보는 사람이 z=0 평면에서 얼마나 떨어져 있는지를 정하는 속성이다. 값이 작을수록 카메라가 가까이 붙어 있는 셈이라 입체감이 과장되고, 값이 클수록 멀리서 보는 듯해 변화가 미묘해진다.
.scene {
perspective: 800px;
}여기서 perspective: 800px라는 건, 자식 요소가 z 방향으로 800px 떨어진 가상의 카메라에서 비춰진다는 뜻이다. MDN은 "Large values of perspective cause a small transformation; small values of perspective cause a large transformation"이라 적어두는데, 풀어보면 값이 클수록 효과가 약해지고 작을수록 더 또렷해진다.
몇 가지 짚어둘 점.
- 자식에 적용된다.
perspective를 건 요소 자체가 변형되는 게 아니라, 그 자식 중에 3D 변환이 걸린 것이 영향을 받는다. - 음수는 syntax error. 1px보다 작은 값을 적으면 자동으로 1px로 올라간다.
- 초기값은
none. 기본 상태에서는 어떤 3D 원근감도 적용되지 않는다. - 새 stacking context가 만들어진다.
none이 아닌 값을 주면 그 요소는 새 stacking context를 형성하고, 그 안의position: fixed도 이 요소를 기준으로 잡힌다.
transform-style — 자식을 3D 공간에 살릴지 평면에 누일지
perspective만 잡아두고 자식에 rotate3d나 translateZ를 걸어도 종종 평평하게 보이는 경우가 있다. 그 원인이 transform-style이다.
.scene {
perspective: 800px;
transform-style: preserve-3d;
}transform-style: flat(기본값) — 자식들이 부모와 같은 평면에 깔린다. 자식의 3D 좌표(z값)는 시각적으로 무시된다.transform-style: preserve-3d— 자식들이 각자의 3D 좌표를 그대로 유지한 채 공간에 배치된다.
문서에는 이렇게 적혀 있다. "Indicates that the children of the element should be positioned in the 3D-space." 자식들의 깊이를 살리려면 부모에 preserve-3d를 명시해야 한다.
한 가지 더 — transform-style은 상속되지 않는다. 중간 레벨에 또 다른 컨테이너가 끼면 거기에도 preserve-3d를 따로 줘야 그 안쪽 자식의 깊이가 살아남는다.
preserve-3d를 무력화하는 속성들
preserve-3d를 줬는데도 자식이 납작해지는 일이 있었다. 처음엔 코드 어디가 잘못됐나 한참 뒤졌는데, 알고 보니 다른 CSS 속성이 preserve-3d를 강제로 flat으로 바꿔놓고 있었다.
MDN은 이렇게 적어둔다.
require the user agent to create a flattened representation of the descendant elements before they can be applied, and therefore force the element to have a used value of
transform-style: flat, even whenpreserve-3dis specified.
preserve-3d를 명시했더라도 아래 같은 대표적인 grouping property가 같이 걸리면 used value가 flat으로 떨어진다.
| 속성 | 무력화되는 값 |
|---|---|
overflow | visible 또는 clip 이외 (예: hidden, auto, scroll) |
opacity | 1 미만 |
filter | none 이외 |
clip-path | none 이외 |
mask-image | none 이외 |
mix-blend-mode | normal 이외 |
contain | paint 또는 paint containment를 유발하는 값 |
3D 씬을 잡는 부모 컨테이너에 무심코 overflow: hidden을 걸면 깊이가 다 죽는다. 이걸 모르고 한참 헤맸다.
perspective-origin — 카메라가 어디서 보는지를 정한다
perspective만 주면 카메라가 요소의 한가운데(50% 50%)를 향한다. 다른 위치에서 비스듬히 내려다보는 느낌이 필요하면 perspective-origin으로 카메라가 보는 위치를 옮기면 된다.
.scene {
perspective: 800px;
perspective-origin: top right;
}top, right, center 같은 키워드를 쓸 수도 있고, <length>나 <percentage>로 정밀하게 잡을 수도 있다. 카드가 왼쪽 위에서 내려다보이는 느낌이 필요하면 perspective-origin: top left처럼 옮긴다.
backface-visibility — 뒤집힌 면을 가린다
카드 플립 애니메이션을 만들 때 가장 헷갈렸던 부분이다. 카드를 180도 돌렸는데 뒷면에 거울에 비친 듯한 앞면이 보였다.
.card-face {
backface-visibility: hidden;
}backface-visibility: hidden을 주면 요소가 시청자 반대편으로 돌아갔을 때 보이지 않는다. 기본값이 visible이라 명시하지 않으면 뒷면이 그대로 비친다.
앞면·뒷면을 각각 다른 요소로 만들고 둘 다 backface-visibility: hidden을 준 다음, 뒷면만 미리 180도 회전시켜 두면 카드 한쪽이 자연스럽게 뒤집힌다.
perspective 속성 vs perspective() 함수
같은 이름인데 동작이 다르다. 처음 봤을 때는 거의 같은 거 아닌가 싶었다.
perspective속성 — 부모에 건다. 그 자식들이 같은 카메라를 함께 쓴다. 여러 자식이 같은 3D 씬 안에 있는 것처럼 보인다.perspective()변환 함수 — 개별 요소의transform에 들어간다. 그 요소만 자기 카메라를 갖는다. 형제끼리 깊이가 따로 잡힌다.
/* 부모에 건다 — 자식들이 같은 카메라를 공유 */
.scene {
perspective: 800px;
}
/* 요소 자신의 transform에 박는다 — 이 요소만의 원근 */
.card {
transform: perspective(800px) rotateY(45deg);
}카드들을 한 씬 안에 묶어 같은 카메라로 비춰주려면 부모에 perspective, 각 카드가 독립적으로 단독 변형만 보여주면 되면 perspective() 함수. 의도에 따라 골라 썼다.
카드 한 장으로 다 엮어보면
지금까지의 속성을 다 합쳐 보면 회전하는 카드 한 장은 이렇게 잡힌다.
.scene {
perspective: 800px;
perspective-origin: 50% 50%;
}
.card {
transform-style: preserve-3d;
transition: transform 0.6s;
}
.card:hover {
transform: rotateY(180deg);
}
.card-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.card-face.back {
transform: rotateY(180deg);
}부모 .scene이 카메라를 들고, .card가 3D 공간을 유지한 채 회전하며, 두 면이 backface-visibility로 한쪽씩만 보이게 닫힌다. 가장 시간을 잡아먹은 건 코드보다 overflow: hidden 한 줄이었다.