デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
SVG の <textPath> で、円に沿わせた文字を描きます。textLength を円周に合わせておくと、文言を差し替えても字間だけが伸び縮みして 1 周ちょうどに並び、継ぎ目が出ません。
字の大きさは、字間なしの長さを測ってから、円周の 84% ほどになるように計算します。書体の読み込みが終わってから測らないと、仮の書体の幅で決まってしまいずれます。SVG の文字は末尾の空白を捨てるので、区切りの記号のあとに文字を続ける場合は空白を保つ指定が要ります。
回転は文字を包むグループを回し続けるだけで、ホバー中は速さを上げています。中央の矢印は同じ形を 2 本重ねて片方だけ先にずらしておき、そろって送っては戻すと、送りきった見た目と送る前の見た目が同じになるので途切れずに繰り返せます。
つまみは並べる文言・回る速さ・文字が円周に占める割合・矢印が抜ける間隔です。写真の角や見出しの脇に添えるアクセント、スクロールを促すボタンに向いています。
コード
<a class="badge" href="#work">
<svg viewBox="0 0 216 216" aria-hidden="true">
<defs>
<path id="badge-circle" d="M30 108a78 78 0 1 1 156 0a78 78 0 1 1 -156 0"></path>
<clipPath id="badge-clip"><circle cx="108" cy="108" r="38"></circle></clipPath>
</defs>
<circle class="badge__disc" cx="108" cy="108" r="108"></circle>
<circle class="badge__ring-line" cx="108" cy="108" r="102"></circle>
<g class="badge__ring">
<text class="badge__text">
<textPath href="#badge-circle" textLength="490.09" lengthAdjust="spacing">SCROLL DOWN <tspan class="badge__dot">•</tspan> EXPLORE THE WORK <tspan class="badge__dot">•</tspan> </textPath>
</text>
</g>
<circle class="badge__center" cx="108" cy="108" r="38"></circle>
<g class="badge__arrows" clip-path="url(#badge-clip)">
<path class="badge__arrow badge__arrow--a" d="M108 90V126M91 113L108 130L125 113"></path>
<path class="badge__arrow badge__arrow--b" d="M108 90V126M91 113L108 130L125 113"></path>
</g>
</svg>
<span class="badge__label">作品を見る</span>
</a>
.badge {
--ink: #262626;
--paper: #f2f1ed;
--mute: #a8a6a0;
position: relative;
display: inline-block;
width: 216px;
height: 216px;
color: inherit;
text-decoration: none;
}
.badge svg { display: block; width: 100%; height: 100%; }
.badge__disc { fill: var(--ink); }
.badge__ring-line { fill: none; stroke: var(--paper); stroke-opacity: 0.18; }
.badge__text {
font: 700 10px/1 "Avenir Next", "Avenir", "Helvetica Neue", "Hiragino Sans", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
fill: var(--paper);
white-space: pre;
}
.badge__dot { fill: var(--mute); }
.badge__center { fill: var(--paper); }
.badge__arrow {
fill: none;
stroke: var(--ink);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.badge__arrow--b { transform: translateY(-76px); }
/* 見出し扱いのラベルは、視覚的には隠し読み上げにだけ残す */
.badge__label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.badge:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; border-radius: 50%; }
import { gsap } from "gsap";
const CIRCUMFERENCE = 2 * Math.PI * 78; // 文字を沿わせる円の半径(108 × 0.72)
const FILL = 0.84; // 字間なしで並べた文字の長さが円周に占める割合
const DROP = 76; // 矢印が抜ける量(中央の円の直径)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".badge").forEach((badge) => {
const text = badge.querySelector(".badge__text");
const ring = badge.querySelector(".badge__ring");
const [a1, a2] = badge.querySelectorAll(".badge__arrow");
gsap.set(a2, { y: -DROP });
// 字形が決まってから、字間なしの長さを測って円周の FILL 割になる大きさにする
document.fonts.ready.then(() => {
const natural = text.getComputedTextLength() / 10; // 仮の 10px で測った長さ
text.style.fontSize = `${((FILL * CIRCUMFERENCE) / natural).toFixed(2)}px`;
});
if (reduceMotion) return; // 動きを減らす設定では、回転も矢印の送りも止めておく
const spin = gsap.to(ring, { rotation: 360, svgOrigin: "108 108", duration: 12, ease: "none", repeat: -1 });
const speedUp = () => gsap.to(spin, { timeScale: 4, duration: 0.5 });
const slowDown = () => gsap.to(spin, { timeScale: 1, duration: 0.8 });
badge.addEventListener("pointerenter", speedUp);
badge.addEventListener("pointerleave", slowDown);
badge.addEventListener("focus", speedUp);
badge.addEventListener("blur", slowDown);
badge.addEventListener("click", (e) => {
const target = document.querySelector(badge.getAttribute("href"));
if (!target) return;
e.preventDefault();
target.scrollIntoView({ behavior: "smooth" });
});
gsap.timeline({ repeat: -1, repeatDelay: 2.6 }).to([a1, a2], { y: `+=${DROP}`, duration: 0.4, ease: "power2.inOut" });
});