デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- 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();
});
});