デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ボタンの文言は、カーソルを重ねると左の文字から順に上へ抜けていき、入れ替わりに下から同じ文字がせり上がってきます。動きは0.45秒の power3.inOut で、文字ごとに0.02秒ずつ遅らせて始めるので、このデモの7文字なら端から端まで0.12秒の差がつき、それが波に見えます。カーソルを離すと同じタイムラインを逆に再生するので、今度は右の文字から元に戻ります。
文字はひとつずつ、行の高さぶんだけを見せるマスクに入れ、その中に同じ文字を上下に積んであります。列を文字ひとつ分持ち上げると、上の文字がマスクから抜け、外に隠れていた下の文字が入ってきます。マスクの高さは行の高さ(line-height)と同じ値なので、行の高さだけを変えて食い違わせると、下の文字の上端がマスクからのぞいてしまいます。下に積んだ文字だけ色を変えておけば、入れ替わりと同時に色も変わります。
分割はプラグインを使わず、スクリプトで自前で行いました。Web フォントの読み込みを待つ処理も要らず、和文でも語の区切りを探す必要はありません。ただ、分けた span はスクリーンリーダーでばらばらに区切って読まれてしまうので、元の文言はリンクの名前(aria-label)に移し、分けた文字は読み上げの対象から外しました。半角スペースは、そのまま分けると幅がなくなるので、white-space の pre で語と語の間を保っています。
コード
<!-- 文言はそのまま書く。1 字ずつのマスクに分けるのは script の役目 -->
<a class="text-roll" href="/contact/">
<span class="text-roll__label">ご相談はこちら</span>
</a>
.text-roll {
--ink: #262626; /* 背景色 */
--on-ink: #ffffff; /* 文字の色 */
--line: 20px; /* 1 字のマスクの高さ。行の高さと同じ値にする */
display: inline-flex;
align-items: center;
height: 48px;
padding: 0 32px;
border-radius: 999px;
background-color: var(--ink);
color: var(--on-ink);
font-size: 15px;
font-weight: 600;
line-height: var(--line);
letter-spacing: 0.04em;
text-decoration: none;
white-space: nowrap;
}
.text-roll__label {
display: flex;
}
/* 1 字ぶんのマスク。行の高さと違う値にすると、下に重ねた字がマスクからのぞく */
.text-roll__cell {
display: block;
height: var(--line);
overflow: hidden;
}
.text-roll__stack {
display: block;
}
/* 同じ字を縦に 2 つ並べる。pre にしないと、語と語のあいだの空白が潰れて幅が無くなる */
.text-roll__stack > span {
display: block;
white-space: pre;
}
.text-roll:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".text-roll").forEach((button) => {
const label = button.querySelector(".text-roll__label");
const text = label.textContent.trim();
// 1 字ずつの span は 1 字ずつ読み上げられてしまうので、元の文言はリンクの名前に移す
button.setAttribute("aria-label", text);
label.setAttribute("aria-hidden", "true");
// 1 字ぶんのマスクを作り、同じ字を縦に 2 つ並べる。下の字はマスクの外に隠れている
label.textContent = "";
const stacks = [...text].map((char) => {
const cell = document.createElement("span");
cell.className = "text-roll__cell";
const stack = document.createElement("span");
stack.className = "text-roll__stack";
for (let i = 0; i < 2; i++) {
const glyph = document.createElement("span");
glyph.textContent = char;
stack.append(glyph);
}
cell.append(stack);
label.append(cell);
return stack;
});
// 縦に並べた列を 1 字ぶん(列の高さの半分)持ち上げる。左の字から 0.02 秒ずつ遅らせる
const hover = gsap
.timeline({ paused: true })
.to(stacks, { yPercent: -50, duration: 0.45, ease: "power3.inOut", stagger: 0.02 });
// 動きを減らす設定では、ほぼ一瞬で入れ替える
if (reduceMotion) hover.timeScale(20);
const roll = () => hover.play();
const back = () => hover.reverse();
button.addEventListener("pointerenter", roll);
button.addEventListener("pointerleave", back);
button.addEventListener("focus", roll);
button.addEventListener("blur", back);
});