デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
カーソルを重ねると、見出しの文字が端から順に横軸のまわりで半回転し、裏に用意しておいた別の色の文字が正面に並びます。離せば同じ動きを逆にたどって、元の文字へ戻ります。文字はそれぞれ、表と裏の面を重ねた小さな要素に分けてあり、それを rotationX で180°回しているだけです。
backface-visibility は表と裏の両方の面に指定しています。指定しないと、回転の途中で裏返った文字が透けて重なって見えます。ばらばらに分けた文字は、スクリーンリーダーでも区切って読まれてしまうので、元の文言はリンクの名前に移し、分けた文字のほうは読み上げの対象から外しています。
半回転は0.55秒の power2.inOut。隣の文字とは0.08秒ずつずらしてあり、この間隔を広げるほど、めくれが波のように見出しを渡っていきます。遠近の強さ(perspective)は350に固定しました。これより小さくすると、手前へ迫ってくる面が誇張されすぎて、安っぽく見えます。
コード
<!-- 文言はそのまま書く。1 字ずつの要素に分けるのは script の役目 -->
<a class="flip-text" href="/works/">
<span class="flip-text__label">制作実績を見る</span>
</a>
.flip-text {
--ink: #262626; /* 表の文字 */
--back: #b27771; /* 裏の色。表より明るくすると、裏返ったのが明るさでもわかる */
display: inline-flex;
font-size: clamp(28px, 5vw, 56px);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--ink);
text-decoration: none;
white-space: nowrap;
}
.flip-text__label {
display: flex;
}
.flip-text__space {
display: inline-block;
width: 0.32em;
}
/* 1 字ぶんの要素。preserve-3d で中の表と裏を立体として扱う */
.flip-text__char {
display: inline-block;
position: relative;
transform-style: preserve-3d;
}
.flip-text__face {
display: block;
backface-visibility: hidden;
}
/* 裏ははじめから伏せて重ねておく。要素が 180° 回ると正立して現れる */
.flip-text__face--back {
position: absolute;
inset: 0;
color: var(--back);
transform: rotateX(180deg);
}
.flip-text:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 6px;
}
import { gsap } from "gsap";
const HALF = 0.55; // 半回転(表 → 裏)にかける時間
const EACH = 0.08; // 隣の文字とのずれ
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".flip-text").forEach((link) => {
const label = link.querySelector(".flip-text__label");
const text = label.textContent;
// 1 字ずつに分けた span は 1 字ずつ読み上げられるので、元の文言はリンクの名前に移す
link.setAttribute("aria-label", text);
label.setAttribute("aria-hidden", "true");
label.textContent = "";
const flips = [];
for (const ch of text) {
if (ch === " ") {
const space = document.createElement("span");
space.className = "flip-text__space";
label.append(space);
continue;
}
const flip = document.createElement("span");
flip.className = "flip-text__char";
const front = document.createElement("span");
front.className = "flip-text__face";
front.textContent = ch;
// 裏は同じ要素の中心で伏せておく。要素が 180° 回ると、表と同じ位置に正立して現れる
const back = document.createElement("span");
back.className = "flip-text__face flip-text__face--back";
back.textContent = ch;
flip.append(front, back);
label.append(flip);
flips.push(flip);
}
gsap.set(flips, { transformPerspective: 350 });
const flip = gsap
.timeline({ paused: true })
.to(flips, { rotationX: 180, duration: HALF, ease: "power2.inOut", stagger: EACH });
// 動きを減らす設定では、ほぼ一瞬で裏返す
if (reduceMotion) flip.timeScale(20);
const open = () => flip.play();
const close = () => flip.reverse();
link.addEventListener("pointerenter", open);
link.addEventListener("pointerleave", close);
link.addEventListener("focus", open);
link.addEventListener("blur", close);
});