デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
本文の段落を、HTML に入れた <wbr> の区切りごとに inline-block の span に包み、1 つずつを文節として扱います。ポインタが乗っている語には、背景の linear-gradient で帯を描きます。帯は語の左端から、語の中をポインタが進んだ距離だけ伸びるので、ペンでなぞったようにカーソルに付いて引かれます。止まっていても 0.45 秒で引ききります。離れた語は、しばらくそのまま残ってから薄れて消えます。
帯の濃さは、語の幅を「その語の上にいた時間」で割った値、つまり語を通った速さで決めます。秒速 300px より遅ければいちばん太く濃く(字の下半分・濃さ 0.8)、1500px より速ければいちばん細く淡く(高さ 0.18em・濃さ 0.5)なります。色は蛍光ペンらしい鮮やかな黄です。黄は明るいので、濃く引いても字が読めます。太い帯は離れてから 1.2 秒残って 3 秒かけて消え、細い帯は 0.3 秒後から 1.2 秒で消えます。止まったままの語は時間とともに太くなるので、指し示しながら読んだ所ほど濃く長く残ります。
帯の縦の位置を span の箱に対する割合で決めると、和文では字の下へずれます。箱の高さは先頭に並べた欧文の書体で決まるからです。そこで高さ 0 の inline-block を語の頭に差し込んでベースラインを測り、帯の下端をその 0.14em 下に置きます。また、和文は語の途中でも折り返すので、文節を inline-block にして行の途中で割れないようにし、word-break を keep-all にして <wbr> の所でだけ折り返させます。
つまみは、帯の色・濃さ・高さ(CSS の変数)と、速さのしきい値・残る時間・消える時間(スクリプトの定数)です。スクロールで決まった語に引く強調と違い、読む人がいま読んでいる所をその場で照らすだけなので、本文には何も残りません。カーソルの周りを照らす演出や絵の具を残す演出とも違い、文字そのものに印が付きます。ポインタの無い端末ではつながず、動きを減らす設定では帯を一度に引いて薄れ方だけを残します。コラムや読みもの、説明の長い製品ページに向いています。
コード
<article class="marker-article">
<p class="marker-article__label">COLUMN</p>
<h2 class="marker-article__title">線を引きながら読む</h2>
<p class="marker-article__meta">コラム・2026年9月30日・読了 4分</p>
<!-- 文節の区切りに <wbr> を入れる。区切りごとにマーカーが引かれる(段落の中で改行しない) -->
<div class="marker-trail">
<p>気になった<wbr>一文に<wbr>線を<wbr>引きながら<wbr>読むと、<wbr>あとで<wbr>見返したときに、<wbr>どこで<wbr>立ち止まったのかが<wbr>すぐに<wbr>わかります。</p>
<p>紙の<wbr>本なら<wbr>蛍光ペンを<wbr>使う<wbr>ところですが、<wbr>画面の<wbr>上では<wbr>カーソルが<wbr>その<wbr>代わりに<wbr>なります。<wbr>なぞった<wbr>跡は<wbr>数秒で<wbr>薄れていくので、<wbr>本文を<wbr>汚さずに、<wbr>いま<wbr>読んでいる<wbr>場所だけを<wbr>照らせます。</p>
<p>ゆっくり<wbr>なぞった<wbr>語ほど<wbr>濃く<wbr>残り、<wbr>さっと<wbr>通り過ぎた<wbr>語は<wbr>かすかに<wbr>残ります。<wbr>読む<wbr>速さが、<wbr>そのまま<wbr>線の<wbr>太さに<wbr>なります。</p>
</div>
</article>
.marker-article {
max-width: 720px;
margin: 0 auto;
padding: 96px 16px;
color: #262626;
}
.marker-article__label {
margin: 0;
color: #6b6b6b;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
}
.marker-article__title {
margin: 16px 0 0;
font-size: clamp(28px, 5vw, 40px);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.02em;
}
.marker-article__meta {
margin: 16px 0 0;
color: #6b6b6b;
font-size: 13px;
letter-spacing: 0.06em;
}
.marker-trail {
/* マーカーの色・濃さ(細いとき・太いとき)・高さ(細いとき・太いとき)・ベースラインから下げる量 */
--marker: #ffe500; /* 蛍光ペンの黄 */
--alpha-thin: 0.5;
--alpha-thick: 0.8;
--thin: 0.18em;
--thick: 0.5em;
--drop: 0.14em;
margin-top: 40px;
font-size: 18px;
line-height: 1.8;
letter-spacing: 0.04em;
/* <wbr> の所でだけ折り返し、文節の途中で行を割らない */
word-break: keep-all;
}
.marker-trail p {
margin: 24px 0 0;
}
.marker-trail p:first-child {
margin-top: 0;
}
/* スクリプトが文節ごとに包む span。--draw(引いた長さ)・--ink(濃さ)・--fade(残り)は 0〜1 */
.marker-word {
--draw: 0;
--ink: 0;
--fade: 0;
--h: calc(var(--thin) + (var(--thick) - var(--thin)) * var(--ink));
--bottom: calc(var(--base, 1.25em) + var(--drop));
--alpha: calc((var(--alpha-thin) + (var(--alpha-thick) - var(--alpha-thin)) * var(--ink)) * var(--fade) * 100%);
display: inline-block;
background: linear-gradient(transparent calc(var(--bottom) - var(--h)), color-mix(in srgb, var(--marker) var(--alpha), transparent) 0 var(--bottom), transparent 0) 0 0 / calc(var(--draw) * 100%) 100% no-repeat;
}
import { gsap } from "gsap";
// 指で触る端末にはなぞるポインタが無いので、本文はそのまま読ませる
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const DRAW = reduceMotion ? 0 : 0.45; // 止まっていても帯を引ききる時間(秒)。動きを減らす設定では一度に引く
const HOLD = [0.3, 1.2]; // 離れてから薄れ始めるまで(秒。細い帯・太い帯)
const FADE = [1.2, 3.0]; // 薄れて消えるまで(秒。細い帯・太い帯)
const SPEED = [300, 1500]; // 語を通る速さ(px/秒)。これより遅いといちばん太く、速いといちばん細い
const clamp01 = gsap.utils.clamp(0, 1);
const lerp = ([a, b], k) => a + (b - a) * k;
const drawEase = gsap.parseEase("power2.out");
const fadeEase = gsap.parseEase("power1.inOut");
// 語の幅 ÷ 上にいた時間(その語を通った速さ)から、帯の濃さ(0〜1)。止まっていれば時間とともに濃くなる
const inkOf = (s) => (s.dwell > 0 ? clamp01((SPEED[1] - s.w / s.dwell) / (SPEED[1] - SPEED[0])) : 0);
// 離れてからの残り。濃い帯ほど長く残る
const fadeOf = (s, now) => 1 - fadeEase(clamp01((now - s.out - lerp(HOLD, inkOf(s))) / lerp(FADE, inkOf(s))));
document.querySelectorAll(".marker-trail").forEach(async (trail) => {
if (!canHover) return;
// <wbr> で区切った文節を、1 つずつ span に包む(中のリンクや強調はそのまま移す)
trail.querySelectorAll("p").forEach((p) => {
let word = null;
for (const node of [...p.childNodes]) {
if (node.nodeName === "WBR") {
node.remove();
word = null;
continue;
}
if (!word) {
word = document.createElement("span");
word.className = "marker-word";
node.before(word);
}
word.append(node);
}
});
const words = trail.querySelectorAll(".marker-word");
if (!words.length) return;
// 帯の下端をベースラインにそろえるため、高さ 0 の inline-block でベースラインを測る(字の大きさとの比で持つ)
await document.fonts.ready;
const probe = document.createElement("span");
probe.style.cssText = "display:inline-block;width:0;height:0;";
words[0].prepend(probe);
const fontSize = parseFloat(getComputedStyle(trail).fontSize);
trail.style.setProperty("--base", `${(probe.getBoundingClientRect().bottom - words[0].getBoundingClientRect().top) / fontSize}em`);
probe.remove();
const states = new Map(); // 見えている語の { t0: 引き始め, dwell: 上にいた秒, moved: 中を進んだ px, out: 最後に乗っていた時刻, w: 幅 }
let current = null;
let last = null;
const paint = (word, s, now) => {
const draw = DRAW ? Math.max(clamp01(s.moved / s.w), drawEase(clamp01((now - s.t0) / DRAW))) : 1;
word.style.setProperty("--draw", draw.toFixed(3));
word.style.setProperty("--ink", inkOf(s).toFixed(3));
word.style.setProperty("--fade", fadeOf(s, now).toFixed(3));
};
// 見えている帯があるあいだだけ回す
const tick = (now, deltaMs) => {
if (current) {
const s = states.get(current);
s.dwell += deltaMs / 1000;
s.out = now;
}
states.forEach((s, word) => {
if (word !== current && fadeOf(s, now) <= 0) {
states.delete(word);
word.style.removeProperty("--draw");
return;
}
paint(word, s, now);
});
if (!states.size) gsap.ticker.remove(tick);
};
trail.addEventListener("pointermove", (event) => {
if (event.pointerType === "touch") return;
const word = event.target.closest(".marker-word");
const now = gsap.ticker.time;
if (word) {
let s = states.get(word);
// 消えきった語に入ったら、左から引き直す
if (!s || fadeOf(s, now) <= 0) states.set(word, (s = { t0: now, dwell: 0, moved: 0, out: now, w: word.offsetWidth }));
if (last) s.moved += Math.hypot(event.clientX - last.x, event.clientY - last.y);
gsap.ticker.add(tick);
}
current = word;
last = { x: event.clientX, y: event.clientY };
});
trail.addEventListener("pointerleave", () => {
current = null;
last = null;
});
});