デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- 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 つずつが離れて置かれた所に向いています。キーボードで移ったときも、そのリンクの右に同じ線を張って、どこにいるかを見せられます。
コード
<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>
.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;
}
}
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);
}
}