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

カーソルの周りの丸い範囲だけ隠れた画像が見える

デモ

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

仕様

用途
カーソル
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

暗いヒーローの上に、同じ配置で明るい画像と隠れたメッセージを描いた層を重ね、カーソルの周りの丸い範囲だけを見せます(懐中電灯)。見せる範囲は mask-image の radial-gradient で決め、光の中心と半径を CSS 変数にして、スクリプトからは変数だけを書き換えます。見出しは、下のページでは輪郭だけ、明るい層では墨の塗りにしてあるので、照らした所だけ文字が浮かび上がります。明るい層の文字は下のページを写して作るので、位置が 1px もずれません。見出しの行は、行末の「、」や「。」を数えず、その手前までの字面の左右の端の中央でそろえ(約物は右へぶら下げます)、写しにも同じずれを当てます。約物まで含めて中央にそろえると、約物の空きの分だけ字が左へ寄って見えます。

光の中心は quickTo で 0.35 秒遅れて追い、ヒーローに入ると半径が 0 から 194px へ 0.5 秒の power3.out で広がり、出ると 0.45 秒で閉じます。縁は、半径の 0.3 倍まではそのまま見せ、そこから半径の 1.34 倍まで数段で薄くして、広く柔らかくぼかしています。filter の blur でぼかすより軽く、光の大きさを変えても同じ柔らかさになります。中心には小さな白い丸を遅れずに付け、本物のカーソルはヒーローの中だけ隠します。

写した層の中のリンクは、そのままだと読み上げでもタブ移動でも 2 度数えられます。明るい層には inert と aria-hidden を付け、写した要素の id は外します。また、明るい層は下のページより手前にあるので、キーボードで移ったリンクの焦点の枠が層に隠れます。写しの同じリンクにも枠を描いておくと、照らされていても焦点の位置が見えます。

指で触る端末では明るい層を出さず、見出しを塗って、暗いページだけで読めるようにします。キーボードで中のリンクへ移ったときは、そのリンクを照らします。動きを減らす設定では、遅れと広がる動きを無くして、その場に光を出します。

つまみは、光の半径、縁を薄くする段、明るい層の地のグラデーションと飾りです。光を当てたくなる見出しの後ろに、明るい層でいちばん明るい所(太陽)が来るよう地の向きを決めると、照らしたときの驚きが大きくなります。同じ中身に光の色を足すだけの照らし方と違い、照らすと中身そのものが別の画像に替わるので、キャンペーンやイベントのヒーロー、隠し要素のある 404 ページ、製品の中を透かして見せる紹介に向いています。

コード

HTML
<section class="spotlight" aria-labelledby="spotlight-title">
  <!-- 暗いページ。光が無くても、これだけで読めるようにしておく -->
  <div class="spotlight__page">
    <p class="spotlight__eyebrow">Vol. 03 — The hidden issue</p>
    <h1 class="spotlight__title" id="spotlight-title"><span class="spotlight__line">目に見えるものが、</span><span class="spotlight__line">すべてじゃない。</span></h1>
    <p class="spotlight__lead">カーソルを動かしてみてください。</p>
    <a class="spotlight__link" href="/subscribe/">購読する</a>
  </div>

  <!-- 照らした所だけ見える明るい層。JS が上のページを写して、飾りとメッセージの間に重ねる。読み上げとタブ移動からは外す -->
  <div class="spotlight__lit" aria-hidden="true" inert>
    <span class="spotlight__sun"></span>
    <span class="spotlight__planet"></span>
    <span class="spotlight__stripes"></span>
    <span class="spotlight__star"></span>
    <p class="spotlight__note" style="--left: 15%; --top: 25%; --tilt: -7deg">見つけましたね。</p>
    <p class="spotlight__note spotlight__note--dark" style="--left: 75%; --top: 75%; --tilt: 5deg">じつは、仲間を募集中です。</p>
    <p class="spotlight__note" style="--left: 44%; --top: 15%; --tilt: 3deg">のぞいてくれて、ありがとう。</p>
  </div>

  <!-- 光の中心の目印(本物のカーソルの代わり) -->
  <span class="spotlight__dot" aria-hidden="true"></span>
</section>

CSS
.spotlight {
  --bg: #111111;
  --ink: #eeece6;
  --mute-small: #a8a6a0; /* 16px 未満の文字 */
  --line: rgb(238 236 230 / 0.14);
  --dark: #111111; /* 明るい層の文字と飾り */
  --lit-bg: linear-gradient(135deg, #9a8ebd 0%, #aea0cf 24%, #d99590 52%, #d9ba92 78%, #eae1b3 100%);
  --radius: 194px; /* 光の半径(JS が読むので px で書く) */
  /* 暗いページの文字。明るい層では下の .spotlight__lit で差し替える */
  --fg: var(--ink);
  --label: var(--mute-small);
  --title-fill: var(--bg);
  --title-line: rgb(238 236 230 / 0.42);
  position: relative;
  overflow: hidden;
  background: radial-gradient(circle, var(--line) 0 1px, transparent 1.6px) 0 0 / 32px 32px, var(--bg);
}
/* 光を出さないとき(指で触る端末・スクリプトが動かないとき)は、見出しを塗って読みやすくする */
.spotlight:not(.has-spotlight) { --title-fill: var(--ink); --title-line: transparent; }

.spotlight__page {
  position: relative; /* 明るい層の飾り(position を持つ)より手前に重ねるため */
  display: grid;
  place-content: center;
  justify-items: center;
  min-height: 100svh;
  box-sizing: border-box;
  padding: 96px clamp(16px, 7.5vw, 96px);
  color: var(--fg);
  text-align: center;
}
.spotlight__eyebrow { margin: 0; color: var(--label); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
/* 暗いページでは輪郭だけ。地の色で塗り、線を塗りの下に回して、字の継ぎ目の線を隠す */
.spotlight__title {
  margin: 24px 0 0;
  color: var(--title-fill);
  font-size: clamp(34px, 9vw, 112px);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
  -webkit-text-stroke: 2px var(--title-line);
  paint-order: stroke fill;
}
.spotlight__line { display: block; } /* 行ごとの箱。JS が行ごとに字面の中央をそろえる */
.spotlight__lead { margin: 32px 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.spotlight__link { padding: 0 24px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--fg); color: var(--fg); font-size: 14px; font-weight: 600; line-height: 40px; letter-spacing: 0.04em; text-decoration: none; }
.spotlight__link:focus-visible,
.spotlight__link.is-focus { outline: 2px solid var(--fg); outline-offset: 4px; }

/* 明るい層: 光の丸(中心 --x --y・半径 --r)の中だけ見せる。縁は半径の 0.3 倍から 1.34 倍まで数段で薄くし、広くぼかす */
.spotlight__lit {
  --fg: var(--dark);
  --label: rgb(17 17 17 / 0.72);
  --title-fill: var(--dark);
  --title-line: transparent;
  position: absolute;
  inset: 0;
  overflow: hidden;
  visibility: hidden;
  pointer-events: none;
  background: var(--lit-bg);
  -webkit-mask-image: radial-gradient(circle calc(var(--r, 0px) * 1.34) at var(--x, 0px) var(--y, 0px), #000 22.4%, rgb(0 0 0 / 0.8) 40.3%, rgb(0 0 0 / 0.45) 64.2%, rgb(0 0 0 / 0.14) 85.1%, transparent 100%);
  mask-image: radial-gradient(circle calc(var(--r, 0px) * 1.34) at var(--x, 0px) var(--y, 0px), #000 22.4%, rgb(0 0 0 / 0.8) 40.3%, rgb(0 0 0 / 0.45) 64.2%, rgb(0 0 0 / 0.14) 85.1%, transparent 100%);
}
.spotlight__lit > span { position: absolute; translate: -50% -50%; aspect-ratio: 1; border-radius: 50%; }
.spotlight__sun { left: 70%; top: 58%; height: 54%; background: #f6efe0; box-shadow: 0 0 0 22px rgb(246 239 224 / 0.42), 0 0 0 44px rgb(246 239 224 / 0.18); }
.spotlight__planet { left: 10%; top: 98%; height: 64%; background: radial-gradient(circle at 35% 30%, #857ba5 0%, #59517b 58%, #484067 100%); }
.spotlight .spotlight__stripes { left: 86%; top: 83%; width: 28%; height: 6%; aspect-ratio: auto; border-radius: 0; background: repeating-linear-gradient(135deg, rgb(17 17 17 / 0.85) 0 9px, transparent 9px 22px); }
.spotlight .spotlight__star { left: 8%; top: 42%; height: 5%; border-radius: 0; background: var(--dark); clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%); }
/* 隠れたメッセージ。位置は中心で決める(文言の長さが変わっても、光を当てる所からずれない) */
.spotlight__note {
  position: absolute;
  left: var(--left);
  top: var(--top);
  margin: 0;
  padding: 0 20px;
  border-radius: 999px;
  background: #ffffff;
  box-shadow: 0 4px 0 var(--dark);
  color: var(--dark);
  font-size: 18px;
  font-weight: 700;
  line-height: 44px;
  letter-spacing: 0.04em;
  white-space: nowrap;
  translate: -50% -50%;
  rotate: var(--tilt);
}
.spotlight__note--dark { background: var(--dark); color: #d2b999; }

/* 光の中心の目印。JS がつないだときだけ出し、本物のカーソルを隠す */
.spotlight__dot { position: absolute; left: -8px; top: -8px; width: 16px; height: 16px; box-sizing: border-box; border: 2px solid var(--dark); border-radius: 50%; background: #ffffff; opacity: 0; pointer-events: none; }
.has-spotlight,
.has-spotlight a { cursor: none; }

JavaScript
import { gsap } from "gsap";

// 指で触る端末にはポインタが無いので、明るい層は出さず、暗いページだけで見せる
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// 見出しの行ごとに、行末の約物(「、」「。」)は数えず、その手前までの字面(インクの左右の端)の中央を行の中央にそろえる。
// 約物は右へぶら下がる(含めて中央にすると、字が左へ寄って見える)。明るい層の写しも同じだけずらす(em で持つ)
document.fonts.ready.then(() => {
  document.querySelectorAll(".spotlight__title").forEach((title) => {
    const style = getComputedStyle(title), ruler = document.createElement("canvas").getContext("2d");
    Object.assign(ruler, { font: `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`, letterSpacing: style.letterSpacing, textAlign: "left", lang: document.documentElement.lang || "ja" });
    title.querySelectorAll(".spotlight__line").forEach((line) => {
      const { width } = ruler.measureText(line.textContent), ink = ruler.measureText(line.textContent.replace(/[、。,.」』)]+$/u, ""));
      line.style.translate = `${(width / 2 - (ink.actualBoundingBoxRight - ink.actualBoundingBoxLeft) / 2) / parseFloat(style.fontSize)}em`;
    });
  });
});

document.querySelectorAll(".spotlight").forEach((hero) => {
  const page = hero.querySelector(".spotlight__page");
  const lit = hero.querySelector(".spotlight__lit");
  const dot = hero.querySelector(".spotlight__dot");
  if (!canHover || !lit) return;

  // 暗いページを写して明るい層に重ねる。同じ組みのまま色だけ替えるので、文字の位置が 1px もずれない
  const copy = page.cloneNode(true);
  copy.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id"));
  lit.querySelector(".spotlight__note").before(copy);
  hero.classList.add("has-spotlight");

  const radius = parseFloat(getComputedStyle(hero).getPropertyValue("--radius"));
  const light = { x: 0, y: 0, r: 0 }; // 光の中心と半径(px)
  const render = () => {
    lit.style.setProperty("--x", `${light.x}px`);
    lit.style.setProperty("--y", `${light.y}px`);
    lit.style.setProperty("--r", `${light.r}px`);
    lit.style.visibility = light.r < 0.5 ? "hidden" : "visible";
  };

  // 光は少し遅れて追う。動きを減らす設定では遅れずに置く(quickTo は duration が 0 だと値が動かない)
  const chase = (prop) =>
    reduceMotion
      ? (value) => {
          light[prop] = value;
          render();
        }
      : gsap.quickTo(light, prop, { duration: 0.35, ease: "power3", onUpdate: render });
  const moveX = chase("x");
  const moveY = chase("y");
  // jump なら追わずにその場から始める(閉じた光を、入った位置で開く)
  const aim = (x, y, jump) => {
    moveX(x, jump ? x : undefined);
    moveY(y, jump ? y : undefined);
  };
  const open = (on) => {
    const duration = reduceMotion ? 0 : on ? 0.5 : 0.45;
    gsap.to(light, { r: on ? radius : 0, duration, ease: on ? "power3.out" : "power2.inOut", overwrite: "auto", onUpdate: render });
  };

  const local = (event) => {
    const box = hero.getBoundingClientRect();
    return [event.clientX - box.left, event.clientY - box.top];
  };
  hero.addEventListener("pointerenter", (event) => {
    if (event.pointerType === "touch") return;
    const [x, y] = local(event);
    aim(x, y, light.r < 1);
    open(true);
    dot.style.opacity = "1";
  });
  hero.addEventListener("pointermove", (event) => {
    if (event.pointerType === "touch") return;
    const [x, y] = local(event);
    aim(x, y);
    dot.style.transform = `translate(${x}px, ${y}px)`; // 目印は遅れずに付く
  });
  hero.addEventListener("pointerleave", () => {
    open(false);
    dot.style.opacity = "0";
  });

  // キーボードで中のリンクへ移ったときは、そのリンクを照らす。明るい層の写しにも焦点の枠を描く(下の枠は層に隠れる)
  const focusables = (root) => [...root.querySelectorAll("a, button")];
  hero.addEventListener("focusin", (event) => {
    if (!event.target.matches(":focus-visible")) return;
    focusables(copy)[focusables(page).indexOf(event.target)]?.classList.add("is-focus");
    const box = hero.getBoundingClientRect();
    const r = event.target.getBoundingClientRect();
    aim(r.left + r.width / 2 - box.left, r.top + r.height / 2 - box.top, light.r < 1);
    open(true);
  });
  hero.addEventListener("focusout", () => {
    copy.querySelector(".is-focus")?.classList.remove("is-focus");
    if (!hero.matches(":hover")) open(false);
  });
});

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

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