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

カーソルとリンクの間にゴムの線が張られ、引き離すと切れて弾む

デモ

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

仕様

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

しくみと調整

大きなリンクの近くにカーソルが入ると、リンクの右端とカーソルのあいだに SVG の線が張られます。線は 2 次ベジェで、近いうちは下へたるみ、引き離すほどまっすぐ細くなります。リンク自身もカーソルのほうへ引きずられ、限界の距離を越えると線が切れて、リンクは揺れながら元の位置に戻ります。掛かる相手は、そのときいちばん近い 1 つだけにします。

190px まで近づくと掛かり、245px を越えると切れます。引きずられる量は距離の 20%(限界で 49px ほど)で、線の太さは 6px から 2.2px まで細くなります。たるみは近いときの 34px から、張りきると 0 になります。切れたあとの揺れは elastic.out の 0.7 秒で、行き過ぎてから収まります。

切れたあとの見え方には順番があります。線がゆっくり消えると、まだ線が残っているのにリンクが跳ね返り、引かれる向きと食い違って見えます。線は 0.2 秒で縮ませて消し、揺れはそのあとも続けてください。切れた瞬間はまだカーソルが近いので、そのままだとすぐ張り直します。300px 離れるまでは、そのリンクに掛けないようにします。

つまみは、掛かる距離・切れる距離・引きずられる割合・線の太さです。切れる距離を近づけると、指先で弾くような短い手応えになり、遠ざけるとねばって伸びる太いゴムになります。狭い画面では、掛かる距離も一緒に縮めてください。指で触る端末では線を張らず、リンクはそのまま押させます。

連絡先の並び、メニュー、フッターの大きなリンクなど、1 つずつが離れて置かれた所に向いています。キーボードで移ったときも、そのリンクの右に同じ線を張って、どこにいるかを見せられます。

コード

HTML
<section class="rubber" aria-labelledby="rubber-title">
  <h2 class="rubber__label" id="rubber-title">Contact</h2>
  <ul class="rubber__list">
    <li><a class="rubber__link" href="mailto:sample@higashiyuki.com">メール</a></li>
    <li><a class="rubber__link" href="/instagram/">Instagram</a></li>
    <li><a class="rubber__link" href="/note/">note</a></li>
    <li><a class="rubber__link" href="/newsletter/">ニュースレター</a></li>
  </ul>
  <p class="rubber__lead">ここに説明文が入ります。</p>
</section>

<!-- ゴムの線。画面に重ねて置くので、座標はそのまま clientX / clientY を使える -->
<svg class="rubber__band" aria-hidden="true">
  <path fill="none" stroke="currentColor" stroke-linecap="round" visibility="hidden"/>
  <circle fill="currentColor" r="6" visibility="hidden"/>
</svg>

CSS
.rubber {
  /* JS が読む値。px で書く(min() や vw のままだと数として読めない) */
  --attach: 190px;
  --break: 245px;
  --reset: 300px;
  --gap: 14px;
  --sag: 34px;
  --whip: 26px;
  --wide: 6px;
  --thin: 2.2px;
  --pull: 0.2;
  --bg: #f2f1ed;
  --ink: #262626;
  box-sizing: border-box;
  padding: 96px clamp(16px, 7.5vw, 96px);
  background: var(--bg);
  color: var(--ink);
}

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

.rubber__list { margin: 0; padding: 0; list-style: none; }

.rubber__link {
  display: inline-flex;
  align-items: center;
  height: 70px;
  margin-top: 24px;
  color: inherit;
  font-size: clamp(34px, 4.4vw, 56px);
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1.1;
  text-decoration: none;
  white-space: nowrap;
}

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

.rubber__lead {
  margin: 40px 0 0;
  color: #6b6b6b;
  font-size: 14px;
  letter-spacing: 0.04em;
  line-height: 1.7;
  word-break: auto-phrase;
}

/* 線は画面いっぱいに重ねる。下のリンクを押せるよう、ポインタは通す */
.rubber__band {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 40;
  width: 100%;
  height: 100%;
  color: #b27771; /* ゴムの線と結び目の色 */
  pointer-events: none;
}

/* 狭い画面では、張る距離も短くする(JS が読むので px で書く) */
@media (max-width: 640px) {
  .rubber {
    --attach: 130px;
    --break: 170px;
    --reset: 210px;
    --sag: 24px;
  }
}

JavaScript
import { gsap } from "gsap";

const svg = document.querySelector(".rubber__band");
const links = [...document.querySelectorAll(".rubber__link")];
// 指で触る端末にはポインタが無いので、線は張らずリンクをそのまま押させる
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (svg && links.length > 0 && canHover) {
  // 線は先に消え(SNAP 秒)、引かれたリンクはそのあとも揺れて戻る(SPRING 秒)
  const SNAP = 0.2, SPRING = 0.7;
  const band = svg.querySelector("path"), knot = svg.querySelector("circle"), box = links[0].closest(".rubber");
  const set = {};
  const names = ["attach", "break", "reset", "gap", "sag", "whip", "wide", "thin", "pull"];
  const load = () => names.forEach((name) => (set[name] = parseFloat(getComputedStyle(box).getPropertyValue(`--${name}`))));
  load();
  window.addEventListener("resize", load);

  // 揺れて戻る曲線。動きを減らす設定では、その場で戻す
  const spring = reduceMotion ? () => 1 : gsap.parseEase("elastic.out(1, 0.5)"), shrink = gsap.parseEase("power3.in");
  // clock.age は切れてからの秒数。now はポインタの位置・切れた点・今あてているずれ
  const clock = { age: 99 }, now = { x: -9999, y: -9999, endX: 0, endY: 0, offX: 0, offY: 0 };
  // active は掛かっている(または揺れて戻っている)リンク、blocked は切れた直後のリンク(離れるまで掛け直さない)
  let active = null, held = false, blocked = null, back = null;

  // 引く前の位置。今あてている transform のぶんを引いて求める
  const homeOf = (link) => {
    const r = link.getBoundingClientRect(), off = link === active ? now : { offX: 0, offY: 0 };
    return [r.right - off.offX + set.gap, r.top + r.height / 2 - off.offY];
  };
  const away = (link) => Math.hypot(homeOf(link)[0] - now.x, homeOf(link)[1] - now.y);

  const draw = () => {
    if (!active) return;
    const [hx, hy] = homeOf(active);
    const toX = (held ? now.x : now.endX) - hx, toY = (held ? now.y : now.endY) - hy;
    const d = Math.hypot(toX, toY) || 0.001;
    const dirX = toX / d, dirY = toY / d;
    const stretch = Math.min(1, d / set.break);
    // 引きずられる量。切れたあとは、揺れながら 0 へ戻る
    const amount = Math.min(d, set.break) * set.pull * (held ? 1 : 1 - spring(Math.min(1, clock.age / SPRING)));
    [now.offX, now.offY] = [Math.round(dirX * amount), Math.round(dirY * amount)];
    active.style.transform = now.offX === 0 && now.offY === 0 ? "" : `translate(${now.offX}px, ${now.offY}px)`;
    const show = held || clock.age < SNAP;
    for (const part of [band, knot]) part.setAttribute("visibility", show ? "visible" : "hidden");
    if (!show) return;
    const ax = hx + dirX * amount, ay = hy + dirY * amount;
    // 自由な端は、掛かっているあいだはポインタ、切れたあとは縮みながら結び目へ戻る
    const k = held ? 0 : shrink(Math.min(1, clock.age / SNAP));
    const bx = ax + (now.x - ax) * (1 - k), by = ay + (now.y - ay) * (1 - k);
    // たるみ: 近いうちは下へ垂れ、張るほどまっすぐ。切れた直後は 1 度だけしなる
    const sag = held ? set.sag * (1 - stretch) : set.whip * Math.sin(2 * Math.PI * k) * (1 - k);
    band.setAttribute("d", `M${ax.toFixed(1)} ${ay.toFixed(1)} Q${((ax + bx) / 2).toFixed(1)} ${((ay + by) / 2 + sag).toFixed(1)} ${bx.toFixed(1)} ${by.toFixed(1)}`);
    band.setAttribute("stroke-width", (set.wide - (set.wide - set.thin) * stretch).toFixed(2));
    band.setAttribute("stroke-opacity", held ? "1" : (1 - k).toFixed(3));
    knot.setAttribute("transform", `translate(${ax.toFixed(1)} ${ay.toFixed(1)})`);
  };

  // 限界を越えたら切る。線は先に消え、リンクだけが揺れて戻る
  const cut = () => {
    if (!held) return;
    held = false;
    blocked = active;
    [now.endX, now.endY] = [now.x, now.y];
    back?.kill();
    back = gsap.fromTo(clock, { age: 0 }, { age: SPRING, duration: reduceMotion ? 0.05 : SPRING, ease: "none", onUpdate: draw, onComplete: draw });
  };
  const hook = (link) => {
    if (active && active !== link) active.style.transform = "";
    back?.kill();
    active = link;
    held = true;
    now.offX = now.offY = 0;
  };

  window.addEventListener("pointermove", (event) => {
    [now.x, now.y] = [event.clientX, event.clientY];
    if (held) {
      if (away(active) > set.break) cut();
    } else {
      if (blocked && away(blocked) > set.reset) blocked = null;
      let near = null;
      for (const link of links) if (link !== blocked && away(link) <= set.attach && (!near || away(link) < away(near))) near = link;
      if (near) hook(near);
    }
    draw();
  });

  // キーボードで移ったときも、そのリンクの右に線を張って見せる
  for (const link of links) {
    link.addEventListener("focus", () => {
      hook(link);
      const [hx, hy] = homeOf(link);
      [now.x, now.y] = [hx + 120, hy];
      draw();
    });
    link.addEventListener("blur", cut);
  }
}

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

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