デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ボタンは 3 枚重ねです。いちばん後ろにグロー、その上に枠、いちばん手前に 2px 内側の地(文字と矢印)を置きます。枠は円錐グラデーション(conic-gradient)で描いた光の筋 2 本で、頭がいちばん明るく、尾へ向けて透明になります。2 本目は 180° ずらして色を変え、筋の無い所にはうっすらした縁をいつも見せておきます。
回すのは CSS 変数 --spin の角度だけで、要素そのものは回しません。GSAP で 0° → 360° を 2.6 秒で繰り返し、ホバー中は timeScale を 0.5 秒かけて 2.5 倍まで上げます。同時にグローの濃さを 0.28 から 1 へ上げ、地を少し明るくして、矢印を 5px 進めます。離れるときは 0.7 秒かけてゆっくり戻します。速さを CSS のアニメーションの長さで変えると角度が飛びますが、timeScale なら今の角度のまま滑らかに加速します。
グローは filter: blur を使わず、頭の先まで滑らかに消した別の筋を、ボタンより 24px 大きいピルに敷き、放射状の mask で縁の近くだけを光らせます。枠と同じ頭の切れた筋をそのままぼかすと、中心から放射する硬い楔のように見えます。グローは z-index の負の値でボタンの後ろに回すので、ボタンに isolation: isolate を付けます。付けないと、カードなど親の地の後ろへ潜って見えなくなります。
回り続ける動きなので、動きを減らす設定では回さず、筋を止めた状態で見せます。画面の外にあるあいだも止めて、見えない所で描き直さないようにします。キーボードで焦点が来たときも、ホバーと同じように光らせます。筋の色と長さ(150°)、回る速さ、グローの濃さで印象が変わります。暗い地で使う前提で、料金表のおすすめのプラン、暗いヒーローの主なボタン、AI 系のサービスの「試す」ボタンに向いています。
コード
<!-- 押すと移るので a。回る光の筋とグローは、style.css の疑似要素で描く -->
<a class="glow-button" href="/pricing/pro/">
<span class="glow-button__label">
プロにアップグレード
<svg class="glow-button__arrow" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5 12h13M13 6.5l5.5 5.5-5.5 5.5" />
</svg>
</span>
</a>
.glow-button {
--bg: #111111; /* 中の地(暗い地で使う) */
--bg-hover: #202020; /* ホバー中の地 */
--ink: #eeece6; /* 文字と矢印 */
--line: rgba(238, 236, 230, 0.14); /* 筋の無い所にうっすら見せる縁 */
--glow: 0.28; /* グローの濃さ。ホバーで 1 へ(script.js) */
--tail: 150deg; /* 光の筋の長さ */
--spin: 0deg; /* 筋の角度。script.js が回す */
/* 光の筋の色(尾 → 中 → 頭)。2 本目は反対側を回る */
--a-tail: #b0a5ca;
--a-mid: #d29b97;
--a-head: #e6d4ce;
--b-tail: #98b1c4;
--b-mid: #a5c0a3;
--b-head: #d8dfd2;
position: relative;
isolation: isolate; /* 疑似要素を z-index の負の値で後ろに回しても、ページの地より手前に留める */
display: inline-grid;
padding: 2px; /* 枠の太さ */
border-radius: 999px;
color: var(--ink);
text-decoration: none;
}
/* グロー: ひと回り大きいピルに、頭の先も滑らかに消した筋を敷き、mask で縁の近くだけを光らせる */
.glow-button::before {
content: "";
position: absolute;
inset: -24px;
z-index: -2;
border-radius: 999px;
background:
conic-gradient(from calc(var(--spin) + 18deg), transparent 0deg calc(360deg - var(--tail)), var(--a-tail) calc(360deg - var(--tail) * 0.4), var(--a-mid) calc(342deg - var(--tail) * 0.05), transparent 360deg),
conic-gradient(from calc(var(--spin) + 198deg), transparent 0deg calc(360deg - var(--tail)), var(--b-tail) calc(360deg - var(--tail) * 0.4), var(--b-mid) calc(342deg - var(--tail) * 0.05), transparent 360deg);
-webkit-mask-image: radial-gradient(closest-side, #000 50%, rgba(0, 0, 0, 0.45) 78%, transparent 100%);
mask-image: radial-gradient(closest-side, #000 50%, rgba(0, 0, 0, 0.45) 78%, transparent 100%);
opacity: var(--glow);
pointer-events: none; /* 外へ広がる光に重なっただけでは反応させない */
}
/* 枠: 頭がいちばん明るく、尾へ向けて透明になる筋 2 本と、うっすらした常設の縁 */
.glow-button::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background:
conic-gradient(from var(--spin), transparent 0deg calc(360deg - var(--tail)), var(--a-tail) calc(360deg - var(--tail) * 0.45), var(--a-mid) calc(360deg - var(--tail) * 0.1), var(--a-head) 359deg, transparent 359.6deg),
conic-gradient(from calc(var(--spin) + 180deg), transparent 0deg calc(360deg - var(--tail)), var(--b-tail) calc(360deg - var(--tail) * 0.45), var(--b-mid) calc(360deg - var(--tail) * 0.1), var(--b-head) 359deg, transparent 359.6deg),
linear-gradient(var(--line), var(--line));
}
.glow-button__label {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 52px;
padding: 0 30px;
border-radius: 999px;
background-color: var(--bg);
font-size: 15px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.04em;
white-space: nowrap;
}
.glow-button__arrow {
flex: none;
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.glow-button:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
import { gsap } from "gsap";
const LAP = 2.6; // 1 周の秒数(ホバーしていないとき)
const BOOST = 2.5; // ホバー中の回る速さの倍率
const NUDGE = 5; // ホバーで矢印を進める量(px)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".glow-button").forEach((button) => {
const label = button.querySelector(".glow-button__label");
const arrow = button.querySelector(".glow-button__arrow");
const styles = getComputedStyle(button);
// 色と濃さは CSS が正。GSAP は var(--name) のままでは補間できないので、値にして使う
const bg = styles.getPropertyValue("--bg").trim();
const bgHover = styles.getPropertyValue("--bg-hover").trim();
const rest = parseFloat(styles.getPropertyValue("--glow"));
// 角度だけを回し続ける(CSS 変数は始点が読めないことがあるので、fromTo で両端を渡す)。
// 動きを減らす設定では回さない
const spin = gsap.fromTo(button, { "--spin": "0deg" }, { "--spin": "360deg", duration: LAP, ease: "none", repeat: -1, paused: reduceMotion });
// ホバーの強さ h(0〜1)から、グロー・地・矢印・回る速さをまとめて描く
const hover = { h: 0 };
const render = () => {
button.style.setProperty("--glow", rest + (1 - rest) * hover.h);
label.style.backgroundColor = gsap.utils.interpolate(bg, bgHover, hover.h);
gsap.set(arrow, { x: NUDGE * hover.h });
spin.timeScale(1 + (BOOST - 1) * hover.h);
};
// 強めるのは速く、戻すのはゆっくり。動きを減らす設定では一瞬で切り替える
const glowUp = () => gsap.to(hover, { h: 1, duration: reduceMotion ? 0 : 0.5, ease: "power2.out", overwrite: true, onUpdate: render });
const glowDown = () => gsap.to(hover, { h: 0, duration: reduceMotion ? 0 : 0.7, ease: "power2.inOut", overwrite: true, onUpdate: render });
button.addEventListener("pointerenter", glowUp);
button.addEventListener("pointerleave", glowDown);
button.addEventListener("focus", glowUp);
button.addEventListener("blur", glowDown);
// 画面の外にあるあいだは止めて、見えない所で毎コマ描き直さない
new IntersectionObserver(([entry]) => spin.paused(reduceMotion || !entry.isIntersecting)).observe(button);
});