要素に rotateY(45deg) を指定してみたのに、立体的に回らずただ横に潰れただけ——3D 表現をはじめて書いたときによくある躓きです。CSS で奥行きを出すには、回す要素そのものではなくその親に「空間」を用意する必要があります。この記事では perspective と transform-style を中心に、カードがめくれる 3D 表現までの作り方を解説します。translate や scale といった 2D の基本は別記事で扱っているので、ここは 3D 空間の作り方に絞ります。
目次
rotateY だけでは立体に見えない理由
ブラウザーは既定では、要素を平面に貼り付けたまま変形させます。この状態で Y 軸まわりに回しても、返ってくるのは「横方向に縮んだ長方形」です。手前に来た辺が大きく、奥に行った辺が小さく描かれてこそ人は立体だと感じますが、そのためには見ている人がどこにいるのかという情報が要ります。CSS はそれを勝手に決めてくれないので、こちらから perspective で教えてあげる、というのが 3D 表現の出発点です。
ポイントは、perspective を書く場所です。回す要素ではなく、その親要素に指定します。親が「奥行きのある舞台」になり、その中にいる子要素の変形が遠近法つきで描かれる、というイメージです。
<!-- 親(舞台)と子(回す要素)を分けるのが基本の形 -->
<div class="scene">
<div class="box">3D</div>
</div>
/* 親に書く。これで .scene の中が奥行きのある空間になる */
.scene {
perspective: 600px;
}
/* 子は普通に回すだけ。遠近感は親が与えてくれる */
.box {
width: 160px;
height: 160px;
transform: rotateY(45deg);
}
この perspective: 600px を外すと、同じ rotateY(45deg) でも要素はただ横に縮むだけになります。translateZ() にいたっては、perspective がなければ見た目がまったく変わりません。手前に出しても奥に引っ込めても、遠近法がなければ大きさが変わらないからです。
親に書く perspective と、子に書く perspective()
紛らわしいのですが、perspective にはプロパティと関数の2つの顔があります。プロパティ版は親に書き、その子要素すべてに同じ遠近感(同じ消失点)を与えます。関数版は transform: perspective(600px) rotateY(45deg) のように子の transform の中に書き、その要素ひとつだけに遠近感を与えます。
要素が1つだけなら関数版が手軽です。ただし複数のカードを横に並べたときは違いが出ます。プロパティ版なら全員が同じ一点から見られている——つまり左端のカードは少し左から、右端のカードは少し右から見た形になりますが、関数版では各要素がそれぞれ真正面から見られた形になり、並べたときの統一感が失われます。
関数版を使うときにもうひとつ気をつけたいのが書く順番です。transform の関数は左から順に適用されるため、perspective() は必ず先頭に書きます。rotateY(45deg) perspective(600px) のように後ろに置くと、意図した遠近感になりません。
/* 親にまとめて指定する書き方(複数の子で消失点を共有できる) */
.scene {
perspective: 600px;
}
.scene .box {
transform: rotateY(45deg);
}
/* 要素ひとつで完結させる書き方。perspective() は必ず先頭に */
.single-box {
transform: perspective(600px) rotateY(45deg);
}
3D 表現に登場する役者と、それを書く場所
3D の CSS でつまずく原因の多くは、プロパティを親に書くのか子に書くのかを取り違えていることです。この記事で扱うものを一度整理しておきます。
| プロパティ・関数 | 役割 | 書く場所 |
|---|---|---|
perspective | 見る人から要素までの距離を決め、遠近感を生む | 立体に見せたい要素の親 |
perspective() | 同じ遠近感を、その要素ひとつだけに与える関数 | 要素の transform の先頭 |
perspective-origin | 消失点の位置=どこから覗いているかを決める | perspective を書いた親 |
transform-style: preserve-3d | 子要素を平面に潰さず、3D 空間に立たせたまま描く | 3D の子を持つ要素(=回す本人) |
backface-visibility: hidden | 裏を向いた面を見えなくする | 面になっている各要素 |
rotateX() / rotateY() / rotateZ() | 横軸・縦軸・画面に垂直な軸まわりの回転 | 動かす要素の transform |
translateZ() | 手前・奥への移動(perspective がないと見た目が変わらない) | 動かす要素の transform |
ざっくり言えば、空間を用意する係が親(perspective と perspective-origin)、その空間で動く係が子(rotate* や translateZ)、そして transform-style は「子をさらに 3D のまま扱うか」を決めるつなぎ役です。
perspective は値が小さいほどパースが強い
ここが直感と逆になりやすいところです。perspective の値は見ている人から要素までの距離なので、値を小さくするほど顔を近づけたことになり、パース(遠近の歪み)が強く出ます。逆に値を大きくすると遠くから望遠レンズで眺めた状態に近づき、変形はおとなしくなります。「数値を上げれば 3D 感が増す」と思って perspective: 3000px と書き、ほとんど効かずに悩む、というのがありがちなパターンです。
同じ rotateY(24deg) rotateX(-12deg) を、perspective の値だけ変えた3枚のカードで見比べてみてください。マウスを乗せる(スマートフォンならタップする)と傾きます。
<p class="hint">カードにマウスを乗せる(スマホならタップする)と傾きます</p>
<div class="grid">
<figure class="cell">
<div class="scene scene--near"><button class="card" type="button">200px</button></div>
<figcaption>perspective: 200px</figcaption>
</figure>
<figure class="cell">
<div class="scene scene--mid"><button class="card" type="button">600px</button></div>
<figcaption>perspective: 600px</figcaption>
</figure>
<figure class="cell">
<div class="scene scene--far"><button class="card" type="button">2000px</button></div>
<figcaption>perspective: 2000px</figcaption>
</figure>
</div>
body {
font-family: sans-serif;
margin: 12px;
}
.hint {
margin: 0 0 12px;
font-size: 12px;
color: #555;
text-align: center;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.cell { margin: 0; }
.cell figcaption {
margin-top: 8px;
font-size: 12px;
color: #555;
text-align: center;
}
/* perspective は「傾ける要素」ではなく、その親に書く。
値は見ている人から要素までの距離。小さいほど近づくのでパースが強くなる */
.scene--near { perspective: 200px; }
.scene--mid { perspective: 600px; }
.scene--far { perspective: 2000px; }
.card {
display: block;
width: 100%;
height: 120px;
border: 0;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-family: inherit;
cursor: pointer;
background: linear-gradient(135deg, #4a90d9, #8e44ad);
transition: transform 0.4s;
}
/* ホバー・タップ・キーボードフォーカスのどれでも傾く */
.card:hover,
.card:focus-visible,
.card:active {
transform: rotateY(24deg) rotateX(-12deg);
}
200px はかなり誇張された見え方で、数字が書かれた面がぐっと台形に歪みます。2000px はほぼ平行投影に近く、傾いていることは分かるものの立体感は控えめです。Web サイトで使うカードや小さなパネルなら、600px から 1200px あたりが自然に見えることが多いので、まずはこの範囲から調整すると決めやすくなります。
また、傾きは transform 側でも作れるので、強い立体感が欲しいときに perspective を無闇に小さくしないのもコツです。値を小さくしすぎると、遠い側の辺が極端に縮んで安っぽい見た目になります。角度を数度足すほうが自然に見えることがほとんどです。
perspective-origin で覗く位置をずらす
perspective-origin は消失点、つまりどこから覗いているかを決めるプロパティです。初期値は 50% 50% で、要素の真正面から見ている状態になります。perspective-origin: 0% 50% にすると左端から、50% 0% にすると上から見下ろしたような描かれ方に変わります。perspective と同じく、これも親に書きます。
.scene {
perspective: 800px;
perspective-origin: 50% 0%; /* 上から見下ろしたような遠近感になる */
}
カードがめくれる表現を作る
ここまでの部品を組み合わせると、定番のフリップカードが作れます。考え方はシンプルで、表と裏の2枚を同じ位置に重ね、裏面だけあらかじめ180度回しておき、まとめて180度回すというだけです。組み立ては次の4ステップになります。
- いちばん外側の親に
perspectiveを指定して舞台を作る - 回す本人(表裏を包むラッパー)に
transform-style: preserve-3dを指定する - 表と裏の面を
position: absoluteで重ね、両方にbackface-visibility: hiddenを指定する - 裏面だけ
transform: rotateY(180deg)で反転させておく
2 の transform-style: preserve-3d が要になります。既定値は flat で、これは「子要素は親の平面に貼り付けて描く」という意味です。flat のままだと、裏面を rotateY(180deg) しても 3D 空間で向こう側を向いてはくれず、ただの鏡文字が表面に重なって終わります。preserve-3d を指定してはじめて、表と裏が同じ空間で背中合わせになります。
3 の backface-visibility: hidden は、要素が裏を向いたときに見えなくする指定です。これがないと、めくっている途中で反転した表面の文字が透けて見えてしまいます。次のデモではカードをクリック(タップ)すると is-flipped クラスが付き、transition に沿ってなめらかに回転します。
<div class="scene">
<div class="card" id="card">
<div class="face face--front">表</div>
<div class="face face--back">裏</div>
</div>
</div>
<p class="hint">カードをクリック(タップ)するとめくれます</p>
body {
font-family: sans-serif;
margin: 16px;
}
/* 1. 舞台となる親に perspective を置く */
.scene {
width: 180px;
height: 240px;
margin: 0 auto;
perspective: 900px;
}
/* 2. 回す本人に preserve-3d。これがないと子の面が平らに潰れる */
.card {
position: relative;
width: 100%;
height: 100%;
cursor: pointer;
transform-style: preserve-3d;
transition: transform 0.7s;
}
.card.is-flipped {
transform: rotateY(180deg);
}
/* 3. 表と裏を同じ位置に重ね、裏返った面は隠す */
.face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
color: #fff;
font-size: 28px;
font-weight: bold;
backface-visibility: hidden;
}
.face--front {
background: linear-gradient(135deg, #4a90d9, #8e44ad);
}
/* 4. 裏面はあらかじめ180度回しておく */
.face--back {
background: linear-gradient(135deg, #f6c343, #e8542f);
transform: rotateY(180deg);
}
.hint {
margin-top: 16px;
font-size: 12px;
color: #555;
text-align: center;
}
const card = document.getElementById('card');
// クリックのたびに is-flipped を付け外しして裏表を切り替える
card.addEventListener('click', () => {
card.classList.toggle('is-flipped');
});
クリックの切り替えは JavaScript で書きましたが、チェックボックスと :has() を使えば CSS だけでも同じことができます。<label> で包んでいるので、カードのどこを押してもチェックが切り替わります。
<label class="scene">
<input type="checkbox" class="toggle">
<div class="card">
<div class="face face--front">表</div>
<div class="face face--back">裏</div>
</div>
</label>
/* チェックボックスは見えなくするが、フォーカスできるよう残しておく */
.toggle {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
/* チェックされている間だけカードを裏返す */
.scene:has(.toggle:checked) .card {
transform: rotateY(180deg);
}
ホバーでめくりたい場合は .scene:hover .card にすれば済みますが、タッチ端末ではホバーが使えません。クリックやチェックボックスで切り替えられるようにしておくと、スマートフォンでも同じように動きます。
3D に見えないときに疑うところ
「書いたのに平らなまま」という症状は、原因がだいたい決まっています。上から順に確かめていくと早く見つかります。
親に perspective がない
もっとも多いのがこれです。rotateY() や translateZ() は書いてあるのに、遠近感を与える perspective がどこにもない状態では、ブラウザーは要素を平面のまま変形させます。開発者ツールで回している要素の親をたどり、perspective が効いているかを確認してください。ひとつの要素だけで済ませたいなら、transform: perspective(600px) rotateY(45deg) と関数版を使う手もあります。
preserve-3d を付ける要素を間違えている
transform-style: preserve-3d は、3D のまま扱いたい子を持つ要素に指定します。フリップカードなら、面そのものではなく表裏を包むラッパー(回転する本人)に書きます。面の側に書いても、面はさらに子を持っていないので何も起きません。
もうひとつ見落としやすいのが、間に挟まった要素です。3D 空間は親から子へ自動的に引き継がれるわけではなく、途中の要素が flat(初期値)のままだと、そこで一度平面に潰されてしまいます。ラッパーを増やしたら立体に見えなくなった、というときは、追加した要素にも preserve-3d が必要かを確認してください。
overflow や filter が preserve-3d を打ち消している
正しい要素に preserve-3d を書いたのに平らになる場合は、同じ要素に付いている別のプロパティが原因かもしれません。仕様上、いくつかのプロパティは「子要素をいったん1枚の絵にまとめてから効果を適用する」必要があるため、指定されていると transform-style の使用値が flat に上書きされます。
代表的なのは overflow(visible 以外)、opacity(1 未満)、filter(none 以外)、clip-path、mask-image、mix-blend-mode(normal 以外)、isolation: isolate、contain: paint です。カードのはみ出しを隠そうとして overflow: hidden を足した、影を柔らかくしようと filter: drop-shadow() を足した——といった何気ない一行で立体が消えます。対処としては、これらの指定を preserve-3d の要素から別の要素(外側の親や内側の子)へ移してください。
裏面が透けたり、文字が鏡文字になる
めくった先に前の面がうっすら重なって見えるなら、backface-visibility: hidden の付け忘れです。これは面それぞれに指定するもので、親にまとめて書いても子には適用されません。表と裏の両方に指定してください。
裏返した先の文字が左右逆になっている場合は、裏面に transform: rotateY(180deg) を指定し忘れています。カード全体を180度回すと裏面も一緒に反転するので、裏面はあらかじめ反対向きに回しておいて、ちょうど正位置になるようにします。
まとめ
CSS の 3D 表現は、役割の違う指定を正しい場所に置けるかどうかでほとんど決まります。奥行きのある空間を作る perspective は親に、その空間で動く rotateX() や translateZ() は子に、そして子をさらに 3D のまま扱いたいときだけ transform-style: preserve-3d を回す本人に指定します。
perspective は値が小さいほどパースが強くなること、overflow や filter が preserve-3d を無効化してしまうことの2つを覚えておけば、多くの「立体に見えない」は自力で解決できます。まずはホバーで少し傾くカードから試して、慣れてきたらフリップカードへ広げてみてください。