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

ホバーで記事カードの見出しに下線が引かれ、矢印が右へ抜けて戻る

デモ

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

仕様

用途
カード
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

カード全体を 1 つのリンク(<a>)にし、カーソルが重なったら、見出しに左から下線を引き、右下の丸を墨色で塗ります。同時に、丸の中の矢印を右へ抜けさせ、左の外に置き直してから戻します。下線と塗りは止めたタイムラインを重なったら play()、離れたら reverse() し、矢印は入るたびに頭から流します。

下線は見出しの中の span の背景(1 本の linear-gradient)で引き、background-size の幅を 0.5 秒で 0% から 100% へ広げます。inline の要素の背景は、折り返した行をつないだ 1 本の帯として扱われるので、2 行の見出しでも 1 行目の右端から 2 行目の左端へ続けて伸びます。疑似要素で引く線では、2 行目に届きません。

矢印は 1 本だけで、0.22 秒で右の外へ出し(power2.in)、左の外から 0.38 秒で戻します(power3.out)。戻りの fromTo は作った時点で from の値を描くので、immediateRender を false にしておかないと、はじめから矢印が丸の外に隠れます。流れている途中に入り直したときは、頭から流し直しません(isActive() で見分けます)。

リンクの名前は aria-labelledby で見出しだけにし、分類と日付は説明として読ませます。「続きを読む」と矢印は見た目の手がかりなので、読み上げから外します。キーボードで焦点が入ったときも同じ動きにし、動きを減らす設定では、矢印は流さずに下線と塗りだけをほぼ一瞬で切り替えます。

離れたときは入るときの倍の速さで戻し、隣のカードへ移ったときに前の下線が残らないようにしています。下線を 1px にすると控えめに、丸を塗らずに矢印だけを動かすと軽い印象になります。ボタンの中の矢印だけを入れ替える形と違い、カード全体が押せることを見出しと矢印の両方で伝えます。ブログ・お知らせ・事例の一覧に向いています。

コード

HTML
<section class="journal" aria-labelledby="journal-title">
  <div class="journal__head">
    <div>
      <p class="journal__label">JOURNAL</p>
      <h2 class="journal__title" id="journal-title">新着記事</h2>
    </div>
    <a class="journal__all" href="/journal/">すべての記事を見る</a>
  </div>

  <ul class="journal__list">
    <li>
      <!-- カード全体が 1 つのリンク。名前は見出しだけにし、分類と日付は説明として読ませる -->
      <a class="post-card" href="/journal/calm-interfaces/" aria-labelledby="post-1-title" aria-describedby="post-1-meta">
        <img class="post-card__image" src="/img/journal-calm.jpg" alt="" width="682" height="464">
        <div class="post-card__body">
          <p class="post-card__meta" id="post-1-meta"><span class="post-card__kind">デザイン</span> <time datetime="2026-09-18">2026年9月18日</time></p>
          <!-- 下線は span の背景で引く(2 行にまたがっても 1 本の線として伸びる) -->
          <h3 class="post-card__title" id="post-1-title"><span>忙しいチームのための、落ち着いた画面のつくり方</span></h3>
          <p class="post-card__more" aria-hidden="true">
            続きを読む
            <span class="post-card__arrow"><span class="post-card__fill"></span><svg viewBox="0 0 24 24" focusable="false"><path d="M5 12h14M13 6l6 6-6 6" /></svg></span>
          </p>
        </div>
      </a>
    </li>

    <li>
      <a class="post-card" href="/journal/motion-that-explains/" aria-labelledby="post-2-title" aria-describedby="post-2-meta">
        <img class="post-card__image" src="/img/journal-motion.jpg" alt="" width="682" height="464">
        <div class="post-card__body">
          <p class="post-card__meta" id="post-2-meta"><span class="post-card__kind">モーション</span> <time datetime="2026-09-09">2026年9月9日</time></p>
          <h3 class="post-card__title" id="post-2-title"><span>飾るためではなく、伝えるための動きを考える</span></h3>
          <p class="post-card__more" aria-hidden="true">
            続きを読む
            <span class="post-card__arrow"><span class="post-card__fill"></span><svg viewBox="0 0 24 24" focusable="false"><path d="M5 12h14M13 6l6 6-6 6" /></svg></span>
          </p>
        </div>
      </a>
    </li>

    <li>
      <a class="post-card" href="/journal/accessible-color/" aria-labelledby="post-3-title" aria-describedby="post-3-meta">
        <img class="post-card__image" src="/img/journal-color.jpg" alt="" width="682" height="464">
        <div class="post-card__body">
          <p class="post-card__meta" id="post-3-meta"><span class="post-card__kind">アクセシビリティ</span> <time datetime="2026-08-28">2026年8月28日</time></p>
          <h3 class="post-card__title" id="post-3-title"><span>誰にでも見やすい配色を選ぶための手引き</span></h3>
          <p class="post-card__more" aria-hidden="true">
            続きを読む
            <span class="post-card__arrow"><span class="post-card__fill"></span><svg viewBox="0 0 24 24" focusable="false"><path d="M5 12h14M13 6l6 6-6 6" /></svg></span>
          </p>
        </div>
      </a>
    </li>
  </ul>
</section>

CSS
.journal {
  --ground: #f2f1ed;  /* 地 */
  --surface: #ffffff; /* カードの面。塗った丸の上の矢印の色にもなる */
  --ink: #262626;     /* 文字・下線・矢印の丸 */
  --mute: #6b6b6b;    /* 分類と日付 */
  --line: 2px;        /* 下線の太さ */
  padding: 64px clamp(16px, 6vw, 96px);
  background: var(--ground);
  color: var(--ink);
}

.journal__head {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: flex-end;
  gap: 16px 32px;
  margin-bottom: 48px;
}

.journal__label { margin: 0; color: var(--mute); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; }
.journal__title { margin: 16px 0 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.journal__all { color: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; text-decoration-thickness: 1px; text-underline-offset: 0.4em; }

.journal__list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 32px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.post-card {
  display: flex;
  flex-direction: column;
  height: 100%;
  border-radius: 4px;
  overflow: hidden;
  background: var(--surface);
  color: inherit;
  text-decoration: none;
}

.post-card:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.post-card__image { display: block; width: 100%; height: auto; aspect-ratio: 341 / 232; object-fit: cover; background: #d9d6cc; }
.post-card__body { display: flex; flex-direction: column; flex: 1; padding: 24px; }

.post-card__meta { display: flex; align-items: baseline; gap: 12px; margin: 0; color: var(--mute); font-size: 13px; line-height: 1; letter-spacing: 0.04em; }
.post-card__kind { font-size: 12px; font-weight: 700; letter-spacing: 0.06em; }

.post-card__title { margin: 16px 0 24px; font-size: 22px; font-weight: 700; line-height: 1.36; letter-spacing: -0.01em; word-break: auto-phrase; text-wrap: balance; }

/* 下線は inline の span の背景。幅を 0% → 100% にすると、折り返した行をつないで左から伸びる */
.post-card__title span {
  background: linear-gradient(currentColor, currentColor) 0 100% / 0% var(--line) no-repeat;
}

.post-card__more {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin: auto 0 0;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
}

/* 線の丸。はみ出した矢印は丸の外で切る */
.post-card__arrow {
  position: relative;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px var(--ink);
  overflow: hidden;
  color: var(--ink);
}

/* 塗りは線ごと覆う面。GSAP で透明度を上げる */
.post-card__fill { position: absolute; inset: 0; border-radius: inherit; background: var(--ink); opacity: 0; }

.post-card__arrow svg {
  position: relative;
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}

JavaScript
import { gsap } from "gsap";

const LEAVE = 2; // 離れたときに戻る速さ(入るときの倍率)。隣へ移ったとき、前の下線が残らない
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 動きを減らす設定では、下線と塗りをほぼ一瞬で切り替え、矢印は流さない
const speed = reduceMotion ? 20 : 1;

document.querySelectorAll(".post-card").forEach((card) => {
  const style = getComputedStyle(card);
  const underline = card.querySelector(".post-card__title span");
  const fill = card.querySelector(".post-card__fill");
  const arrow = card.querySelector(".post-card__arrow svg");

  // 下線と塗り。重なったら play、離れたら reverse
  const hover = gsap
    .timeline({ paused: true })
    .to(underline, { backgroundSize: `100% ${style.getPropertyValue("--line").trim()}`, duration: 0.5, ease: "power2.inOut" }, 0)
    .to(fill, { opacity: 1, duration: 0.3, ease: "power1.inOut" }, 0)
    .to(arrow, { color: style.getPropertyValue("--surface").trim(), duration: 0.3, ease: "power1.inOut" }, 0);

  // 矢印は 1 本だけ。右の外へ出し、左の外に置き直してから戻す(戻りきった形で止まるので、離れたときは何もしない)
  const nudge = gsap
    .timeline({ paused: true })
    .to(arrow, { xPercent: 150, duration: 0.22, ease: "power2.in" })
    // fromTo は作った時点で from を描くので、後ろに置くときは immediateRender を切る
    .fromTo(arrow, { xPercent: -150 }, { xPercent: 0, duration: 0.38, ease: "power3.out", immediateRender: false });

  const enter = () => {
    hover.timeScale(speed).play();
    // 流れている途中に入り直したときは、頭から流し直さない(矢印が飛ぶ)
    if (!reduceMotion && !nudge.isActive()) nudge.restart();
  };
  const leave = () => {
    // カーソルもキーボードの焦点もカードから外れたときだけ戻す
    if (card.matches(":hover") || card.matches(":focus-visible")) return;
    hover.timeScale(speed * LEAVE).reverse();
  };

  card.addEventListener("pointerenter", enter);
  card.addEventListener("pointerleave", leave);
  card.addEventListener("focus", enter);
  card.addEventListener("blur", leave);
});

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

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