デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
同心円のリングと点を、SVGのグループ要素ごと rotate で回すだけの背景です。ライブラリは使わず、リングと点、点の後ろに引く尾を1つのグループにまとめ、角度をそのまま属性に指定して回しています。
リングごとに半径・周期・向きを変え、1周期でそろって出発点に戻るよう、周る回数をすべて整数にしています。破線は円周を分割数で割った長さにしているので、何周しても継ぎ目が出ません。尾は長さの違う弧を重ねて、点に近いほど濃くなるようにしています。
コンテナの大きさが変わるたびに viewBox を測り直して、リングと点の位置を作り直します。動きを減らす設定では、回転を止めて1コマだけを見せます。
半径や周期、色を変えるだけで、軌道の込み具合や速さの印象を変えられます。宇宙・通信・グローバル展開など、広がりを感じさせたいヒーローの背景に向いています。
コード
<section class="orbit-hero">
<!-- リングは飾りなので、JS が組み立てて読み上げから外す -->
<svg class="orbit-hero__rings" aria-hidden="true"></svg>
<div class="orbit-hero__inner">
<p class="orbit-hero__eyebrow">NETWORK</p>
<h1 class="orbit-hero__title">いつでも、<br>つながる。</h1>
<p class="orbit-hero__lead">ここに説明文が入ります。</p>
<a class="orbit-hero__button" href="/network/">ネットワークを見る</a>
</div>
</section>
.orbit-hero {
--paper: #ffffff;
--ink: #111111;
--ring: #ffffff; /* リングと点の色 */
--accent: #d2b999; /* 強調する点の色。グレージュ */
--gutter: clamp(24px, 7vw, 96px);
position: relative;
display: grid;
align-items: center;
min-height: min(90vh, 640px);
padding: 0 var(--gutter);
overflow: hidden;
color: var(--paper);
background: radial-gradient(circle at 62% 52%, #5a6f80 0%, #4a5b66 45%, #2e3940 100%);
}
.orbit-hero__rings {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.orbit-hero__inner {
position: relative; /* リングの上に載せる */
max-width: 28em;
}
.orbit-hero__eyebrow {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
opacity: 0.75;
}
.orbit-hero__title {
margin: 24px 0 0;
font-size: clamp(36px, 7vw, 60px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
}
.orbit-hero__lead {
margin: 24px 0 0;
font-size: 18px;
line-height: 1.8;
word-break: auto-phrase; /* 語の途中で折り返さない */
}
.orbit-hero__button {
display: inline-grid;
place-items: center;
height: 48px;
margin-top: 32px;
padding: 0 32px;
border-radius: 999px;
background: var(--paper);
color: var(--ink);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.orbit-hero__button:focus-visible { outline: 2px solid var(--paper); outline-offset: 4px; }
import { gsap } from "gsap";
const SVG_NS = "http://www.w3.org/2000/svg";
const DURATION = 24; // 全部のリングがそろって出発点に戻るまでの秒数
const CENTER = [0.62, 0.52]; // 回転の中心(コンテナに対する比)
const TAIL = 42; // 点の後ろに引く尾の長さ(度)
// radius: 高さに対する半径 / laps: 1 周期で回る周数(整数。負は反時計回り)/ width: 線の太さ(px)
// dash: [円周の分割数, 線の割合](null は実線)/ dots: [角度(度), 半径(高さ比), "fill" | "ring" | "accent"]
const RINGS = [
{ radius: 0.1, laps: 4, width: 1.5, opacity: 0.85, dash: null, dots: [[0, 0.011, "fill"]] },
{ radius: 0.18, laps: -3, width: 1, opacity: 0.5, dash: [48, 0.45], dots: [[140, 0.009, "fill"], [320, 0.014, "ring"]] },
{ radius: 0.27, laps: 2, width: 1.5, opacity: 0.7, dash: null, dots: [[60, 0.018, "accent"]] },
{ radius: 0.36, laps: -1, width: 7, opacity: 0.3, dash: [120, 0.12], dots: [] },
{ radius: 0.46, laps: 1, width: 1.2, opacity: 0.6, dash: null, dots: [[200, 0.012, "fill"], [20, 0.022, "ring"]] },
{ radius: 0.58, laps: -1, width: 1, opacity: 0.38, dash: [72, 0.6], dots: [[300, 0.014, "fill"]] },
{ radius: 0.72, laps: 1, width: 1, opacity: 0.3, dash: null, dots: [[110, 0.02, "accent"], [250, 0.009, "fill"]] },
{ radius: 0.9, laps: -1, width: 1, opacity: 0.2, dash: [120, 0.3], dots: [] },
];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function el(name, attrs) {
const node = document.createElementNS(SVG_NS, name);
for (const key in attrs) node.setAttribute(key, attrs[key]);
return node;
}
document.querySelectorAll(".orbit-hero__rings").forEach((svg, index) => {
// リングと点の色は CSS 変数から読む
const style = getComputedStyle(svg);
const ink = style.getPropertyValue("--ring").trim();
const accent = style.getPropertyValue("--accent").trim();
let cx, cy, groups, halo;
// リング 1 本と、その上の点・尾をひとつの <g> にまとめ、<g> ごと回す
function layout() {
const w = svg.clientWidth;
const h = svg.clientHeight;
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
svg.textContent = "";
cx = w * CENTER[0];
cy = h * CENTER[1];
groups = RINGS.map((ring) => {
const r = ring.radius * h;
const circumference = 2 * Math.PI * r;
const group = el("g", {});
const line = el("circle", { cx, cy, r, fill: "none", stroke: ink, "stroke-width": ring.width, "stroke-opacity": ring.opacity });
if (ring.dash) {
const period = circumference / ring.dash[0];
line.setAttribute("stroke-dasharray", `${period * ring.dash[1]} ${period * (1 - ring.dash[1])}`);
}
group.append(line);
for (const [angle, size, style] of ring.dots) {
const color = style === "accent" ? accent : ink;
if (style !== "ring") {
// 尾: 長さの違う弧を 4 本重ね、点に近いほど濃くする
for (let k = 1; k <= 4; k++) {
const span = TAIL * (k / 4);
const start = ring.laps > 0 ? angle - span : angle;
const dash = `${(span / 360) * circumference} ${circumference}`;
group.append(el("circle", { cx, cy, r, fill: "none", stroke: color, "stroke-width": 2, "stroke-opacity": 0.12, "stroke-dasharray": dash, transform: `rotate(${start} ${cx} ${cy})` }));
}
}
const x = cx + r * Math.cos((angle * Math.PI) / 180);
const y = cy + r * Math.sin((angle * Math.PI) / 180);
if (style === "accent") group.append(el("circle", { cx: x, cy: y, r: size * h * 2.4, fill: accent, "fill-opacity": 0.22 }));
group.append(
style === "ring"
? el("circle", { cx: x, cy: y, r: size * h, fill: "none", stroke: ink, "stroke-width": 1.5 })
: el("circle", { cx: x, cy: y, r: size * h, fill: color })
);
}
svg.append(group);
return { node: group, laps: ring.laps };
});
// 中心の光は、中心ほど濃い放射グラデーション(id は svg ごとに変えて、同じページに 2 つ置いても混ざらない)
const glow = el("radialGradient", { id: `orbit-halo-${index}` });
glow.append(el("stop", { offset: 0, "stop-color": ink, "stop-opacity": 0.55 }), el("stop", { offset: 1, "stop-color": ink, "stop-opacity": 0 }));
halo = el("circle", { cx, cy, r: h * 0.12, fill: `url(#orbit-halo-${index})` });
svg.append(glow);
svg.append(halo, el("circle", { cx, cy, r: h * 0.028, fill: ink }));
spin(reduceMotion ? 0 : gsap.ticker.time % DURATION);
}
function spin(t) {
const turn = (t / DURATION) * 360;
for (const { node, laps } of groups) node.setAttribute("transform", `rotate(${(laps * turn) % 360} ${cx} ${cy})`);
halo.setAttribute("opacity", 0.45 + 0.35 * (0.5 + 0.5 * Math.sin((t / DURATION) * Math.PI * 2 * 6)));
}
new ResizeObserver(layout).observe(svg);
layout();
// 動きを減らす設定では、リングを止めて 1 コマだけ見せる
if (reduceMotion) return;
gsap.ticker.add((time) => spin(time % DURATION));
});