本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
カーソル

カーソルが通った本文の語に淡いマーカーが引かれ、少しずつ消えていく

デモ

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

仕様

用途
カーソル
動作
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 の変数)と、速さのしきい値・残る時間・消える時間(スクリプトの定数)です。スクロールで決まった語に引く強調と違い、読む人がいま読んでいる所をその場で照らすだけなので、本文には何も残りません。カーソルの周りを照らす演出や絵の具を残す演出とも違い、文字そのものに印が付きます。ポインタの無い端末ではつながず、動きを減らす設定では帯を一度に引いて薄れ方だけを残します。コラムや読みもの、説明の長い製品ページに向いています。

コード

HTML
<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>

CSS
.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;
}

JavaScript
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;
  });
});

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

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