本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
オーバーレイ

リンクにホバーすると、行き先のプレビューのカードが少し遅れて浮かぶ

デモ

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

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

記事の中のリンクにポインタを 0.3 秒乗せると、リンクの上に行き先のカード(画像・分類・題・ひとこと)が 8px 下から浮かんで出ます。画像は 112% に寄った所から 0.6 秒かけて引いて収まります。カードが出ている間に別のリンクへ移ると、待たずにカードがそちらへ滑り、中身だけが替わります。

最初の 0.3 秒の待ちは、通り過ぎただけのリンクでカードを出さないためのものです。いったん出たあとは、読む人がリンクを見比べている最中なので、同じ待ちを挟むと反応が鈍く感じられます。そこで、出ている間と、消えてから 0.5 秒の間は待たずに出します。離れてから消え始めるまでも 0.25 秒待ち、その間にカードへ乗れば消しません。待ちはどれも delayedCall で作り、次の操作が来たら止めます。

別のリンクへ滑るときは、位置の補間(0.35 秒の power3.out)だけを止めて付け直し、不透明度の補間は止めません。出る途中で移ったときに killTweensOf でカードの補間を全部止めると、半透明のまま残ります。中身は 0.1 秒で消しきってから替え、0.15 秒で出します。重ねて替えると、途中で 2 つの題が重なって読めません。

カードはリンクの文と同じ内容の飾りなので、読み上げからは外します。キーボードで焦点が来たときは待たずに出し、焦点が外れたときと Esc で消します。タッチの端末では押すとすぐに移るので、マウスのときだけ出し、ホバーの無い端末では出しません。置き場所はリンクの 1 行目の中央の上で、入らなければ下に回し、左右は画面の中に収めます。

待ちは 0.2〜0.5 秒のあいだで決めます。短いと読んでいる途中にカードが次々と出て、長いと気づかれません。カードの中身は、行き先のページの題と説明(OGP など)から作れます。ブログやドキュメントの関連記事へのリンク、用語集、人の名前から開くプロフィールに向いています。

コード

HTML
<!-- 記事の中のリンク。行き先の分類・題・ひとこと・画像を data 属性に持たせる -->
<p>
  余白のとり方ひとつで、読みやすさは大きく変わります。動きについては「<a class="preview-link" href="/journal/subtle-motion/"
    data-preview-label="コラム・5分" data-preview-title="控えめなモーションの作り方"
    data-preview-text="動きを控えめにする5つのルール" data-preview-image="/img/motion.jpg">控えめなモーション</a>」で紹介しています。
</p>
<p>
  あわせて「<a class="preview-link" href="/journal/layout-with-whitespace/"
    data-preview-label="コラム・8分" data-preview-title="余白で組むレイアウトの基本"
    data-preview-text="余白で読みやすくする方法" data-preview-image="/img/whitespace.jpg">余白で組むレイアウト</a>」と「<a class="preview-link" href="/journal/two-color-palette/"
    data-preview-label="コラム・6分" data-preview-title="二色で組むパレットの考え方"
    data-preview-text="色数をしぼった配色の考え方" data-preview-image="/img/palette.jpg">二色で組むパレット</a>」もご覧ください。
</p>

<!-- プレビューのカード。ページに 1 つだけ置き、乗ったリンクの中身に入れ替えて使う。
     リンクの文と同じ内容の飾りなので、読み上げからは外す -->
<div class="link-preview" aria-hidden="true">
  <div class="link-preview__content">
    <div class="link-preview__media"><img class="link-preview__image" alt="" width="320" height="160"></div>
    <div class="link-preview__body">
      <p class="link-preview__label"></p>
      <p class="link-preview__title"></p>
      <p class="link-preview__text"></p>
    </div>
  </div>
</div>

CSS
.preview-link {
  color: inherit;
  text-decoration-color: rgba(38, 38, 38, 0.35);
  text-decoration-thickness: 1px;
  text-underline-offset: 5px;
}

.preview-link:hover,
.preview-link:focus-visible { text-decoration-color: currentColor; }
.preview-link:focus-visible { border-radius: 2px; outline: 2px solid #262626; outline-offset: 2px; }

/* カード。ページの座標に置き、記事と一緒にスクロールする。出し入れは GSAP の不透明度と visibility */
.link-preview {
  --surface: #ffffff;
  --ink: #262626;
  --mute: #6b6b6b; /* 分類の文字 */
  --line: rgba(38, 38, 38, 0.12); /* 縁 */
  position: absolute;
  top: 0;
  left: 0;
  z-index: 100;
  width: min(320px, calc(100vw - 16px));
  overflow: hidden;
  border-radius: 4px;
  background-color: var(--surface);
  color: var(--ink);
  visibility: hidden;
  opacity: 0; /* visibility だけにすると、最初のフェードの前に一瞬見える */
}

/* 縁の線は、画像の上にも引くように重ねて描く(影は付けない) */
.link-preview::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px var(--line);
  pointer-events: none;
}

/* 画像は枠の中で拡大して引くので、枠で切る */
.link-preview__media { height: 160px; overflow: hidden; background-color: #e9e6df; }
.link-preview__image { display: block; width: 100%; height: 100%; object-fit: cover; }
.link-preview__body { padding: 16px 20px 20px; }
.link-preview__label { margin: 0; color: var(--mute); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; }
.link-preview__title { margin: 10px 0 0; font-size: 20px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.link-preview__text { margin: 6px 0 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; }

/* ホバーの無い端末(タッチ)では、押すとすぐ移るので出さない */
@media (hover: none) {
  .link-preview { display: none; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const DELAY = 0.3; // 乗ってから出るまで。通り過ぎただけのリンクでは出さない
const HIDE_DELAY = 0.25; // 離れてから消え始めるまで。この間にカードへ移れば消えない
const WARM = 0.5; // 消えてからこの間に別のリンクへ乗ったら、待たずに出す
const GAP = 12; // リンクとカードのすき間

const card = document.querySelector(".link-preview");
const content = card.querySelector(".link-preview__content");
const image = card.querySelector(".link-preview__image");
const [label, title, text] = ["label", "title", "text"].map((name) => card.querySelector(`.link-preview__${name}`));
let current = null; // カードが出ているリンク
let wait, hideCall, warm; // 出るまでの待ち・消えるまでの猶予・待たずに出す間の、それぞれの delayedCall

function fill(link) {
  const data = link.dataset;
  image.src = data.previewImage;
  label.textContent = data.previewLabel;
  title.textContent = data.previewTitle;
  text.textContent = data.previewText;
}

// リンクの 1 行目の中央の上に置く。上に入らなければ下に置き、左右は画面の中に収める(ページの座標で返す)
function place(link) {
  const r = link.getClientRects()[0];
  const above = r.top - GAP - card.offsetHeight;
  const x = gsap.utils.clamp(8, document.documentElement.clientWidth - card.offsetWidth - 8, r.left + r.width / 2 - card.offsetWidth / 2);
  return { x: x + scrollX, y: (above >= 8 ? above : r.bottom + GAP) + scrollY };
}

function show(link) {
  current = link;
  fill(link);
  const { x, y } = place(link);
  gsap.killTweensOf([card, content, image]);
  gsap.set(content, { opacity: 1 });
  gsap
    .timeline()
    .set(card, { x, visibility: "visible" })
    .fromTo(card, { opacity: 0 }, { opacity: 1, duration: 0.15, ease: "none" }, 0)
    .fromTo(card, { y: y + 8 }, { y, duration: 0.35, ease: "power3.out" }, 0) // 8px 下から浮かぶ
    .fromTo(image, { scale: 1.12 }, { scale: 1, duration: 0.6, ease: "power2.out" }, 0) // 画像は寄った所から引く
    .timeScale(SPEED);
}

// 出ているときは待たずに、カードを滑らせて中身だけ替える(前の中身が消えきってから替える)
function swap(link) {
  if (link === current) return;
  current = link;
  const { x, y } = place(link);
  gsap.killTweensOf(card, "x,y");
  gsap.killTweensOf([content, image]);
  gsap
    .timeline()
    .to(card, { x, y, duration: 0.35, ease: "power3.out" }, 0)
    .to(content, { opacity: 0, duration: 0.1, ease: "none" }, 0)
    .call(fill, [link], 0.1)
    .to(content, { opacity: 1, duration: 0.15, ease: "none" }, 0.1)
    .fromTo(image, { scale: 1.12 }, { scale: 1, duration: 0.6, ease: "power2.out", immediateRender: false }, 0.1)
    .timeScale(SPEED);
}

function hide() {
  if (!current) return;
  current = null;
  gsap.killTweensOf(card);
  gsap.to(card, { opacity: 0, y: "+=4", duration: 0.15 / SPEED, ease: "none", onComplete: () => gsap.set(card, { visibility: "hidden" }) });
  warm?.kill();
  warm = gsap.delayedCall(WARM, () => (warm = null));
}

function enter(link) {
  hideCall?.kill();
  wait?.kill();
  if (current) swap(link);
  else if (warm) show(link);
  else wait = gsap.delayedCall(DELAY, () => show(link));
}

function leave() {
  wait?.kill();
  hideCall?.kill();
  if (current) hideCall = gsap.delayedCall(HIDE_DELAY, hide);
}

document.querySelectorAll(".preview-link").forEach((link) => {
  // タッチでは押すとすぐ移るので、マウスのときだけ出す
  link.addEventListener("pointerenter", (event) => event.pointerType === "mouse" && enter(link));
  link.addEventListener("pointerleave", leave);
  // キーボードで焦点が来たときは、待たずに出す
  link.addEventListener("focus", () => link.matches(":focus-visible") && (hideCall?.kill(), current ? swap(link) : show(link)));
  link.addEventListener("blur", leave);
});
card.addEventListener("pointerenter", () => hideCall?.kill());
card.addEventListener("pointerleave", leave);
document.addEventListener("keydown", (event) => event.key === "Escape" && hide());

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

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