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

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

デモ

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

仕様

用途
ボタン
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

文字を 1 字ずつ、高さが 1 行ぶんの窓に入れます。窓の中には同じ字を縦に 2 つ積んであり、下の字は窓の外に隠れています。カーソルが重なったら、この列を 1 字ぶんだけ持ち上げます。上の字が窓から抜け、入れ替わりに下から同じ字が入ってきます。

持ち上げは 0.45 秒の power3.inOut で、左の字から 0.02 秒ずつ遅らせます。7 字なら端から端まで 0.12 秒の差がつき、波のように入れ替わります。離れたときは同じタイムラインを逆再生するので、今度は右の字から戻ります。

窓の高さは、行の高さと同じ値にします。line-height だけを変えて窓と食い違わせると、下に積んだ字の上端が窓からのぞきます。語と語のあいだの空白も、そのままだと窓の幅が潰れるので、空白をそのまま残す指定(white-space を pre)にします。

分割は自前で行うので、文字を分けるプラグインも、書体の読み込みを待つ処理も要りません。ただし 1 字ずつに分けた span は 1 字ずつ読み上げられてしまうので、元の文言はリンクの名前(aria-label)に移し、分けた文字のほうは読み上げから外します。

下の段だけ色を変えると、文字が入れ替わると同時に色も変わります。和文も 1 字ずつ窓に分ければそのまま使えます(語の区切りを探す必要がありません)。ナビゲーションのリンクや、申し込みのボタンのように、短い文言のところに向いています。

コード

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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。