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

ボタン

ホバーでボタンの文字が上へ抜け、下から同じ文字が入る

デモ

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

仕様

用途
ボタン
動作
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 で語と語の間を保っています。

コード

HTML
<!-- 文言はそのまま書く。1 字ずつのマスクに分けるのは script の役目 -->
<a class="text-roll" href="/contact/">
  <span class="text-roll__label">ご相談はこちら</span>
</a>

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

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

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

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