本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ボタン

フォローのボタンを押すと、幅を変えながらフォロー中に変わる

デモ

操作:クリック・カーソルを重ねる。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
ボタン
動作
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、解除の色で印象が変わります。フォロー・お気に入り・購読・参加の申し込みのように、押すたびに状態が入れ替わるボタンに向いています。

コード

HTML
<!-- 名前は「フォロー」のまま変えず、状態は 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>

CSS
.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; }

JavaScript
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);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。