デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
2本の鎖の点を横に並べ、位置ごとの位相をずらしながら回すと、高さと奥行きが入れ替わってらせんに見えます。手前に来た点ほど大きく濃く、奥へ回った点ほど小さく薄く描きます。
1周するのにかかる時間は2.16秒で、横幅の中にねじれが1.5回入るようにしています。向かい合う2点を結ぶ横木は、点が真横を向くあたりでいちばん濃く見えるようにしています。
奥にある点を先に描き、横木、手前の点の順に重ね直しているので、どちらの鎖が手前に来ても正しく前後関係が見えます。並べ替えを省くと、奥の点が手前の点より上に描かれたままになる瞬間が出ます。
点の組の数やねじれの数、振れ幅は、どれもスクリプトの先頭で変えられます。動きを減らす設定では振れ幅をごく小さくして、回転そのものは止めずに残します。解析中・生成中・スキャン中のような、処理の見当がつかない待ち時間の画面に向いています。
コード
<div class="dna-loader" role="status">
<svg class="dna-loader__svg" viewBox="0 0 320 120" aria-hidden="true"></svg>
<span class="dna-loader__text">読み込み中</span>
</div>
.dna-loader {
--ground: #111111; /* 地の色 */
--strand-a: #94d390; /* 鎖 A の点の色 */
--strand-b: #f3efe1; /* 鎖 B の点の色。A と明るさを離して 2 本を見分ける */
--rung: #eeece6; /* 横木の線の色 */
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
background: var(--ground);
}
.dna-loader__svg {
display: block;
width: min(320px, 80vw);
height: auto;
overflow: visible;
}
/* 読み上げにだけ届く文言。画面には出さない */
.dna-loader__text {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SVG_NS = "http://www.w3.org/2000/svg";
const DURATION = 2.16; // 1 周(秒)
const PAIRS = 16; // 点の組の数
const TURNS = 1.5; // 横幅の中のねじれの数
const WIDTH = 320;
const HEIGHT = 120;
const MARGIN = 16;
// 動きを減らす設定では、らせんの振れ幅をごく小さくする
const AMP = reduceMotion ? 8 : 38;
const DOT = 6; // 手前の点の半径。いちばん奥では半分
function node(parent, tag, attrs) {
const n = document.createElementNS(SVG_NS, tag);
for (const [key, value] of Object.entries(attrs)) n.setAttribute(key, String(value));
parent.append(n);
return n;
}
// k は奥行き(0 = いちばん奥、1 = いちばん手前)。fade は両端を薄くする係数
function place(dot, y, k, fade) {
dot.setAttribute("cy", y.toFixed(2));
dot.setAttribute("r", (DOT * (0.5 + 0.5 * k)).toFixed(2));
dot.setAttribute("fill-opacity", ((0.3 + 0.7 * k) * fade).toFixed(3));
}
function createLoader(root) {
const svg = root.querySelector(".dna-loader__svg");
svg.setAttribute("viewBox", `0 0 ${WIDTH} ${HEIGHT}`);
const style = getComputedStyle(root);
// 色は CSS の変数が正。SVG の属性には var() を渡せないので、値にして読む
const strandA = style.getPropertyValue("--strand-a").trim();
const strandB = style.getPropertyValue("--strand-b").trim();
const rungColor = style.getPropertyValue("--rung").trim();
const pairs = Array.from({ length: PAIRS }, (_, i) => {
const x = MARGIN + (i * (WIDTH - 2 * MARGIN)) / (PAIRS - 1);
const group = node(svg, "g", {});
const rung = node(group, "line", { x1: x, x2: x, stroke: rungColor, "stroke-width": 1.5, "stroke-linecap": "round" });
const a = node(group, "circle", { cx: x, fill: strandA });
const b = node(group, "circle", { cx: x, fill: strandB });
// 両端の 2 組ずつを薄くして、らせんが中央から伸びているように見せる
const fade = Math.min(1, (Math.min(i, PAIRS - 1 - i) + 1) / 3);
return { group, rung, a, b, fade };
});
const state = { phase: 0 };
function draw() {
const cy = HEIGHT / 2;
const base = state.phase * Math.PI * 2;
pairs.forEach(({ group, rung, a, b, fade }, i) => {
const theta = base + (i * TURNS * Math.PI * 2) / (PAIRS - 1);
const s = Math.sin(theta);
const k = (Math.cos(theta) + 1) / 2; // 鎖 A の奥行き。鎖 B は反対側
const ya = cy + AMP * s;
const yb = cy - AMP * s;
place(a, ya, k, fade);
place(b, yb, 1 - k, fade);
rung.setAttribute("y1", ya.toFixed(2));
rung.setAttribute("y2", yb.toFixed(2));
rung.setAttribute("stroke-opacity", ((0.12 + 0.18 * Math.abs(s)) * fade).toFixed(3));
// 奥の点 → 横木 → 手前の点
if (k >= 0.5) group.append(b, rung, a);
else group.append(a, rung, b);
});
}
const spin = gsap.to(state, { phase: 1, duration: DURATION, ease: "none", repeat: -1, onUpdate: draw });
return {
// 解析が終わったら呼ぶ。呼ぶまで回り続ける
done() {
gsap.to(root, {
autoAlpha: 0,
duration: reduceMotion ? 0 : 0.3,
onComplete: () => {
spin.kill();
root.remove();
},
});
},
};
}
// 例: 解析や生成の完了を待って loader.done()
export const loader = createLoader(document.querySelector(".dna-loader"));