デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
表と裏の 2 面を同じ位置に重ね、両方に backface-visibility の hidden を付けて、裏面だけをはじめから 180° 回しておきます。2 面を包む要素を Y 軸で 0° から 180° まで回すと、表が向こうへ隠れ、裏が手前に出てきます。
回転は 0.8 秒の power3.inOut で、止めたタイムライン 1 本にまとめ、カーソルが重なったら play()、離れたら reverse() します。途中で離れても、その角度から引き返します。奥行き(perspective)は回らない外枠に 1200px で付け、値を小さくすると遠近が強くなります。
カーソルは、回る要素ではなく、回らない外枠で拾います。回る要素で拾うと、真横を向いた瞬間に幅がなくなって離れたことになり、表と裏をちらちらと行き来します。
裏面は、表を向いているあいだも visibility や aria-hidden で隠しません。隠すと、裏面の文やリンクが読み上げと Tab の移動から外れます。フォーカスがカードの中に入ったら裏返し、外へ出たら表に戻すと、キーボードでも裏面を読めます。指で触る端末にはホバーが無いので、タップで裏返します。
縦に回すなら、回す軸を X にして、裏面も X 軸で 180° 回しておきます。チーム紹介、料金プラン、単語カード、商品の表と詳細に向いています。
コード
<section class="team" aria-labelledby="team-title">
<h2 class="team__title" id="team-title">メンバー紹介</h2>
<p class="team__lead">カードにカーソルを合わせてみてください。</p>
<ul class="team__list">
<!-- 回らない外枠。ホバーとフォーカスを拾い、奥行き(perspective)を持つ。--tint は写真が届くまでの地の色 -->
<li class="flip-card" style="--tint: #b27771">
<div class="flip-card__inner">
<div class="flip-card__face flip-card__front">
<img class="flip-card__photo" src="/img/team-takahashi.jpg" alt="" width="640" height="600">
<h3 class="flip-card__name">高橋 美緒</h3>
<p class="flip-card__role">デザインリード</p>
</div>
<!-- 裏面は、表を向いているあいだも読み上げとキーボードの対象のまま(visibility や aria-hidden で隠さない) -->
<div class="flip-card__face flip-card__back">
<p class="flip-card__hello">美緒からひとこと</p>
<p class="flip-card__bio">ここに説明文が入ります。</p>
<ul class="flip-card__skills" aria-label="得意なこと">
<li>UI設計</li>
<li>デザインシステム</li>
</ul>
<a class="flip-card__link" href="/contact/?to=takahashi">メッセージを送る<span class="flip-card__sr">(高橋 美緒)</span> <span aria-hidden="true">→</span></a>
</div>
</div>
</li>
<li class="flip-card" style="--tint: #688297">
<div class="flip-card__inner">
<div class="flip-card__face flip-card__front">
<img class="flip-card__photo" src="/img/team-morita.jpg" alt="" width="640" height="600">
<h3 class="flip-card__name">森田 蓮</h3>
<p class="flip-card__role">モーションデザイナー</p>
</div>
<div class="flip-card__face flip-card__back">
<p class="flip-card__hello">蓮からひとこと</p>
<p class="flip-card__bio">ここに説明文が入ります。</p>
<ul class="flip-card__skills" aria-label="得意なこと">
<li>モーション</li>
<li>3D</li>
</ul>
<a class="flip-card__link" href="/contact/?to=morita">メッセージを送る<span class="flip-card__sr">(森田 蓮)</span> <span aria-hidden="true">→</span></a>
</div>
</div>
</li>
<li class="flip-card" style="--tint: #739374">
<div class="flip-card__inner">
<div class="flip-card__face flip-card__front">
<img class="flip-card__photo" src="/img/team-ono.jpg" alt="" width="640" height="600">
<h3 class="flip-card__name">小野 彩花</h3>
<p class="flip-card__role">フロントエンドエンジニア</p>
</div>
<div class="flip-card__face flip-card__back">
<p class="flip-card__hello">彩花からひとこと</p>
<p class="flip-card__bio">ここに説明文が入ります。</p>
<ul class="flip-card__skills" aria-label="得意なこと">
<li>Web制作</li>
<li>表示速度の改善</li>
</ul>
<a class="flip-card__link" href="/contact/?to=ono">メッセージを送る<span class="flip-card__sr">(小野 彩花)</span> <span aria-hidden="true">→</span></a>
</div>
</div>
</li>
</ul>
</section>
.team {
--ground: #f2f1ed; /* 地 */
--surface: #ffffff; /* 表の面 */
--ink: #262626; /* 表の文字。裏の面の色にもなる(明暗の反転) */
--mute: #6b6b6b; /* 役職 */
--back-ink: #eeece6; /* 裏の文字 */
--back-mute: #a8a6a0; /* 裏の小さいラベル */
--back-line: rgba(238, 236, 230, 0.14); /* 裏のチップの枠 */
padding: 48px clamp(24px, 6vw, 96px);
background: var(--ground);
color: var(--ink);
}
.team__title { margin: 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.team__lead { margin: 8px 0 40px; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.team__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 32px;
margin: 0;
padding: 0;
list-style: none;
}
/* 回らない外枠。奥行きはここで付ける(値が小さいほど遠近が強い) */
.flip-card { perspective: 1200px; }
/* 表と裏を同じ升に重ねる。カードの高さは、中身の多いほうの面に合う */
.flip-card__inner {
display: grid;
transform-style: preserve-3d;
}
.flip-card__face {
grid-area: 1 / 1;
border-radius: 4px;
overflow: hidden;
backface-visibility: hidden;
}
.flip-card__front {
padding-bottom: 24px;
background: var(--surface);
color: var(--ink);
}
.flip-card__photo {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 15;
object-fit: cover;
background: var(--tint);
}
.flip-card__name { margin: 24px 24px 4px; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.flip-card__role { margin: 0 24px; color: var(--mute); font-size: 14px; letter-spacing: 0.04em; }
/* 裏は、明暗を反転した面。はじめから裏向きに重ねておく */
.flip-card__back {
display: flex;
flex-direction: column;
padding: 24px;
background: var(--ink);
color: var(--back-ink);
transform: rotateY(180deg);
}
.flip-card__hello { margin: 0; color: var(--back-mute); font-size: 13px; font-weight: 700; letter-spacing: 0.06em; line-height: 1; }
/* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
.flip-card__bio { margin: 16px 0 auto; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.flip-card__skills { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0; padding: 0; list-style: none; }
/* 得意なことのチップ(和文の小さなラベル) */
.flip-card__skills li {
padding: 0 12px;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--back-line);
color: var(--back-mute);
font-size: 12px;
font-weight: 700;
line-height: 24px;
letter-spacing: 0.06em;
white-space: nowrap;
}
.flip-card__link { align-self: start; color: inherit; font-size: 15px; font-weight: 600; letter-spacing: 0.04em; text-decoration: none; }
.flip-card__link:focus-visible { outline: 2px solid var(--back-ink); outline-offset: 4px; }
/* 読み上げにだけ届く語(同じ文言のリンクが並んでも、誰宛てか分かるように) */
.flip-card__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".flip-card").forEach((card) => {
const inner = card.querySelector(".flip-card__inner");
const flip = gsap
.timeline({ paused: true })
.fromTo(inner, { rotationY: 0 }, { rotationY: 180, duration: 0.8, ease: "power3.inOut" });
// 動きを減らす設定では、ほぼ一瞬で裏返す
if (reduceMotion) flip.timeScale(20);
const open = () => flip.play();
const close = () => flip.reverse();
let touch = false;
// ホバーは回らない外枠で拾う。回る面で拾うと、真横を向いた瞬間に幅がなくなり、離れたことになってちらつく
card.addEventListener("pointerenter", (event) => event.pointerType !== "touch" && open());
card.addEventListener("pointerleave", (event) => event.pointerType !== "touch" && close());
// Tab で裏面のリンクへ移ったら裏返し、フォーカスがカードの外へ出たら表に戻す
card.addEventListener("focusin", open);
card.addEventListener("focusout", (event) => !card.contains(event.relatedTarget) && close());
// 指で触る端末にはホバーが無いので、タップで裏返す(裏面のリンクを押したときは、そのまま移る)
card.addEventListener("pointerdown", (event) => (touch = event.pointerType === "touch"));
card.addEventListener("click", (event) => {
if (!touch || event.target.closest("a")) return;
flip.reversed() || flip.progress() === 0 ? open() : close();
});
});