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

十字の線が画面いっぱいに伸びてカーソルを追い、座標の数字がついてくる

デモ

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

仕様

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

しくみと調整

縦と横の 1px の線を、ヒーローの全面に重ねた層に置き、ポインタが動くたびに線と座標の札を動かします。線はヒーローの幅と高さいっぱいの長さで用意しておき、見える長さを clip-path の inset で決めて、交点から上下左右へ同じだけ開きます。ヒーローに入ると交点から端まで伸び、出ると交点へ縮みます。札の数字は、十字の今の位置をヒーローの左上からの px で 4 桁にそろえて出すので、動いているあいだは数字が回って見えます。

十字は 0.3 秒、札は 0.55 秒で追いつくように、どちらも quickTo で追わせています。遅れに差があるので、動かすと札が十字から離れてたなびき、止まると交点の右下 16px に戻って寄り添います。線が伸びるのは 0.8 秒の power3.out で、最初の一瞬で画面の端まで届き、縮むときは 0.45 秒の power2.in で交点へ吸い込まれます。

札の数字は tabular-nums で等幅にします。等幅でないと、数字が変わるたびに札の幅が揺れ、右端と下端で札を交点の反対側へ回す判定まで震えます。線と札の層には pointer-events の none を付け、下のリンクや画像へポインタを通します。本物のカーソルを隠すのは、十字を出したヒーローの中だけです。

指で触る端末ではつながず、本物のカーソルのままにします。キーボードで中のリンクへ移ったときは、そのリンクの中央に十字を合わせて伸ばします。動きを減らす設定では、遅れと伸びる動きを無くして、その場に十字と札を出します。

つまみは、線の濃さ、二つの追いつく時間、札を離す量です。線を罫線ほど淡くすると図面の補助線のように静かになり、濃くすると照準のような張り詰めた印象になります。輪や点が追いかける独自カーソルと違い、画面の縦横の位置そのものを見せるので、設計事務所や写真家のポートフォリオ、寸法や配置を見せたいプロダクトのヒーローに向いています。

コード

HTML
<section class="crosshair-hero" aria-labelledby="crosshair-hero-title">
  <div class="crosshair-hero__text">
    <p class="crosshair-hero__eyebrow">Architecture</p>
    <h1 class="crosshair-hero__title" id="crosshair-hero-title">暮らしに合わせて、<br>設計する。</h1>
    <p class="crosshair-hero__lead">ここに説明文が入ります。</p>
    <a class="crosshair-hero__link" href="/projects/">作品を見る</a>
  </div>
  <img class="crosshair-hero__image" src="/img/house-itoshima.jpg" alt="白い箱が張り出した平屋の住宅の外観" width="960" height="960">

  <!-- 十字の線と座標の札。飾りなので読み上げから外す -->
  <div class="crosshair" aria-hidden="true">
    <span class="crosshair__line crosshair__line--v"></span>
    <span class="crosshair__line crosshair__line--h"></span>
    <span class="crosshair__box"></span>
    <span class="crosshair__tag"><span>X 0000</span><span>Y 0000</span></span>
  </div>
</section>

CSS
.crosshair-hero {
  --bg: #f2f1ed;
  --ink: #262626;
  --mute: #6b6b6b;
  --line: rgba(38, 38, 38, 0.72); /* 十字の線。写真の上でも見える濃さ */
  position: relative;
  display: grid;
  grid-template-columns: 1fr minmax(0, 480px);
  align-items: center;
  gap: 48px;
  min-height: 100vh;
  box-sizing: border-box;
  padding: 96px clamp(24px, 7.5vw, 96px);
  overflow: hidden;
  background: var(--bg);
  color: var(--ink);
}

.crosshair-hero__eyebrow {
  margin: 0 0 24px;
  color: var(--mute);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.crosshair-hero__title {
  margin: 0;
  font-size: clamp(32px, 5vw, 64px);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
  word-break: keep-all; /* 和文の見出しを語の途中で折らない */
}

.crosshair-hero__lead {
  margin: 24px 0 32px;
  font-size: 18px;
  line-height: 1.8;
  letter-spacing: 0.04em;
  word-break: auto-phrase;
}

.crosshair-hero__link {
  display: inline-flex;
  align-items: center;
  height: 48px;
  padding: 0 32px;
  border: 1px solid var(--ink);
  border-radius: 999px;
  color: var(--ink);
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
}

.crosshair-hero__link:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.crosshair-hero__image { width: 100%; height: auto; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }

@media (max-width: 767px) {
  .crosshair-hero { grid-template-columns: 1fr; }
}

/* 下のリンクを押せるよう、十字はポインタを通す。JS がつないだときだけ出し、本物のカーソルを隠す */
.crosshair { position: absolute; inset: 0; display: none; pointer-events: none; }
.has-crosshair .crosshair { display: block; }
.has-crosshair, .has-crosshair a { cursor: none; }
.crosshair > * { position: absolute; left: 0; top: 0; }
.crosshair__line { background: var(--line); }
.crosshair__line--v { width: 1px; height: 100%; }
.crosshair__line--h { width: 100%; height: 1px; }

/* 交点の小さな四角。transform で交点に置くので、大きさの半分だけ戻す */
.crosshair__box { width: 11px; height: 11px; margin: -5px 0 0 -5px; box-sizing: border-box; border: 1px solid var(--ink); background: var(--bg); }

/* 数字は等幅にする。幅が変わると、札が端で回り込む判定まで揺れる */
.crosshair__tag {
  display: flex;
  gap: 12px;
  align-items: center;
  height: 28px;
  padding: 0 10px;
  border-radius: 2px;
  background: var(--ink);
  color: #fff;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.04em;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

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 digits = (value) => String(Math.max(0, Math.round(value))).padStart(4, "0");

document.querySelectorAll(".crosshair-hero").forEach((hero) => {
  const cross = hero.querySelector(".crosshair");
  if (!canHover || !cross) return;
  const [vline, hline] = cross.querySelectorAll(".crosshair__line");
  const box = cross.querySelector(".crosshair__box");
  const tag = cross.querySelector(".crosshair__tag");
  const [textX, textY] = tag.children;
  hero.classList.add("has-crosshair");

  const pos = { x: 0, y: 0, reach: 0 }; // 十字の位置と伸び具合(0〜1)
  const spot = { x: 0, y: 0, show: 0 }; // 札の位置と開き具合(0〜1)
  let size = hero.getBoundingClientRect();

  // 線の長さは clip-path で決める。交点から上下左右へ同じ長さだけ開く
  const render = () => {
    const { width, height } = size;
    const r = pos.reach * Math.hypot(width, height);
    vline.style.transform = `translateX(${pos.x}px)`;
    vline.style.clipPath = `inset(${Math.max(0, pos.y - r)}px 0 ${Math.max(0, height - pos.y - r)}px)`;
    hline.style.transform = `translateY(${pos.y}px)`;
    hline.style.clipPath = `inset(0 ${Math.max(0, width - pos.x - r)}px 0 ${Math.max(0, pos.x - r)}px)`;
    box.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
    box.style.opacity = pos.reach > 0.02 ? 1 : 0;
    tag.style.transform = `translate(${spot.x}px, ${spot.y}px)`;
    tag.style.clipPath = `inset(0 ${(1 - spot.show) * 100}% 0 0)`;
    textX.textContent = `X ${digits(pos.x)}`;
    textY.textContent = `Y ${digits(pos.y)}`;
  };

  // 札を十字より遅らせる。動きを減らす設定では遅れずに置く(quickTo は duration が 0 だと値が動かない)
  const chase = (target, prop, duration) =>
    reduceMotion
      ? (value) => {
          target[prop] = value;
          render();
        }
      : gsap.quickTo(target, prop, { duration, ease: "power3", onUpdate: render });
  const lineX = chase(pos, "x", 0.3);
  const lineY = chase(pos, "y", 0.3);
  const tagX = chase(spot, "x", 0.55);
  const tagY = chase(spot, "y", 0.55);

  // 札は交点の右下。右端・下端に近いときは反対側へ回す。jump なら追わずにその場へ置く
  const aim = (x, y, jump) => {
    const tx = x + 16 + tag.offsetWidth > size.width - 24 ? x - 16 - tag.offsetWidth : x + 16;
    const ty = y + 16 + tag.offsetHeight > size.height - 24 ? y - 16 - tag.offsetHeight : y + 16;
    lineX(x, jump ? x : undefined);
    lineY(y, jump ? y : undefined);
    tagX(tx, jump ? tx : undefined);
    tagY(ty, jump ? ty : undefined);
  };

  const toggle = (on) => {
    const ease = on ? "power3.out" : "power2.in";
    gsap.to(pos, { reach: on ? 1 : 0, duration: reduceMotion ? 0 : on ? 0.8 : 0.45, ease, overwrite: "auto", onUpdate: render });
    gsap.to(spot, { show: on ? 1 : 0, duration: reduceMotion ? 0 : 0.3, ease, overwrite: "auto", onUpdate: render });
  };

  const local = (event) => {
    size = hero.getBoundingClientRect();
    return [event.clientX - size.left, event.clientY - size.top];
  };

  hero.addEventListener("pointerenter", (event) => {
    if (event.pointerType === "touch") return;
    const [x, y] = local(event);
    aim(x, y, pos.reach < 0.02); // 縮みきっていれば、入った位置から伸ばす
    toggle(true);
  });
  hero.addEventListener("pointermove", (event) => {
    if (event.pointerType !== "touch") aim(...local(event));
  });
  hero.addEventListener("pointerleave", () => toggle(false));

  // キーボードで中のリンクに移ったときは、その右隣に十字を合わせる(交点の四角で文字を隠さない)
  hero.addEventListener("focusin", (event) => {
    if (!event.target.matches(":focus-visible")) return;
    size = hero.getBoundingClientRect();
    const r = event.target.getBoundingClientRect();
    aim(r.right + 16 - size.left, r.top + r.height / 2 - size.top, pos.reach < 0.02);
    toggle(true);
  });
  hero.addEventListener("focusout", () => hero.matches(":hover") || toggle(false));

  window.addEventListener("resize", () => {
    size = hero.getBoundingClientRect();
    render();
  });
  render();
});

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

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