デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ボタンの中に「フォロー」「フォロー中」「フォロー解除」の 3 つの文言を重ね、高さ 1 行の窓で切ります。押すと、今の文言が上へ抜けて次の文言が下から入り、ボタンは墨色の塗りから、白地に線の形へ変わります。幅は文言の長さに合わせて、右端を止めたまま滑らかに変わります。フォロー中のボタンにカーソルを重ねると「フォロー解除」とエラーの色に変わり、押すと元のボタンに戻ります。
抜けるのは 0.2 秒の power2.in、入るのは 0.08 秒遅れて 0.35 秒の power3.out、幅は 0.45 秒の power3.inOut です。幅は、状態ごとに「文言の幅 + 左右の余白」を測っておき、その値へ補間します。解除して「フォロー」に戻るときだけ、上から入れて下へ抜き、取り消した向きに見せます。線は box-shadow の inset で引くと、地の色と一緒に 1 本のトゥイーンで補間できます。
フォローした直後は、カーソルがまだボタンの上にあります。そこで「フォロー解除」に変えると、押した勢いのまま解除されやすくなるので、いったん離れるまでは「フォロー中」のままにします。指のタップでは pointerenter と pointerleave が続けて届くので、ホバーはマウスとペンだけで受けます。
名前は「フォロー」のまま変えず、状態は aria-pressed で伝えます。名前を「フォロー中」に替えながら押された状態も付けると、読み上げが食い違います。キーボードで移ってきたときは、重ねたときと同じ見え方にします(:focus-visible のときだけ)。
文言が入れ替わる向き、幅の ease、解除の色で印象が変わります。フォロー・お気に入り・購読・参加の申し込みのように、押すたびに状態が入れ替わるボタンに向いています。
コード
<!-- 名前は「フォロー」のまま変えず、状態は aria-pressed で伝える。見た目の文言は 3 つ重ねて入れ替える -->
<button class="follow-button" type="button" aria-pressed="false" aria-label="フォロー">
<span class="follow-button__label" data-state="follow" aria-hidden="true">フォロー</span>
<span class="follow-button__label" data-state="following" aria-hidden="true">フォロー中</span>
<span class="follow-button__label" data-state="unfollow" aria-hidden="true">フォロー解除</span>
</button>
.follow-button {
--ink: #262626; /* フォローの塗り・フォロー中の線と文字 */
--surface: #ffffff; /* フォロー中の地 */
--hover: #3d3d3d; /* フォローに重ねたときの塗り */
--danger: #c4553d; /* 解除の線 */
--danger-text: #a8432d; /* 解除の文字 */
--tint: #f8ebe8; /* 解除の地(白にエラーの色を 12% 混ぜた色) */
--height: 44px;
--pad-x: 24px; /* 文言の左右の余白。幅は「文言の幅 + 余白」で JS が決める */
position: relative;
display: inline-block;
height: var(--height);
padding: 0;
border: 0;
border-radius: 999px;
overflow: hidden; /* 入れ替わる文言を、高さ 1 行の窓で切る */
background-color: var(--ink);
box-shadow: inset 0 0 0 1px var(--ink); /* 線は地と一緒に補間できるよう、inset の影で引く */
font: inherit;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
cursor: pointer;
vertical-align: middle;
}
.follow-button__label {
display: block;
padding: 0 var(--pad-x);
line-height: var(--height);
text-align: center;
}
/* 2 つ目からは重ねて置く。JS が動くまでは隠しておく */
.follow-button__label:not(:first-child) { position: absolute; left: 0; right: 0; top: 0; opacity: 0; }
.follow-button__label[data-state="follow"] { color: #ffffff; }
.follow-button__label[data-state="following"] { color: var(--ink); }
.follow-button__label[data-state="unfollow"] { color: var(--danger-text); }
.follow-button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
document.querySelectorAll(".follow-button").forEach((button) => {
const labels = Object.fromEntries([...button.querySelectorAll(".follow-button__label")].map((el) => [el.dataset.state, el]));
const css = getComputedStyle(button);
const color = (name) => css.getPropertyValue(`--${name}`).trim();
// 状態ごとの地と線(GSAP は var(--name) のままでは補間できないので、値にして渡す)
const looks = {
follow: [color("ink"), color("ink")],
hover: [color("hover"), color("hover")],
following: [color("surface"), color("ink")],
unfollow: [color("tint"), color("danger")],
};
const look = (state) => ({ backgroundColor: looks[state][0], boxShadow: `inset 0 0 0 1px ${looks[state][1]}` });
const widths = {};
let shown = "follow";
let following = false;
let hovering = false;
let armed = false; // フォローした直後は、いったん離れるまで「フォロー解除」を出さない
// 状態ごとの幅は「文言の幅 + 左右の余白」。書体が読み込まれたら測り直す
const measure = () => {
const pad = parseFloat(color("pad-x"));
const range = document.createRange();
for (const [state, label] of Object.entries(labels)) {
range.selectNodeContents(label);
widths[state] = Math.ceil(range.getBoundingClientRect().width + pad * 2);
}
gsap.set(button, { width: widths[shown] });
};
const paint = () => gsap.to(button, { ...look(shown === "follow" && hovering ? "hover" : shown), duration: 0.25 / SPEED, ease: "none", overwrite: "auto" });
// 文言を高さ 1 行の窓の中で縦に入れ替える。隠れている文言は、入ってくる向きの外に置き直してから入れる
const slide = (label, into, from) => {
const hidden = Math.abs(gsap.getProperty(label, "yPercent")) >= 100;
if (!into && hidden) return;
if (into && hidden) gsap.set(label, { yPercent: from });
gsap.to(label, into
? { yPercent: 0, duration: 0.35 / SPEED, delay: 0.08 / SPEED, ease: "power3.out", overwrite: true }
: { yPercent: -from, duration: 0.2 / SPEED, ease: "power2.in", overwrite: true });
};
const show = (next) => {
if (next !== shown) {
const from = next === "follow" && shown === "unfollow" ? -100 : 100; // 解除で戻るときだけ、上から入れる
slide(labels[shown], false, from);
slide(labels[next], true, from);
gsap.to(button, { width: widths[next], duration: 0.45 / SPEED, ease: "power3.inOut", overwrite: "auto" });
shown = next;
}
paint();
};
button.addEventListener("click", () => {
following = !following;
armed = false;
button.setAttribute("aria-pressed", String(following));
show(following ? "following" : "follow");
});
const enter = () => {
hovering = true;
if (following && armed) show("unfollow");
else paint();
};
const leave = () => {
hovering = false;
armed = following;
if (following) show("following");
else paint();
};
// 指のタップでは pointerenter / pointerleave が続けて届くので、ホバーはマウスとペンだけで受ける
button.addEventListener("pointerenter", (event) => event.pointerType !== "touch" && enter());
button.addEventListener("pointerleave", (event) => event.pointerType !== "touch" && leave());
// キーボードで移ってきたときも、重ねたときと同じに見せる(マウスで押したときの焦点では出さない)
button.addEventListener("focus", () => button.matches(":focus-visible") && enter());
button.addEventListener("blur", () => !button.matches(":hover") && leave());
gsap.set(Object.values(labels).slice(1), { yPercent: 100, opacity: 1 });
measure();
document.fonts.ready.then(measure);
});