デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- 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 など)から作れます。ブログやドキュメントの関連記事へのリンク、用語集、人の名前から開くプロフィールに向いています。
コード
<!-- 記事の中のリンク。行き先の分類・題・ひとこと・画像を 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>
.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; }
}
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());