デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
暗い画面の中で、ボタンの縁を色の違う光の筋が2本、ゆっくり回り続けています。カーソルを乗せると回転が速まり、背後のにじんだ光が濃くなって、矢印が少し前へ進みます。筋は conic-gradient で描いたもので、先頭がいちばん明るく、後ろへ向けて透明に消えていきます。筋のない部分にも、うっすらした縁を常に残しています。
要素そのものは回していません。動かしているのは CSS 変数 --spin の角度だけで、GSAP で2.6秒に1周のペースで繰り返しています。ホバーで速さを変えるのは timeScale の役目で、0.5秒かけて2.5倍まで上げ、離れるとゆっくり戻します。CSS アニメーションの長さを書き換えて速さを変えると、その瞬間に角度が飛びますが、timeScale なら今の角度のまま加速も減速もできます。
背後の光には filter: blur を使わないことにしました。縁と同じ、先頭がくっきり切れた筋をぼかすと、中心から放射状に伸びる硬いくさびのような形になります。代わりに、先頭までなめらかに消える別の筋をボタンより上下左右に24px ずつ大きいピル形に描き、放射状のマスクで縁の近くだけを光らせています。この光は z-index の負の値でボタンの後ろへ回しているので、ボタンには isolation: isolate が欠かせません。外すと、カードなど親要素の背景よりさらに後ろへ回り込み、光が見えなくなります。
ボタンが画面の外にある間は回転を止め、見えない所で描き直しが続かないようにしています。フォーカスが来たときも、ホバーと同じように光を強めます。暗い背景で使う前提の作りで、料金表のおすすめプランや暗いヒーローセクションのメインボタン、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);
});