円形パーセントローダー

conic-gradientとrequestAnimationFrameでなめらかにカウントアップする円形プログレス。イージングと完了時のグロー演出付き。初回ローディング画面に。

#css#javascript#progress#conic-gradient

ライブデモ

使用例(お題: カフェ MOON BREW)

この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- MOON BREW:初回ローディング画面。円形パーセントが満ちると本編ヒーローへ -->
<div class="mb-stage" id="mbStage">

  <!-- 本編(背面に常駐) -->
  <main class="mb-hero">
    <span class="mb-hero__tag">SPECIALTY COFFEE</span>
    <h1 class="mb-hero__title">月夜に、<br>一杯の余白を。</h1>
    <p class="mb-hero__lead">厳選した自家焙煎を、静かな夜にゆっくりと。</p>
    <a class="mb-hero__btn" href="#">本日のおすすめ</a>
  </main>

  <!-- ローディングオーバーレイ -->
  <div class="mb-load" id="mbLoad">
    <div class="mb-ring" id="mbRing">
      <div class="mb-ring__hole">
        <span class="mb-ring__moon">☾</span>
        <span class="mb-ring__pct" id="mbPct">0%</span>
      </div>
    </div>
    <p class="mb-load__name">MOON BREW</p>
    <p class="mb-load__sub">豆を挽いています…</p>
  </div>

</div>
CSS
/* MOON BREW:円形パーセントローダー → 本編ヒーロー */
:root {
  --cream: #f5ede1;
  --brown: #2b1d12;
  --amber: #c98a3b;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  font-family: "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  overflow: hidden;
}

.mb-stage { position: relative; height: 400px; }

/* 本編ヒーロー */
.mb-hero {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 0 34px;
  color: #fff;
  font-family: "Hiragino Mincho ProN", serif;
  background:
    linear-gradient(180deg, rgba(43,29,18,0.5), rgba(43,29,18,0.78)),
    url("https://picsum.photos/700/500?random=44") center/cover no-repeat;
}
.mb-hero__tag {
  font-size: 10px;
  letter-spacing: 0.3em;
  color: var(--amber);
  font-family: "Hiragino Kaku Gothic ProN", sans-serif;
}
.mb-hero__title { margin: 12px 0; font-size: 32px; line-height: 1.4; font-weight: 700; }
.mb-hero__lead {
  margin: 0 0 22px;
  font-size: 13px;
  line-height: 1.8;
  color: rgba(255,255,255,0.9);
  font-family: "Hiragino Kaku Gothic ProN", sans-serif;
}
.mb-hero__btn {
  align-self: flex-start;
  padding: 11px 22px;
  border-radius: 999px;
  background: var(--amber);
  color: #fff;
  font-size: 13px;
  font-weight: 700;
  text-decoration: none;
  font-family: "Hiragino Kaku Gothic ProN", sans-serif;
  box-shadow: 0 8px 20px rgba(201,138,59,0.4);
}

/* ローディングオーバーレイ */
.mb-load {
  position: absolute;
  inset: 0;
  z-index: 5;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  background: var(--cream);
  transition: opacity 0.6s ease, visibility 0.6s ease;
}
.mb-load.is-done { opacity: 0; visibility: hidden; }

/* 円形プログレス:conic-gradient で塗り進む */
.mb-ring {
  width: 124px;
  height: 124px;
  border-radius: 50%;
  background:
    conic-gradient(var(--amber) calc(var(--p, 0) * 1%), #e7dccb 0);
  display: grid;
  place-items: center;
  margin-bottom: 16px;
}
.mb-ring.is-done { box-shadow: 0 0 26px rgba(201,138,59,0.55); }
.mb-ring__hole {
  width: 98px;
  height: 98px;
  border-radius: 50%;
  background: var(--cream);
  display: grid;
  place-items: center;
  position: relative;
}
.mb-ring__moon {
  position: absolute;
  top: 20px;
  font-size: 20px;
  color: var(--amber);
}
.mb-ring__pct {
  font-size: 26px;
  font-weight: 700;
  color: var(--brown);
  font-family: "Hiragino Kaku Gothic ProN", sans-serif;
  margin-top: 12px;
}
.mb-load__name {
  margin: 4px 0 0;
  font-family: "Hiragino Mincho ProN", serif;
  font-size: 17px;
  font-weight: 700;
  letter-spacing: 0.14em;
  color: var(--brown);
}
.mb-load__sub { margin: 2px 0 0; font-size: 11px; color: #8a755e; letter-spacing: 0.08em; }

@media (prefers-reduced-motion: reduce) {
  .mb-load { transition: none; }
}
JavaScript
// MOON BREW 初回ロード:conic-gradient を rAF でカウントアップ → 本編へ
const ring = document.getElementById('mbRing');
const pct = document.getElementById('mbPct');
const load = document.getElementById('mbLoad');

// イージング(終盤ゆっくり)
const easeOut = (t) => 1 - Math.pow(1 - t, 3);

function run() {
  if (!ring || !pct || !load) return;
  load.classList.remove('is-done');
  ring.classList.remove('is-done');
  const dur = 2200;
  const start = performance.now();

  function tick(now) {
    const t = Math.min((now - start) / dur, 1);
    const v = Math.round(easeOut(t) * 100);
    ring.style.setProperty('--p', v);   // 塗り進み
    pct.textContent = v + '%';
    if (t < 1) {
      requestAnimationFrame(tick);
    } else {
      // 完了:グロー → オーバーレイをフェードアウト
      ring.classList.add('is-done');
      setTimeout(() => load.classList.add('is-done'), 450);
      // しばらくして再ループ
      setTimeout(run, 4200);
    }
  }
  requestAnimationFrame(tick);
}

// 初回起動
run();

コード

HTML
<!-- 円形プログレス: conic-gradient とカウントアップ数値で読み込み率を表現 -->
<div class="cp-stage">
  <div class="cp-dial" id="cpDial" style="--p:0">
    <div class="cp-inner">
      <span class="cp-num" id="cpNum">0</span>
      <span class="cp-pct">%</span>
    </div>
  </div>
  <p class="cp-caption" id="cpCaption">アセットを読み込み中</p>
</div>
CSS
:root {
  --bg: #0c1322;
  --ring-bg: #1e2740;
  --a1: #f9a8d4;
  --a2: #c084fc;
  --a3: #60a5fa;
  --txt: #eef3ff;
}
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, sans-serif;
  background:
    radial-gradient(700px 500px at 50% 120%, #1a1340 0%, transparent 60%),
    var(--bg);
  color: var(--txt);
}
.cp-stage {
  display: grid;
  justify-items: center;
  gap: 20px;
  padding: 24px;
}
/* conic-gradient で円弧を塗る。--p(0〜100) で角度が変わる */
.cp-dial {
  width: 168px;
  height: 168px;
  border-radius: 50%;
  background:
    conic-gradient(
      from -90deg,
      var(--a1) 0%,
      var(--a2) calc(var(--p) * .5%),
      var(--a3) calc(var(--p) * 1%),
      var(--ring-bg) calc(var(--p) * 1%)
    );
  display: grid;
  place-items: center;
  filter: drop-shadow(0 12px 30px rgba(96, 165, 250, .25));
}
.cp-inner {
  width: 128px;
  height: 128px;
  border-radius: 50%;
  background: var(--bg);
  display: grid;
  place-items: baseline center;
  grid-auto-flow: column;
  align-content: center;
  justify-content: center;
  gap: 2px;
  box-shadow: inset 0 2px 10px rgba(0, 0, 0, .4);
}
.cp-num {
  font-size: 46px;
  font-weight: 700;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.cp-pct { font-size: 18px; color: rgba(238, 243, 255, .55); }
.cp-caption {
  margin: 0;
  font-size: 12px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: rgba(238, 243, 255, .5);
}
.cp-dial.is-done { animation: cp-glow 1.4s ease-in-out infinite alternate; }
@keyframes cp-glow {
  from { filter: drop-shadow(0 12px 30px rgba(96, 165, 250, .25)); }
  to   { filter: drop-shadow(0 12px 38px rgba(192, 132, 252, .55)); }
}

@media (prefers-reduced-motion: reduce) {
  .cp-dial.is-done { animation: none; }
}
JavaScript
// requestAnimationFrame でなめらかにカウントアップする円形プログレス
const dial    = document.getElementById('cpDial');
const num     = document.getElementById('cpNum');
const caption = document.getElementById('cpCaption');

const DURATION = 2600; // ミリ秒
let startTime  = null;

// イージング(終盤を緩める)
const easeOut = (t) => 1 - Math.pow(1 - t, 3);

function frame(now) {
  if (startTime === null) startTime = now;
  const elapsed = now - startTime;
  const t = Math.min(1, elapsed / DURATION);
  const p = Math.round(easeOut(t) * 100);

  if (dial) dial.style.setProperty('--p', String(p));
  if (num) num.textContent = String(p);

  if (t < 1) {
    requestAnimationFrame(frame);
  } else {
    // 完了演出
    dial?.classList.add('is-done');
    if (caption) caption.textContent = '読み込み完了';
    // 少し待ってからループ再生
    setTimeout(() => {
      dial?.classList.remove('is-done');
      if (caption) caption.textContent = 'アセットを読み込み中';
      startTime = null;
      requestAnimationFrame(frame);
    }, 1600);
  }
}

// 初回起動
requestAnimationFrame(frame);

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「円形パーセントローダー」の効果を追加してください。

# 追加してほしい効果
円形パーセントローダー(ローダー & スケルトン)
conic-gradientとrequestAnimationFrameでなめらかにカウントアップする円形プログレス。イージングと完了時のグロー演出付き。初回ローディング画面に。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 円形プログレス: conic-gradient とカウントアップ数値で読み込み率を表現 -->
<div class="cp-stage">
  <div class="cp-dial" id="cpDial" style="--p:0">
    <div class="cp-inner">
      <span class="cp-num" id="cpNum">0</span>
      <span class="cp-pct">%</span>
    </div>
  </div>
  <p class="cp-caption" id="cpCaption">アセットを読み込み中</p>
</div>

【CSS】
:root {
  --bg: #0c1322;
  --ring-bg: #1e2740;
  --a1: #f9a8d4;
  --a2: #c084fc;
  --a3: #60a5fa;
  --txt: #eef3ff;
}
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, sans-serif;
  background:
    radial-gradient(700px 500px at 50% 120%, #1a1340 0%, transparent 60%),
    var(--bg);
  color: var(--txt);
}
.cp-stage {
  display: grid;
  justify-items: center;
  gap: 20px;
  padding: 24px;
}
/* conic-gradient で円弧を塗る。--p(0〜100) で角度が変わる */
.cp-dial {
  width: 168px;
  height: 168px;
  border-radius: 50%;
  background:
    conic-gradient(
      from -90deg,
      var(--a1) 0%,
      var(--a2) calc(var(--p) * .5%),
      var(--a3) calc(var(--p) * 1%),
      var(--ring-bg) calc(var(--p) * 1%)
    );
  display: grid;
  place-items: center;
  filter: drop-shadow(0 12px 30px rgba(96, 165, 250, .25));
}
.cp-inner {
  width: 128px;
  height: 128px;
  border-radius: 50%;
  background: var(--bg);
  display: grid;
  place-items: baseline center;
  grid-auto-flow: column;
  align-content: center;
  justify-content: center;
  gap: 2px;
  box-shadow: inset 0 2px 10px rgba(0, 0, 0, .4);
}
.cp-num {
  font-size: 46px;
  font-weight: 700;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.cp-pct { font-size: 18px; color: rgba(238, 243, 255, .55); }
.cp-caption {
  margin: 0;
  font-size: 12px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: rgba(238, 243, 255, .5);
}
.cp-dial.is-done { animation: cp-glow 1.4s ease-in-out infinite alternate; }
@keyframes cp-glow {
  from { filter: drop-shadow(0 12px 30px rgba(96, 165, 250, .25)); }
  to   { filter: drop-shadow(0 12px 38px rgba(192, 132, 252, .55)); }
}

@media (prefers-reduced-motion: reduce) {
  .cp-dial.is-done { animation: none; }
}

【JavaScript】
// requestAnimationFrame でなめらかにカウントアップする円形プログレス
const dial    = document.getElementById('cpDial');
const num     = document.getElementById('cpNum');
const caption = document.getElementById('cpCaption');

const DURATION = 2600; // ミリ秒
let startTime  = null;

// イージング(終盤を緩める)
const easeOut = (t) => 1 - Math.pow(1 - t, 3);

function frame(now) {
  if (startTime === null) startTime = now;
  const elapsed = now - startTime;
  const t = Math.min(1, elapsed / DURATION);
  const p = Math.round(easeOut(t) * 100);

  if (dial) dial.style.setProperty('--p', String(p));
  if (num) num.textContent = String(p);

  if (t < 1) {
    requestAnimationFrame(frame);
  } else {
    // 完了演出
    dial?.classList.add('is-done');
    if (caption) caption.textContent = '読み込み完了';
    // 少し待ってからループ再生
    setTimeout(() => {
      dial?.classList.remove('is-done');
      if (caption) caption.textContent = 'アセットを読み込み中';
      startTime = null;
      requestAnimationFrame(frame);
    }, 1600);
  }
}

// 初回起動
requestAnimationFrame(frame);

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。