デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
右上の「EN」を押すと、メニューの5つの項目が左から順に縦に回って、日本語が英語に入れ替わります。見出しの2行も少し遅れて回ります。言葉が替わると項目の幅も変わるので、右端をそろえたまま、左の項目が少しずつ寄っていきます。
項目の中には日本語と英語を重ねて置き、90°ずらした角柱の2つの面として回しています。いまの面は rotateX で上へ倒れて奥へ消え、次の面が下から起き上がってきます。回転の軸を行の高さの半分だけ奥に置いたので、文字がめくれるのではなく、角柱が転がるように見えます。動きは0.75秒の power3.inOut、項目ごとに0.08秒ずつずらしました。回っている途中の文字も読めるように、やや遅めにしてあります。
見落としがちなのが幅です。「私たちについて」と「About」では倍以上の差があるので、文字だけを入れ替えると、回り終えた瞬間に隣の項目が跳ねてしまいます。回っている間は回転と同じ進み具合で次の言語の幅へ近づけ、止まっているときは見せている面だけで幅が決まるようにしました。見せていない面には aria-hidden を付けています。
回った回数は1つの値で持っていて、その偶奇がいまの言語になります。回っている途中で反対側を押しても逆回しにはせず、いまの回転を終えてからもう一度上へ回すので、向きが行ったり来たりしません。押したときには html の lang も書き換え、スクリーンリーダーが英語の項目を英語として読めるようにしています。
見出しのように行が縦に並ぶ所は、行ごとに overflow: hidden で切っています。回っている面は行の高さより少し上下にはみ出すので、切らずにおくと上の行と下の行の文字が重なります。遠近の強さは perspective() の距離しだいで、短くするほど手前に迫ってくるように大きく回ります。
コード
<header class="lang-header">
<a class="lang-header__logo" href="/">Hi! I'm HIGASHIYUKI®</a>
<nav class="lang-header__nav" aria-label="メイン">
<!-- 項目ごとに日本語と英語を重ねて置く。見せていない側は aria-hidden(JS が切り替える) -->
<ul class="lang-header__menu">
<li><a class="lang-roll" href="/about/"><span class="lang-roll__face" lang="ja">私たちについて</span><span class="lang-roll__face" lang="en" aria-hidden="true">About</span></a></li>
<li><a class="lang-roll" href="/services/"><span class="lang-roll__face" lang="ja">サービス</span><span class="lang-roll__face" lang="en" aria-hidden="true">Services</span></a></li>
<li><a class="lang-roll" href="/works/"><span class="lang-roll__face" lang="ja">制作実績</span><span class="lang-roll__face" lang="en" aria-hidden="true">Works</span></a></li>
<li><a class="lang-roll" href="/careers/"><span class="lang-roll__face" lang="ja">採用情報</span><span class="lang-roll__face" lang="en" aria-hidden="true">Careers</span></a></li>
<li><a class="lang-roll" href="/contact/"><span class="lang-roll__face" lang="ja">お問い合わせ</span><span class="lang-roll__face" lang="en" aria-hidden="true">Contact</span></a></li>
</ul>
</nav>
<div class="lang-switch" role="group" aria-label="表示する言語">
<span class="lang-switch__pill" aria-hidden="true"></span>
<button class="lang-switch__button" type="button" data-lang="ja" aria-pressed="true">JP</button>
<button class="lang-switch__button" type="button" data-lang="en" aria-pressed="false">EN</button>
</div>
</header>
<section class="lang-hero">
<div class="lang-hero__copy">
<p class="lang-hero__eyebrow">CREATIVE STUDIO</p>
<h1 class="lang-hero__title">
<span class="lang-roll lang-roll--line"><span class="lang-roll__face" lang="ja">デザインで、</span><span class="lang-roll__face" lang="en" aria-hidden="true">Design that moves</span></span>
<span class="lang-roll lang-roll--line"><span class="lang-roll__face" lang="ja">事業を前へ。</span><span class="lang-roll__face" lang="en" aria-hidden="true">business forward.</span></span>
</h1>
<p class="lang-hero__lead">ここに説明文が入ります。</p>
</div>
<!-- 実際のサイトでは画像に替える -->
<div class="lang-hero__visual" aria-hidden="true"></div>
</section>
.lang-header,
.lang-hero {
--bg: #f2f1ed;
--ink: #262626;
--mute: #6b6b6b; /* 選んでいない言語の文字 */
--line: rgba(38, 38, 38, 0.12);
--gutter: clamp(16px, 7vw, 96px);
background: var(--bg);
color: var(--ink);
}
.lang-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px 40px;
padding: 32px var(--gutter);
white-space: nowrap;
}
.lang-header a { color: inherit; text-decoration: none; }
.lang-header__logo { margin-right: auto; font-size: 18px; font-weight: 700; letter-spacing: 0.12em; }
.lang-header__menu {
display: flex;
gap: 30px;
margin: 0;
padding: 0;
list-style: none;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
}
/* 日本語と英語を重ねる要素。見せている側だけが幅を決め、もう片方は上に重ねる */
.lang-roll { position: relative; display: block; line-height: 20px; text-align: center; }
.lang-roll__face { display: block; white-space: nowrap; }
.lang-roll__face[aria-hidden="true"] { position: absolute; top: 0; left: 50%; translate: -50% 0; visibility: hidden; }
.lang-roll:focus-visible { outline: 2px solid var(--ink); outline-offset: 6px; border-radius: 2px; }
.lang-switch {
position: relative;
display: flex;
padding: 3px;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--line);
}
/* 選んでいる言語の印。JS が押されたボタンの位置へ動かす */
.lang-switch__pill { position: absolute; top: 3px; left: 3px; width: 44px; height: 30px; border-radius: 999px; background: var(--ink); }
.lang-switch__button {
position: relative;
width: 44px;
height: 30px;
padding: 0 0 0 0.08em; /* 最後の字の後ろの字間の分を左にも足して、中央にそろえる */
border: 0;
border-radius: 999px;
background: none;
color: var(--mute);
font: inherit;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
cursor: pointer;
transition: color 0.3s;
}
.lang-switch__button[aria-pressed="true"] { color: #ffffff; }
.lang-switch__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
.lang-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 392px);
align-items: center;
gap: 48px;
padding: 64px var(--gutter) 96px;
}
.lang-hero__eyebrow { margin: 0; color: var(--mute); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; }
.lang-hero__title { margin: 24px 0 0; font-size: clamp(30px, 6vw, 64px); font-weight: 800; letter-spacing: -0.02em; }
/* 見出しの行は、回る面を行の高さで切る(隣の行の回る面と重ならないように) */
.lang-roll--line { overflow: hidden; line-height: 1.125; text-align: left; }
.lang-roll--line .lang-roll__face[aria-hidden="true"] { left: 0; translate: none; }
.lang-hero__lead { max-width: 460px; margin: 32px 0 0; font-size: 18px; line-height: 1.8; }
.lang-hero__visual { aspect-ratio: 392 / 456; border-radius: 2px; background: linear-gradient(150deg, #8fa9c2 0%, #6b85a3 48%, #7f6fa6 100%); }
@media (max-width: 860px) {
/* メニューを 2 段目に回し、入りきらなければ横にスクロールさせる。フォーカスのアウトラインが切れないよう、余白の分だけ外へ広げる */
.lang-header__nav { order: 3; width: calc(100% + 16px); margin: -8px; padding: 8px; overflow-x: auto; }
.lang-hero { grid-template-columns: minmax(0, 1fr); }
.lang-header { column-gap: 16px; }
.lang-header__logo { font-size: 15px; } /* ロゴと言語の切り替えを 1 段目に並べる */
}
import { gsap } from "gsap";
const LANGS = ["ja", "en"]; // 面の順番。v(回った回数)の偶奇がこの番号になる
const ROLL = { duration: 0.75, stagger: 0.08, ease: "power3.inOut" }; // メニューの項目が回る長さと、左から順にずらす間
const TITLE = { delay: 0.2, duration: 0.85, stagger: 0.12 }; // 見出しの行(メニューが回り始めてから遅らせる)
const SLIDE = { duration: 0.45, ease: "power3.inOut" }; // 選んでいる言語の印が滑る長さ
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 1 つの項目を、日本語と英語の 2 面を 90° ずらした角柱として回す。v の整数の部分の偶奇がいまの言語、端数が回っている途中
function makeDrum(el) {
const faces = LANGS.map((lang) => el.querySelector(`.lang-roll__face[lang="${lang}"]`));
const line = el.classList.contains("lang-roll--line"); // 見出しの行は幅を動かさない
const drum = { v: 0, widths: [0, 0] };
drum.measure = () => { drum.widths = faces.map((face) => face.offsetWidth); };
drum.paint = () => {
const turns = Math.floor(drum.v + 1e-6), q = Math.min(Math.max(drum.v - turns, 0), 1), out = turns % 2, r = el.offsetHeight / 2;
// 幅は、回る進み具合に合わせて次の言語の幅へ近づける(測る前は、見せている面の幅のまま)
if (!line && drum.widths[0]) el.style.width = `${drum.widths[out] + (drum.widths[1 - out] - drum.widths[out]) * q}px`;
[out, 1 - out].forEach((lang, k) => {
const angle = k === 0 ? 90 * q : 90 * (q - 1); // いまの面は上へ倒れ、次の面が下から起き上がる
const face = faces[lang];
face.style.visibility = (k === 0 ? q < 1 : q > 0) ? "visible" : "hidden";
face.style.transform = angle ? `perspective(${r * 18}px) translateZ(${-r}px) rotateX(${angle}deg) translateZ(${r}px)` : "";
face.style.opacity = String(Math.max(0, Math.cos((angle * Math.PI) / 180) * 1.2 - 0.2)); // 奥へ倒れるほど薄く、真横の手前で消える
});
};
drum.show = (lang) => faces.forEach((face, i) => face.setAttribute("aria-hidden", String(LANGS[i] !== lang)));
return drum;
}
document.querySelectorAll(".lang-switch").forEach((group) => {
const buttons = [...group.querySelectorAll(".lang-switch__button")];
const pill = group.querySelector(".lang-switch__pill");
const menu = [...document.querySelectorAll(".lang-header__menu .lang-roll")].map(makeDrum);
const lines = [...document.querySelectorAll(".lang-hero__title .lang-roll")].map(makeDrum);
const drums = [...menu, ...lines];
let tweens = [];
document.fonts.ready.then(() => drums.forEach((drum) => { drum.measure(); drum.paint(); })); // 書体が読み込まれてから幅を測る
const select = (lang) => {
const to = LANGS.indexOf(lang);
buttons.forEach((button) => button.setAttribute("aria-pressed", String(button.dataset.lang === lang)));
document.documentElement.lang = lang;
drums.forEach((drum) => { drum.show(lang); drum.paint(); }); // 回り始めるまで(delay の間)も、前の言語の面を見せておく
tweens.forEach((tween) => tween.kill());
// 印は押したボタンの位置へ。ボタンの間の距離は CSS の幅から読む
const x = buttons[to].offsetLeft - buttons[0].offsetLeft;
tweens = [gsap.to(pill, { x, duration: reduceMotion ? 0 : SLIDE.duration, ease: SLIDE.ease })];
drums.forEach((drum, i) => {
// 回るのはいつも上向き。途中なら、その回転の行き先から数えて、偶奇が to になるまで回す
const c = Math.ceil(drum.v - 1e-6), target = c % 2 === to ? c : c + 1;
if (reduceMotion) { drum.v = target; drum.paint(); return; } // 動きを減らす設定では、回さずに入れ替える
const inMenu = i < menu.length;
const delay = inMenu ? i * ROLL.stagger : TITLE.delay + (i - menu.length) * TITLE.stagger;
const duration = (inMenu ? ROLL.duration : TITLE.duration) * Math.max(target - drum.v, 0.3);
tweens.push(gsap.to(drum, { v: target, duration, delay, ease: ROLL.ease, onUpdate: drum.paint, onComplete: drum.paint }));
});
};
buttons.forEach((button) => button.addEventListener("click", () => {
if (button.getAttribute("aria-pressed") !== "true") select(button.dataset.lang);
}));
});