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

カーソルに近い文字が押しのけられて、まわりに丸い空き地ができる

デモ

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

仕様

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

しくみと調整

大きな見出しを 1 文字ずつの inline-block の span に分け、ポインタとの距離だけで外へ押しのけます。押しのける量は、円の縁からの深さを余弦の山で表します。中心にいちばん近い文字が大きく動き、縁ではなめらかに 0 へ戻ります。向きは中心から文字へ向かうベクトルそのままなので、カーソルのまわりに丸い空き地ができます。明るさや色は変えず、位置と傾きだけで場所を空けるのがこの作りです。

円の半径は 152px、いちばん近い文字が動く量はその 55%(84px ほど)、傾きは最大 13° です。円の中心は quickTo で 0.3 秒かけてポインタに追いつくので、文字がわずかに遅れて逃げ、手を止めるとすっと収まります。見出しは文字そのものが見どころなので、節の見出しより大きい 88px にしています。

つまずきやすいのは、カーソルが文字の真上を通る瞬間です。押しのける向きがそこで裏返るので、そのままだと文字が左右に飛びます。中心から 30px の内側では量を弱め、真上でちょうど 0 になるようにすると、どこを通っても続けてつながります。文字の中心は、押しのけていない状態で測ってください。動かしたまま測ると、空き地が文字を追いかけて逃げ続けます。

つまみは、円の半径・押しのける量・傾き・追いつく時間の 4 つです。半径を広げると行をまたぐ大きな穴が開き、量を 0.3 ほどに下げると、字がわずかに身をよける控えめな表情になります。指で触る端末ではつながず、文字はそのまま読ませます。キーボードでボタンへ移ったときは、そのボタンの中央を円の中心にします。

ヒーローの見出しや、作品集の大きな文字の壁に向いています。文字は DOM のまま transform で動かすだけなので、読み上げにも検索にもそのまま届きます。

コード

HTML
<section class="repel" aria-labelledby="repel-title">
  <p class="repel__label">Design studio</p>
  <!-- 行の切れ目は <br> で決める。JS がこの中の文字だけを 1 字ずつの span に包む -->
  <h2 class="repel__title" id="repel-title">福岡を拠点に、<br>ブランドとWebを<br>ていねいにつくる<br>デザイン事務所。</h2>
  <p class="repel__lead">ここに説明文が入ります。</p>
  <a class="repel__cta" href="/contact/">相談する</a>
</section>

CSS
.repel {
  /* JS が読む値。px で書く(min() や vw のままだと数として読めない) */
  --reach: 152px;
  --push: 0.55;
  --tilt: 13;
  --soft: 30px;
  --ink: #262626;
  --bg: #f2f1ed;
  box-sizing: border-box;
  padding: 96px;
  background: var(--bg);
  color: var(--ink);
}

.repel__label {
  margin: 0;
  color: #6b6b6b;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  line-height: 1;
  text-transform: uppercase;
}

.repel__title {
  margin: 24px 0 0;
  font-size: clamp(38px, 6.9vw, 88px);
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1.1;
}

/* 1 字ずつの箱。transform を効かせるために inline-block にする */
.repel__char {
  display: inline-block;
}

.repel__lead {
  margin: 40px 0 0;
  font-size: 18px;
  letter-spacing: 0.04em;
  line-height: 1.8;
  word-break: auto-phrase; /* 和文を文節で折り返す */
}

.repel__cta {
  display: inline-flex;
  align-items: center;
  height: 48px;
  margin-top: 24px;
  padding: 0 32px;
  border-radius: 999px;
  background: var(--ink);
  color: var(--bg);
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
}

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

/* 狭い画面では円も小さくする(JS が読むので px で書く) */
@media (max-width: 640px) {
  .repel {
    --reach: 110px;
    --soft: 22px;
    padding: 64px 16px;
  }
}

JavaScript
import { gsap } from "gsap";

const title = document.querySelector(".repel__title");
const cta = document.querySelector(".repel__cta");
// 指で触る端末にはポインタが無いので、文字はそのまま読ませる
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (title && canHover) {
  // 見出しの文字だけを 1 字ずつの span に包む(<br> はそのまま残す)
  const chars = [];
  for (const node of [...title.childNodes]) {
    if (node.nodeType !== Node.TEXT_NODE) continue;
    const parts = document.createDocumentFragment();
    for (const letter of node.textContent) {
      if (letter === " ") {
        parts.append(" ");
        continue;
      }
      const span = document.createElement("span");
      span.className = "repel__char";
      span.textContent = letter;
      parts.append(span);
      chars.push(span);
    }
    node.replaceWith(parts);
  }

  const state = { x: -9999, y: -9999, power: 0 };
  let reach = 0;
  let push = 0;
  let tilt = 0;
  let soft = 1;
  let centers = [];

  // 円の中心からの距離で、外へ押しのける量と傾きを決める(中心で最大、縁でなめらかに 0)
  const render = () => {
    chars.forEach((span, i) => {
      const dx = centers[i][0] - state.x;
      const dy = centers[i][1] - state.y;
      const d = Math.hypot(dx, dy) || 0.001;
      // 中心のすぐそばは弱める。真上を通ったときに文字が左右へ飛ぶのを防ぐ
      const k = d >= reach ? 0 : (0.5 + 0.5 * Math.cos((Math.PI * d) / reach)) * Math.min(1, d / soft) * state.power;
      const move = reach * push * k;
      span.style.transform = k < 0.001 ? "" : `translate(${Math.round((dx / d) * move)}px, ${Math.round((dy / d) * move)}px) rotate(${((dx / d) * tilt * k).toFixed(2)}deg)`;
    });
  };

  // 文字の中心は、押しのけていない状態で測る。字の大きさが変わるたびに測り直す
  const measure = () => {
    const style = getComputedStyle(title.closest(".repel"));
    reach = parseFloat(style.getPropertyValue("--reach"));
    push = parseFloat(style.getPropertyValue("--push"));
    tilt = parseFloat(style.getPropertyValue("--tilt"));
    soft = parseFloat(style.getPropertyValue("--soft"));
    chars.forEach((span) => (span.style.transform = ""));
    centers = chars.map((span) => {
      const r = span.getBoundingClientRect();
      return [r.left + r.width / 2, r.top + r.height / 2];
    });
    render();
  };

  // 動きを減らす設定では遅れずに置く(quickTo は duration が 0 だと値が動かない)
  const follow = (prop) => (reduceMotion ? (value) => ((state[prop] = value), render()) : gsap.quickTo(state, prop, { duration: 0.3, ease: "power3", onUpdate: render }));
  const moveX = follow("x"), moveY = follow("y");
  const power = (to, time) => {
    if (reduceMotion) return (state.power = to), render();
    gsap.to(state, { power: to, duration: time, ease: "power3.out", overwrite: "auto", onUpdate: render });
  };

  title.addEventListener("pointermove", (event) => {
    moveX(event.clientX);
    moveY(event.clientY);
  });
  title.addEventListener("pointerenter", (event) => {
    // 元の並びに戻りきっていれば、入った位置から押しのけ始める(空き地が遠くから飛んでこないように)
    if (state.power < 0.02) {
      moveX(event.clientX, event.clientX);
      moveY(event.clientY, event.clientY);
    }
    power(1, 0.3);
  });
  title.addEventListener("pointerleave", () => power(0, 0.5));

  // キーボードでボタンへ移ったときは、そのボタンにいちばん近い見出しの文字を押しのける
  cta?.addEventListener("focus", () => {
    const r = cta.getBoundingClientRect();
    const x = r.left + r.width / 2;
    const y = Math.min(r.top + r.height / 2, title.getBoundingClientRect().bottom - 24);
    moveX(x, x);
    moveY(y, y);
    power(1, 0.3);
  });
  cta?.addEventListener("blur", () => power(0, 0.5));

  document.fonts.ready.then(measure);
  window.addEventListener("resize", measure);
}

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

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