デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
文中のリンクには、カーソルを重ねる前から薄い線が引かれていて、本文の中でもリンクだとわかります。重ねるとその上を下線が左から伸び、離れると止まらずに右へ抜けていきます。薄い線に見えているのは、はみ出しを隠す細い要素の背景色です。その要素の中で、下線の位置だけを、左の外から要素いっぱいへ、そして右の外へと動かしています。
伸びるのは0.45秒の power3.out、抜けるのは同じ長さの power3.inOut。移動量は要素の幅の101%にして、止まったときに縁へ線の端が残らないようにしました。
scaleX と変形の原点の切り替えでも同じ見た目は作れます。ただそれだと、伸びきる前にカーソルが離れたとき、原点が左から右へ一気に移って線が跳ねます。位置だけで動かしていれば、途中で離れてもその場から右へ抜けます。
線を引く高さにも理由があります。和文には g や p のようにベースラインより下へはみ出す字がないので、欧文向けの位置に引くと字から離れて見えます。ここではベースラインのすぐ下、文字の大きさの0.19倍の位置に置きました。ベースラインは書体や行の高さで変わるので、高さ0の要素をリンクの先頭に差し込んで測り、書体の読み込みなどでリンクの大きさが変わるたびに測り直しています。
キーボードでフォーカスが来たときも同じように伸ばします。文中でフォーカスの枠線を表示すると隣の字にかかるので、枠線の代わりにこの下線でフォーカスを示しています。マウスでクリックしたときのフォーカスでは表示し続けません。続けると、カーソルが離れたあとも線が残ってしまいます。
リンクは折り返さない要素(white-space を nowrap)にしてあるので、2行にまたがる長いリンクには使えません。そういうリンクには、背景のグラデーションの幅で下線を描く方法なら、行をまたいで伸ばせます。
コード
<!-- 下線のトラック(track)と線(line)は飾りなので、スクリーンリーダーから外す -->
<p>
使う人にとってのわかりやすさを、いちばんに考えて設計しています。詳しくは<a class="underline-link" href="/principles/">デザインの原則<span class="underline-link__track" aria-hidden="true"><span class="underline-link__line"></span></span></a>をご覧ください。
</p>
.underline-link {
--ink: #262626; /* 文字と下線の色 */
--line: rgba(38, 38, 38, 0.12); /* 下線のトラックの背景色。ホバーする前からリンクだとわかる */
--baseline: 1.2em; /* 要素の上端からベースラインまで。script が測って入れ直す */
position: relative;
display: inline-block;
color: var(--ink);
font-weight: 600; /* 本文(400)と太さで分ける */
text-decoration: none;
white-space: nowrap; /* 2 行にまたがると、下線のトラックが 1 本にならない */
}
/* 下線のトラック。和文は下へはみ出す字が無いので、ベースラインのすぐ下に置く */
.underline-link__track {
position: absolute;
top: calc(var(--baseline) + 0.19em);
right: 0;
left: 0;
height: max(2px, 0.075em);
overflow: hidden; /* トラックの外の線を隠す。左から伸びて右へ抜けるように見える */
background-color: var(--line);
}
.underline-link__line {
display: block;
height: 100%;
background-color: var(--ink);
transform: translateX(-101%); /* 読み込むまでのあいだ、線をトラックの左の外に置いておく */
}
/* フォーカス時は、ホバーしたときと同じく下線を伸ばして見せる(文中でアウトラインを引くと、隣の字にかかる)。
透明なアウトラインは、強制カラー(Windows のハイコントラスト)のときだけ見える */
.underline-link:focus-visible {
outline: 2px solid transparent;
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 動きを減らす設定では、伸び縮みさせずに一瞬で切り替える
const DURATION = reduceMotion ? 0 : 0.45;
// 要素の上端からベースラインまでを測って --baseline に入れる。ベースラインは書体と行の高さで決まるので、
// リンクの大きさが変わるたび(書体の読み込み・行の高さの切り替え)に測り直す
const baseline = new ResizeObserver((entries) => {
for (const { target: link } of entries) {
// 高さ 0 の inline-block は、下端をベースラインにそろえて置かれる
const probe = document.createElement("span");
probe.style.cssText = "display:inline-block;width:0;height:0;";
link.prepend(probe);
link.style.setProperty("--baseline", `${probe.offsetTop}px`);
probe.remove();
}
});
document.querySelectorAll(".underline-link").forEach((link) => {
const line = link.querySelector(".underline-link__line");
// CSS の translateX(-101%) を GSAP は px の x として読むので、x を 0 に戻して xPercent に持ち替える
gsap.set(line, { x: 0, xPercent: -101 });
baseline.observe(link);
let hovered = false;
let focused = false;
let shown = false;
// ホバーしているか、キーボードのフォーカスがあるあいだ、下線を表示する
const update = () => {
const on = hovered || focused;
if (on === shown) return;
shown = on;
if (on) {
// 左の外からトラックいっぱいまで伸ばす
gsap.fromTo(line, { xPercent: -101 }, { xPercent: 0, duration: DURATION, ease: "power3.out", overwrite: true });
} else {
// 今の位置から右の外へ抜く(伸びきる前に離れても、その場から抜ける)
gsap.to(line, { xPercent: 101, duration: DURATION, ease: "power3.inOut", overwrite: true });
}
};
link.addEventListener("pointerenter", () => {
hovered = true;
update();
});
link.addEventListener("pointerleave", () => {
hovered = false;
update();
});
// マウスで押したときのフォーカスでは表示し続けない(カーソルが離れても線が残ってしまう)
link.addEventListener("focus", () => {
focused = link.matches(":focus-visible");
update();
});
link.addEventListener("blur", () => {
focused = false;
update();
});
});