打ち上げカウントダウン・ローダー

HUD風の3・2・1カウントダウンからロケットが発射し、星が流線になるワープで読み込み完了を告げるローダー演出です。待ち時間をミッション開始の高揚感に変える、体験型サイトの導入に。

#loader#countdown#rocket#warp

ライブデモ

使用例(お題: アイドルグループ Sakura)

この技法を「アイドルグループ Sakura」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- Sakura: ライブ開演前のカウントダウン。発射→ワープで開演を告げる -->
<div class="stage" data-loader aria-label="ライブ開演カウントダウン">
  <canvas class="stars" aria-hidden="true"></canvas>

  <p class="caption">LIVE OPENING<span class="dots">...</span></p>

  <svg class="ring" viewBox="0 0 160 160" aria-hidden="true">
    <circle class="ring-bg" cx="80" cy="80" r="70" fill="none" stroke="rgba(255,255,255,.12)" stroke-width="6"/>
    <circle class="ring-fg" cx="80" cy="80" r="70" fill="none" stroke="#FF7DA8" stroke-width="6" stroke-linecap="round" stroke-dasharray="440"/>
  </svg>

  <div class="count" aria-hidden="true">3</div>

  <svg class="rocket" viewBox="0 0 40 72" aria-hidden="true">
    <path d="M12 18 L20 2 L28 18 Z" fill="#FF7DA8"/>
    <rect x="10" y="16" width="20" height="40" rx="10" fill="#F2F2F2"/>
    <circle cx="20" cy="30" r="5" fill="#9B6BFF"/>
    <path d="M10 46 L4 60 L10 56 Z" fill="#FF7DA8"/>
    <path d="M30 46 L36 60 L30 56 Z" fill="#FF7DA8"/>
    <path class="flame" d="M14 54 L20 72 L26 54 Z" fill="#FFE3B0"/>
  </svg>

  <div class="loaded" aria-hidden="true">WELCOME</div>
</div>
CSS
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #14081e; }

.stage {
  position: relative; width: 100%; height: 100vh;
  min-height: 260px; max-height: 100%; overflow: hidden;
  background: #14081e;
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: #F2F2F2;
}
.stars { position: absolute; inset: 0; z-index: 0; display: block; }

.caption {
  position: absolute; left: 50%; top: 15%; transform: translateX(-50%);
  z-index: 1; margin: 0; font-size: 12px; letter-spacing: .26em; color: rgba(255,209,224,.7);
}

.ring {
  position: absolute; left: 50%; top: 50%;
  width: 180px; height: 180px;
  transform: translate(-50%, -50%) rotate(-90deg); z-index: 1;
}
.count {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 2;
  font-size: 96px; font-weight: 900; line-height: 1; font-variant-numeric: tabular-nums; color: #fff;
}
.rocket { position: absolute; left: 50%; top: 60%; width: 56px; height: auto; transform: translate(-50%, -50%); z-index: 2; }
.flame { transform-box: fill-box; transform-origin: center top; animation: flame .12s steps(3) infinite alternate; }
@keyframes flame { from { transform: scaleY(.7); } to { transform: scaleY(1.2); } }

.loaded {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 2;
  font-size: 28px; font-weight: 700; letter-spacing: .4em; text-indent: .4em; color: #FFD9E0; opacity: 0;
}
JavaScript
// 星空canvas+数字/リング/ロケット。フェーズは elapsed 1本で8s分割(デモと同じロジック)。
(() => {
  const stage  = document.querySelector('[data-loader]');
  if (!stage) return;
  const cv     = stage.querySelector('.stars');
  const ctx    = cv.getContext('2d');
  const numEl  = stage.querySelector('.count');
  const ringFg = stage.querySelector('.ring-fg');
  const ring   = stage.querySelector('.ring');
  const rocket = stage.querySelector('.rocket');
  const loaded = stage.querySelector('.loaded');

  const easeOutExpo = t => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t));
  const clamp01 = t => Math.max(0, Math.min(1, t));

  let W = 0, H = 0, dpr = 1;
  const stars = [];
  const initStars = () => {
    stars.length = 0;
    for (let i = 0; i < 120; i++) stars.push({ xr: ((i * 137.5) % 100) / 100, y: ((i * 53) % 100) / 100 * H, size: 1 + (i % 2) });
  };
  const resize = () => {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = cv.clientWidth; H = cv.clientHeight;
    cv.width = W * dpr; cv.height = H * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    initStars();
  };
  window.addEventListener('resize', resize);
  resize();

  const T = 8, RING = 440;
  let start = 0;

  const loop = (now) => {
    if (!start) start = now;
    const e = ((now - start) / 1000) % T;

    let warp = 0;
    if (e >= 4.2 && e < 6) warp = 1 + 17 * ((e - 4.2) / 1.8);
    const step = warp > 0 ? warp : 0.4;
    ctx.fillStyle = warp > 0 ? 'rgba(20,8,30,0.3)' : '#14081e';
    ctx.fillRect(0, 0, W, H);
    ctx.fillStyle = '#fff'; ctx.strokeStyle = '#fff';
    for (const s of stars) {
      const x = s.xr * W;
      const prevY = s.y;
      let y = prevY + step;
      let wrapped = false;
      if (y > H) { y -= H; wrapped = true; }
      s.y = y;
      if (warp > 0 && !wrapped) { ctx.lineWidth = s.size; ctx.beginPath(); ctx.moveTo(x, prevY); ctx.lineTo(x, y); ctx.stroke(); }
      else { ctx.fillRect(x - s.size / 2, y - s.size / 2, s.size, s.size); }
    }

    stage.style.opacity = e > 7.7 ? clamp01(1 - (e - 7.7) / 0.3) : 1;

    if (e < 3) {
      const n = 3 - Math.floor(e);
      const p = e - Math.floor(e);
      const a = clamp01(p / 0.3);
      numEl.textContent = n;
      numEl.style.opacity = easeOutExpo(a);
      numEl.style.transform = `translate(-50%, -50%) scale(${1.4 - 0.4 * easeOutExpo(a)})`;
      ring.style.opacity = 1;
      ringFg.style.strokeDashoffset = RING * (1 - p);
      rocket.style.opacity = 1;
      rocket.style.transform = 'translate(-50%, -50%)';
      loaded.style.opacity = 0;
    } else if (e < 4.2) {
      numEl.style.opacity = 0; ring.style.opacity = 0;
      const lp = clamp01((e - 3) / 1.2);
      const ease = lp * lp;
      rocket.style.opacity = 1;
      rocket.style.transform = `translate(-50%, -50%) translateY(${-130 * ease}vh)`;
      loaded.style.opacity = 0;
    } else if (e < 6) {
      rocket.style.opacity = 0; numEl.style.opacity = 0; ring.style.opacity = 0; loaded.style.opacity = 0;
    } else if (e < 7) {
      const lp = clamp01((e - 6) / 0.6);
      loaded.style.opacity = easeOutExpo(lp);
      loaded.style.letterSpacing = (0.4 - 0.3 * easeOutExpo(lp)) + 'em';
    } else {
      loaded.style.opacity = 1; loaded.style.letterSpacing = '0.1em';
    }
    requestAnimationFrame(loop);
  };
  requestAnimationFrame(loop);
})();

実装ガイド

使いどころ

体験型サイトやイベントの導入ローダーに。待ち時間を「開始」の高揚感に変えます。

実装時の注意点

星空はcanvas、数字/リング/ロケットはDOM+SVGのハイブリッド構成です(全canvasより役割分担が楽)。フェーズはelapsed時間1本で8sを分割します(複数タイマー併用は事故る)。ワープ中は星を「前フレーム→現位置」の線分で描き、線幅=星サイズにしないと細くて消えます。残像は半透明塗りで表現します。

対応ブラウザ

Canvas 2D・SVG・requestAnimationFrameは全モダンブラウザで安定動作します。tabular-numsで数字幅を固定し、実際のローダーでは読み込み完了イベントと演出の終端を同期させます。対応は実機で確認してください。

よくある失敗

フェーズを複数タイマーで分けるとズレるので経過時間ひとつで分岐します。ワープの線幅を細いままにすると流線が消えます。文字とUIを全部canvasに描くと管理が煩雑になるためDOMと分担します。

応用例

配色やキャプションをテーマに、カウント秒数を実ロードに連動、ロケットをブランド要素に、完了後に本編へフェードなどに発展できます。

コード

HTML
<!-- 打ち上げカウントダウン・ローダー:3・2・1→発射→ワープで読み込み完了を告げる -->
<div class="stage" data-loader aria-label="打ち上げカウントダウンのローダー">
  <canvas class="stars" aria-hidden="true"></canvas>

  <p class="caption">INITIALIZING<span class="dots">...</span></p>

  <svg class="ring" viewBox="0 0 160 160" aria-hidden="true">
    <circle class="ring-bg" cx="80" cy="80" r="70" fill="none" stroke="rgba(255,255,255,.12)" stroke-width="6"/>
    <circle class="ring-fg" cx="80" cy="80" r="70" fill="none" stroke="#4ED1A1" stroke-width="6" stroke-linecap="round" stroke-dasharray="440"/>
  </svg>

  <div class="count" aria-hidden="true">3</div>

  <svg class="rocket" viewBox="0 0 40 72" aria-hidden="true">
    <path d="M12 18 L20 2 L28 18 Z" fill="#FF5C5C"/>
    <rect x="10" y="16" width="20" height="40" rx="10" fill="#F2F2F2"/>
    <circle cx="20" cy="30" r="5" fill="#4D7CFE"/>
    <path d="M10 46 L4 60 L10 56 Z" fill="#FF5C5C"/>
    <path d="M30 46 L36 60 L30 56 Z" fill="#FF5C5C"/>
    <path class="flame" d="M14 54 L20 72 L26 54 Z" fill="#FFC83D"/>
  </svg>

  <div class="loaded" aria-hidden="true">LOADED</div>
</div>
CSS
:root{
  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-spring:   cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-inout:    cubic-bezier(0.65, 0, 0.35, 1);
}
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #0F1117; }

.stage {
  position: relative;
  width: 100%;
  height: 100vh;
  min-height: 260px;
  max-height: 100%;
  overflow: hidden;
  background: #0F1117;
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  color: #F2F2F2;
}
.stars { position: absolute; inset: 0; z-index: 0; display: block; }

.caption {
  position: absolute; left: 50%; top: 15%; transform: translateX(-50%);
  z-index: 1; margin: 0;
  font-size: 12px; letter-spacing: .26em; color: rgba(242,242,242,.6);
}

.ring {
  position: absolute; left: 50%; top: 50%;
  width: 180px; height: 180px;
  transform: translate(-50%, -50%) rotate(-90deg); /* 12時始点 */
  z-index: 1;
}

.count {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  z-index: 2;
  font-size: 96px; font-weight: 900; line-height: 1;
  font-variant-numeric: tabular-nums;
  color: #F2F2F2;
}

.rocket {
  position: absolute; left: 50%; top: 60%;
  width: 56px; height: auto;
  transform: translate(-50%, -50%);
  z-index: 2;
}
.flame {
  transform-box: fill-box;
  transform-origin: center top;
  animation: flame .12s steps(3) infinite alternate;
}
@keyframes flame { from { transform: scaleY(.7); } to { transform: scaleY(1.2); } }

.loaded {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  z-index: 2;
  font-size: 28px; font-weight: 700; letter-spacing: .4em;
  text-indent: .4em; /* letter-spacing分の右寄りを補正 */
  color: #F2F2F2; opacity: 0;
}
JavaScript
// 星空canvas+数字/リング/ロケットのDOM。フェーズは elapsed 時間1本で分割(8sサイクル)。
(() => {
  const stage  = document.querySelector('[data-loader]');
  if (!stage) return; // null安全
  const cv     = stage.querySelector('.stars');
  const ctx    = cv.getContext('2d');
  const numEl  = stage.querySelector('.count');
  const ringFg = stage.querySelector('.ring-fg');
  const ring   = stage.querySelector('.ring');
  const rocket = stage.querySelector('.rocket');
  const loaded = stage.querySelector('.loaded');

  const easeOutExpo = t => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t));
  const clamp01 = t => Math.max(0, Math.min(1, t));

  let W = 0, H = 0, dpr = 1;
  const stars = [];
  const initStars = () => {
    stars.length = 0;
    for (let i = 0; i < 120; i++) {
      stars.push({ xr: ((i * 137.5) % 100) / 100, y: ((i * 53) % 100) / 100 * H, size: 1 + (i % 2) });
    }
  };
  const resize = () => {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = cv.clientWidth; H = cv.clientHeight;
    cv.width = W * dpr; cv.height = H * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    initStars();
  };
  window.addEventListener('resize', resize);
  resize();

  const T = 8, RING = 440;
  let start = 0;

  const loop = (now) => {
    if (!start) start = now;
    const e = ((now - start) / 1000) % T;

    // ---- 星空 / ワープ ----
    let warp = 0;
    if (e >= 4.2 && e < 6) warp = 1 + 17 * ((e - 4.2) / 1.8); // 1→18px/f
    const step = warp > 0 ? warp : 0.4;
    if (warp > 0) { ctx.fillStyle = 'rgba(15,17,23,0.3)'; }
    else { ctx.fillStyle = '#0F1117'; }
    ctx.fillRect(0, 0, W, H);
    ctx.fillStyle = '#fff'; ctx.strokeStyle = '#fff';
    for (const s of stars) {
      const x = s.xr * W;
      const prevY = s.y;
      let y = prevY + step;
      let wrapped = false;
      if (y > H) { y -= H; wrapped = true; }
      s.y = y;
      if (warp > 0 && !wrapped) {
        ctx.lineWidth = s.size; // 線幅=星サイズにしないと細くて消える
        ctx.beginPath(); ctx.moveTo(x, prevY); ctx.lineTo(x, y); ctx.stroke();
      } else {
        ctx.fillRect(x - s.size / 2, y - s.size / 2, s.size, s.size);
      }
    }

    // ---- 全体フェード(サイクル末) ----
    stage.style.opacity = e > 7.7 ? clamp01(1 - (e - 7.7) / 0.3) : 1;

    // ---- フェーズ別のDOM ----
    if (e < 3) {                       // 1. カウントダウン
      const n = 3 - Math.floor(e);
      const p = e - Math.floor(e);
      const a = clamp01(p / 0.3);
      numEl.textContent = n;
      numEl.style.opacity = easeOutExpo(a);
      numEl.style.transform = `translate(-50%, -50%) scale(${1.4 - 0.4 * easeOutExpo(a)})`;
      ring.style.opacity = 1;
      ringFg.style.strokeDashoffset = RING * (1 - p); // 毎秒 440→0
      rocket.style.opacity = 1;
      rocket.style.transform = 'translate(-50%, -50%)';
      loaded.style.opacity = 0;
    } else if (e < 4.2) {              // 2. 発射
      numEl.style.opacity = 0;
      ring.style.opacity = 0;
      const lp = clamp01((e - 3) / 1.2);
      const ease = lp * lp;            // cubic-bezier(0.5,0,1,1) ≒ ease-in
      rocket.style.opacity = 1;
      rocket.style.transform = `translate(-50%, -50%) translateY(${-130 * ease}vh)`;
      loaded.style.opacity = 0;
    } else if (e < 6) {                // 3. ワープ
      rocket.style.opacity = 0;
      numEl.style.opacity = 0;
      ring.style.opacity = 0;
      loaded.style.opacity = 0;
    } else if (e < 7) {                // 4. 完了
      const lp = clamp01((e - 6) / 0.6);
      loaded.style.opacity = easeOutExpo(lp);
      loaded.style.letterSpacing = (0.4 - 0.3 * easeOutExpo(lp)) + 'em';
    } else {                           // 5. 停止
      loaded.style.opacity = 1;
      loaded.style.letterSpacing = '0.1em';
    }

    requestAnimationFrame(loop);
  };
  requestAnimationFrame(loop);
})();

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

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

# 追加してほしい効果
打ち上げカウントダウン・ローダー(ローダー & スケルトン)
HUD風の3・2・1カウントダウンからロケットが発射し、星が流線になるワープで読み込み完了を告げるローダー演出です。待ち時間をミッション開始の高揚感に変える、体験型サイトの導入に。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 打ち上げカウントダウン・ローダー:3・2・1→発射→ワープで読み込み完了を告げる -->
<div class="stage" data-loader aria-label="打ち上げカウントダウンのローダー">
  <canvas class="stars" aria-hidden="true"></canvas>

  <p class="caption">INITIALIZING<span class="dots">...</span></p>

  <svg class="ring" viewBox="0 0 160 160" aria-hidden="true">
    <circle class="ring-bg" cx="80" cy="80" r="70" fill="none" stroke="rgba(255,255,255,.12)" stroke-width="6"/>
    <circle class="ring-fg" cx="80" cy="80" r="70" fill="none" stroke="#4ED1A1" stroke-width="6" stroke-linecap="round" stroke-dasharray="440"/>
  </svg>

  <div class="count" aria-hidden="true">3</div>

  <svg class="rocket" viewBox="0 0 40 72" aria-hidden="true">
    <path d="M12 18 L20 2 L28 18 Z" fill="#FF5C5C"/>
    <rect x="10" y="16" width="20" height="40" rx="10" fill="#F2F2F2"/>
    <circle cx="20" cy="30" r="5" fill="#4D7CFE"/>
    <path d="M10 46 L4 60 L10 56 Z" fill="#FF5C5C"/>
    <path d="M30 46 L36 60 L30 56 Z" fill="#FF5C5C"/>
    <path class="flame" d="M14 54 L20 72 L26 54 Z" fill="#FFC83D"/>
  </svg>

  <div class="loaded" aria-hidden="true">LOADED</div>
</div>

【CSS】
:root{
  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-spring:   cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-inout:    cubic-bezier(0.65, 0, 0.35, 1);
}
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #0F1117; }

.stage {
  position: relative;
  width: 100%;
  height: 100vh;
  min-height: 260px;
  max-height: 100%;
  overflow: hidden;
  background: #0F1117;
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  color: #F2F2F2;
}
.stars { position: absolute; inset: 0; z-index: 0; display: block; }

.caption {
  position: absolute; left: 50%; top: 15%; transform: translateX(-50%);
  z-index: 1; margin: 0;
  font-size: 12px; letter-spacing: .26em; color: rgba(242,242,242,.6);
}

.ring {
  position: absolute; left: 50%; top: 50%;
  width: 180px; height: 180px;
  transform: translate(-50%, -50%) rotate(-90deg); /* 12時始点 */
  z-index: 1;
}

.count {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  z-index: 2;
  font-size: 96px; font-weight: 900; line-height: 1;
  font-variant-numeric: tabular-nums;
  color: #F2F2F2;
}

.rocket {
  position: absolute; left: 50%; top: 60%;
  width: 56px; height: auto;
  transform: translate(-50%, -50%);
  z-index: 2;
}
.flame {
  transform-box: fill-box;
  transform-origin: center top;
  animation: flame .12s steps(3) infinite alternate;
}
@keyframes flame { from { transform: scaleY(.7); } to { transform: scaleY(1.2); } }

.loaded {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  z-index: 2;
  font-size: 28px; font-weight: 700; letter-spacing: .4em;
  text-indent: .4em; /* letter-spacing分の右寄りを補正 */
  color: #F2F2F2; opacity: 0;
}

【JavaScript】
// 星空canvas+数字/リング/ロケットのDOM。フェーズは elapsed 時間1本で分割(8sサイクル)。
(() => {
  const stage  = document.querySelector('[data-loader]');
  if (!stage) return; // null安全
  const cv     = stage.querySelector('.stars');
  const ctx    = cv.getContext('2d');
  const numEl  = stage.querySelector('.count');
  const ringFg = stage.querySelector('.ring-fg');
  const ring   = stage.querySelector('.ring');
  const rocket = stage.querySelector('.rocket');
  const loaded = stage.querySelector('.loaded');

  const easeOutExpo = t => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t));
  const clamp01 = t => Math.max(0, Math.min(1, t));

  let W = 0, H = 0, dpr = 1;
  const stars = [];
  const initStars = () => {
    stars.length = 0;
    for (let i = 0; i < 120; i++) {
      stars.push({ xr: ((i * 137.5) % 100) / 100, y: ((i * 53) % 100) / 100 * H, size: 1 + (i % 2) });
    }
  };
  const resize = () => {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = cv.clientWidth; H = cv.clientHeight;
    cv.width = W * dpr; cv.height = H * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    initStars();
  };
  window.addEventListener('resize', resize);
  resize();

  const T = 8, RING = 440;
  let start = 0;

  const loop = (now) => {
    if (!start) start = now;
    const e = ((now - start) / 1000) % T;

    // ---- 星空 / ワープ ----
    let warp = 0;
    if (e >= 4.2 && e < 6) warp = 1 + 17 * ((e - 4.2) / 1.8); // 1→18px/f
    const step = warp > 0 ? warp : 0.4;
    if (warp > 0) { ctx.fillStyle = 'rgba(15,17,23,0.3)'; }
    else { ctx.fillStyle = '#0F1117'; }
    ctx.fillRect(0, 0, W, H);
    ctx.fillStyle = '#fff'; ctx.strokeStyle = '#fff';
    for (const s of stars) {
      const x = s.xr * W;
      const prevY = s.y;
      let y = prevY + step;
      let wrapped = false;
      if (y > H) { y -= H; wrapped = true; }
      s.y = y;
      if (warp > 0 && !wrapped) {
        ctx.lineWidth = s.size; // 線幅=星サイズにしないと細くて消える
        ctx.beginPath(); ctx.moveTo(x, prevY); ctx.lineTo(x, y); ctx.stroke();
      } else {
        ctx.fillRect(x - s.size / 2, y - s.size / 2, s.size, s.size);
      }
    }

    // ---- 全体フェード(サイクル末) ----
    stage.style.opacity = e > 7.7 ? clamp01(1 - (e - 7.7) / 0.3) : 1;

    // ---- フェーズ別のDOM ----
    if (e < 3) {                       // 1. カウントダウン
      const n = 3 - Math.floor(e);
      const p = e - Math.floor(e);
      const a = clamp01(p / 0.3);
      numEl.textContent = n;
      numEl.style.opacity = easeOutExpo(a);
      numEl.style.transform = `translate(-50%, -50%) scale(${1.4 - 0.4 * easeOutExpo(a)})`;
      ring.style.opacity = 1;
      ringFg.style.strokeDashoffset = RING * (1 - p); // 毎秒 440→0
      rocket.style.opacity = 1;
      rocket.style.transform = 'translate(-50%, -50%)';
      loaded.style.opacity = 0;
    } else if (e < 4.2) {              // 2. 発射
      numEl.style.opacity = 0;
      ring.style.opacity = 0;
      const lp = clamp01((e - 3) / 1.2);
      const ease = lp * lp;            // cubic-bezier(0.5,0,1,1) ≒ ease-in
      rocket.style.opacity = 1;
      rocket.style.transform = `translate(-50%, -50%) translateY(${-130 * ease}vh)`;
      loaded.style.opacity = 0;
    } else if (e < 6) {                // 3. ワープ
      rocket.style.opacity = 0;
      numEl.style.opacity = 0;
      ring.style.opacity = 0;
      loaded.style.opacity = 0;
    } else if (e < 7) {                // 4. 完了
      const lp = clamp01((e - 6) / 0.6);
      loaded.style.opacity = easeOutExpo(lp);
      loaded.style.letterSpacing = (0.4 - 0.3 * easeOutExpo(lp)) + 'em';
    } else {                           // 5. 停止
      loaded.style.opacity = 1;
      loaded.style.letterSpacing = '0.1em';
    }

    requestAnimationFrame(loop);
  };
  requestAnimationFrame(loop);
})();

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

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