禮物開箱動態 · 純 CSS 規格與範例

點一下就能看效果;往下有時間軸、參數表、可直接複製的 CSS/JS/HTML,以及給 AI 的提示詞與規格。

v1.0只動 transform / opacity無相依套件兩張圖共 46 KB支援 prefers-reduced-motion
單一檔案版(可直接開) gift-open.css gift-open.js AI-SPEC.md
給 AI/工程師直接照做的規格。只靠 CSS 動畫+一支精簡的 JS 控制器,無任何相依套件。
預覽與互動說明:https://gift-open-fx.pages.dev/ | 單檔可直接開啟:https://gift-open-fx.pages.dev/gift-open.standalone.html

示範(可操作)

待领取奖励1 / 11
+6.00
每日任务奖励
可领取时间 2026-10-07 14:25:29

除錯控制

播放速度
金幣幣面符號

按「可領取」開始;播完按「重播」會回到未開狀態,再按一次可再播。示範文案為簡體,沿用產品畫面。慢 4× 最適合檢查換圖那一瞬間。

給 AI 的提示詞(複製貼上即可)

提示詞
你是前端工程師。請把「禮物開箱動態」整合進我的專案,嚴格照規格,不要自行改動任何動畫數值或 keyframes。
1. 先讀完整規格(內含完整 CSS、JS、HTML 結構、時間軸與驗收清單):
   https://gift-open-fx.pages.dev/AI-SPEC.md
2. 取得下列檔案:
   https://gift-open-fx.pages.dev/gift-open.css
   https://gift-open-fx.pages.dev/gift-open.js
   https://gift-open-fx.pages.dev/assets/gift_closed.webp
   https://gift-open-fx.pages.dev/assets/gift_open.webp
3. 在要放盒子的位置貼上規格第 2 節的 HTML 結構,根元素 class 必須是 gfx;用 const gift = GiftOpen(el, { onOpen, onDone }) 初始化,按鈕點擊呼叫 gift.play()。
4. 只能用 CSS 變數 --gfx-*(規格第 5 節)調外觀與節奏。所有動畫只可動 transform/opacity;元件的容器不可 overflow:hidden。
5. 若要換成別的盒子,只換兩張圖,且必須同框、已開圖不可烤入特效(規格第 7 節)。
6. 完成後逐項回報規格第 12 節的驗收清單。

0一句話

點擊按鈕後:禮物盒抖動 0.7 秒 → 閃光蓋住 → 最亮時把「未開」圖換成「已開」圖 → 光芒爆開並慢轉、一圈光環、12 枚金幣(幣面為錢幣符號)+8 顆閃光點噴出、4 顆浮光點持續飄、獎勵數字浮出。全程約 2.3 秒,之後只剩光芒慢轉與浮光點。

1檔案與取得方式

全部是靜態檔,可直接下載。

檔案用途
https://gift-open-fx.pages.dev/gift-open.css全部動畫與樣式(本文第 10 節即為其完整內容)
https://gift-open-fx.pages.dev/gift-open.js狀態控制器 GiftOpen(本文第 11 節即為其完整內容)
https://gift-open-fx.pages.dev/assets/gift_closed.webp未開圖,340×340 透明 WebP(≈18 KB)
https://gift-open-fx.pages.dev/assets/gift_open.webp已開圖,340×340 透明 WebP(≈28 KB),與未開圖「同框」
https://gift-open-fx.pages.dev/gift-open.standalone.html單一檔案版(CSS、JS、圖片都內嵌),拿來直接開或當範例

2HTML 結構

根元素 class 必須是 gfx;子元素的順序決定疊層,不可調換。

html
<!-- 根元素 class 必須是 gfx;effects 的順序決定疊層,請勿調換 -->
<div class="gfx" id="gift" aria-hidden="true">
  <div class="gfx__halo"></div>
  <div class="gfx__ring"></div>
  <div class="gfx__rays"></div>
  <div class="gfx__glow"><div class="gfx__rays gfx__rays--bright"></div></div>
  <div class="gfx__box">
    <img class="gfx__img gfx__img--closed" src="assets/gift_closed.webp" alt="" width="340" height="340">
    <img class="gfx__img gfx__img--open"   src="assets/gift_open.webp"   alt="" width="340" height="340">
  </div>
  <div class="gfx__motes"></div>   <!-- JS 自動放入 4 顆浮光點 -->
  <div class="gfx__flash"></div>
  <!-- JS 自動加入 .gfx__coin ×12、.gfx__spark ×8 -->
</div>
<!-- 選用:獎勵數字,必須是 .gfx 的「後面的兄弟節點」 -->
<div class="gfx-reward">+6.00</div>

3初始化與 API

js
const gift = GiftOpen(document.getElementById('gift'), {
  speed: 1,          // 時間倍率;慢動作除錯用 2、4
  flash: true,       // false=不閃光,兩張圖直接硬切
  onOpen: () => {},  // 換成「已開」圖的那一刻(可在此呼叫領取 API 的 UI 更新)
  onDone: () => {}   // 整段播完(約 2.3 秒)
});
button.onclick = () => gift.play();   // 回傳 Promise;播放中重複呼叫會回傳同一個 Promise
// gift.reset()      回到未開狀態並重新產生金幣、星星
// gift.setSpeed(2)  setFlash(false)  destroy()

按鈕載入狀態(點擊後按鈕內要轉圈):

html
<button id="claim" type="button">可领取</button>
js
claim.addEventListener('click', () => {
  claim.disabled = true;                       // 不可重複點
  claim.setAttribute('aria-busy', 'true');
  claim.innerHTML = '<span class="gfx-spinner" aria-hidden="true"></span>';   // 按鈕內轉圈
  gift.play();
});
// onDone 時:claim.disabled = false; claim.removeAttribute('aria-busy'); claim.textContent = '重播';

轉圈樣式 .gfx-spinner 已包含在 gift-open.css(第 12 段),顏色用 --gfx-spinner-rgb 調整。

4狀態機與時間軸

speed = 1,單位 ms,自 play() 起算。

時間動作(JS 對 .gfx 加/去 class)發生什麼
0加 is-playing is-shaking盒子左右抖動並逐步放大到 1.08;光暈放大到 1.25 倍(0.7 s)
700加 is-flashing閃光自中心擴散(0.62 s,約 190 ms 時最亮)
890去 is-shaking;加 is-open is-pop is-burst;呼叫 onOpen閃光最亮 → 換成已開圖;光環一圈(1.1 s);光芒爆開(1.2 s)後開始慢轉;亮度閃一下(1.2 s);金幣 12+星星 8 噴出(0.9–1.3 s);浮光點淡入;盒子彈跳(0.55 s);獎勵數字浮出(延遲 0.25 s、0.5 s)
2290去 is-playing;呼叫 onDone播完。之後只剩光芒慢轉+浮光點循環
任意切換 is-paused元件離開畫面或分頁隱藏時,暫停全部動畫

5CSS 變數(Token)

變數預設說明
--gfx-speed1全部時間乘上此倍率。設在 :root 或與 .gfx-reward 的共同祖先
--gfx-w --gfx-h323px 220px舞台尺寸;所有效果以中心為原點,超出舞台不裁切
--gfx-box170px盒子邊長。圖檔是 340px(2 倍解析度),請維持圖檔 ≥ 顯示尺寸的 2 倍
--gfx-halo240px常駐光暈直徑
--gfx-rays380px光芒直徑。可見半徑約為直徑的 0.34 倍(≈130px);要更大範圍改 440(≈150px)
--gfx-spin16s光芒轉一圈的時間
--gfx-gold255,238,175主光色 RGB(逗號分隔,用於 rgba(var(--gfx-gold), α))。換冷色例:200,230,255
--gfx-gold-hi255,246,205開箱瞬間的亮部光色
--gfx-mote-scale.743浮光點整體範圍
--gfx-coin-symbol"$"噴出金幣的幣面符號,字串需含引號。例:"¥"、"€"。設在 :root 或任一祖先
--gfx-spinner-rgb42,29,0按鈕內轉圈的顏色 RGB(預設為金色按鈕上的深色字)

6元素與 keyframes 對照

元素作用keyframes時長/緩動只動的屬性
.gfx__box抖動gfx-shake0.7 s/ease-in-outtransform
.gfx__box開啟彈跳gfx-pop0.55 s/cubic-bezier(.2,1.6,.4,1)transform
.gfx__flash閃光gfx-flash0.62 s/ease-outopacity, transform
.gfx__halo開箱後光暈gfx-halo-beat1.2 s/ease-outtransform, opacity
.gfx__ring光環(只播一次)gfx-ring-once1.1 s/cubic-bezier(.15,.7,.3,1)transform, opacity
.gfx__rays光芒爆開gfx-rays-burst1.2 s/cubic-bezier(.1,.8,.3,1)transform, opacity
.gfx__rays光芒慢轉gfx-spin16 s/linear,延遲 1.2 s,無限rotate
.gfx__glow亮度閃一下gfx-glow-pulse1.2 s/ease-outopacity
.gfx__coin ×12噴出(幣面為 --gfx-coin-symbol)gfx-coin0.9–1.3 s(隨機)/cubic-bezier(.15,.7,.3,1)transform, opacity
.gfx__spark ×8閃點gfx-spark0.9 s/ease-outtransform, opacity
.gfx__mote ×4上飄+左右飄移+閃爍gfx-mote-rise/-sway/-twinkle4.2–7 s/1.8–3.2 s/1–2.2 s,無限transform, translate, opacity
.gfx-spinner按鈕內轉圈(選用)gfx-spinner0.8 s/linear,無限;不受 --gfx-speed 影響transform
.gfx-reward數字浮出gfx-rise0.5 s(延遲 0.25 s)/ease-outopacity, transform

7素材規格

要換成別的主題(寶箱、紅包⋯)時照此準備。

8效能與無障礙規則

以下必須遵守。

  1. 只動 transform、opacity、rotate、scale、translate。 禁止動畫 width/height/top/left/margin/box-shadow/filter。
  2. will-change 只在 .is-playing 期間存在;不要常駐。
  3. 離開視窗或分頁隱藏時必須暫停(控制器已處理,切換 .is-paused)。
  4. 整個元件只有光環一處使用 mix-blend-mode: screen,不要再加其他混合模式。
  5. 元件的容器不可 overflow:hidden(光芒、光環、閃光會超出舞台)。若特效超出視窗寬度造成頁面橫向捲軸(手機常見,透明的層也會撐寬),只在最外層頁面容器加 overflow-x:clip,不要加在元件的容器上。元件本身有 isolation:isolate,特效不會蓋到外面的文字。
  6. 同一頁面同時在畫面內的實例建議 ≤ 3 個。
  7. 尊重 prefers-reduced-motion: reduce:已內建——不抖、不閃、不噴金幣,光芒只淡入。
  8. 動畫是純裝飾,根元素已加 aria-hidden="true";獎勵結果請另外用真正的文字(例如 aria-live="polite")告知。

實測條件:桌機 Chrome、CPU 4 倍降速、播放 5 秒、只含元件的測試頁。結果約 120 FPS、超過 20 ms 的格數 0、排版重算 2 次(共約 1 ms)。放進真實頁面後數字會受頁面其他內容影響(例如本說明頁因按鈕狀態切換,曾量到 1 格 26 ms)。手機請實機再測。

9常見修改

照表調整,不要動 keyframes。

想做的事怎麼改
光芒範圍變大--gfx-rays: 440px
光芒轉慢/轉快--gfx-spin: 24s/10s
換光色--gfx-gold、--gfx-gold-hi
整段變慢除錯gift.setSpeed(4) 或 :root{--gfx-speed:4}
換成別的盒子(寶箱、紅包…)只換兩張圖,其餘不動(須符合第 7 節)
少一個特效從 HTML 移除對應元素(.gfx__ring、.gfx__glow、.gfx__motes 皆獨立,可單獨移除);不要移除 .gfx__flash 又保留硬切換
金幣翻轉角度gift-open.js 的 spawnParticles 內 --r,目前 ±60°(符號有上下方向,翻太多會倒著);要多翻滾可放大
換金幣的幣面符號:root{ --gfx-coin-symbol: "¥"; }(字串要含引號)
金幣、星星數量gift-open.js 的 spawnParticles 內的 12、8
光環要重複不建議;設計上光環只在開箱那一下出現一次

10完整 CSS(gift-open.css)

css
/*!
 * gift-open.css  v1.0
 * 禮物開箱動態:純 CSS 特效 + 極薄的 JS 控制(gift-open.js)
 *
 * 鐵則
 *  1. 所有動畫只動 transform / opacity(含 rotate、scale、translate 屬性),不觸發排版與重繪。
 *  2. 只用 CSS 變數 --gfx-* 調整外觀與節奏;不要改 keyframes 內的數值。
 *  3. 光芒、光環、閃光會超出舞台範圍:元件的容器請勿設 overflow:hidden。
 *     若特效超出視窗造成頁面橫向捲軸(手機常見),只在最外層頁面容器加 overflow-x:clip。
 *
 * 狀態 class(由 gift-open.js 依序加到 .gfx 根元素):
 *   is-playing → is-shaking → is-flashing → is-open + is-pop + is-burst
 *   is-paused(離開畫面/分頁隱藏時暫停無限動畫)
 */

/* ───────── 1. Token(可調參數)───────── */
:root{
  --gfx-coin-symbol:"$";       /* 金幣幣面的符號(字串,需含引號)。例:"¥"、"€"。可設在 :root 或任一祖先 */
  --gfx-spinner-rgb:42,29,0;   /* 按鈕轉圈的顏色 RGB(預設是金色按鈕上的深色字)*/
  --gfx-speed:1;               /* 時間倍率:1 正常、2 慢 2 倍、0.5 快 2 倍。
                                  請設在 :root 或元件與「獎勵數字」的共同祖先,兩邊才會一起變慢 */
}
.gfx{
  --gfx-w:323px;               /* 舞台寬 */
  --gfx-h:220px;               /* 舞台高 */
  --gfx-box:170px;             /* 盒子邊長 */
  --gfx-halo:240px;            /* 常駐光暈直徑 */
  --gfx-rays:380px;            /* 光芒直徑;實際可見半徑約為直徑的 0.34 倍(≈130px) */
  --gfx-spin:16s;              /* 光芒轉一圈的時間(開箱後 1.2 秒才開始轉) */
  --gfx-gold:255,238,175;      /* 主光色 RGB(逗號分隔,供 rgba() 使用) */
  --gfx-gold-hi:255,246,205;   /* 亮部光色 RGB(開箱瞬間的亮閃) */
  --gfx-mote-scale:.743;       /* 浮光點整體範圍縮放 */

  position:relative;
  width:var(--gfx-w);
  height:var(--gfx-h);
  isolation:isolate;           /* 特效的 z-index 只在元件內部排序,不會蓋到外面的文字 */
}

/* ───────── 2. 共同定位(全部以舞台正中心為原點)───────── */
.gfx__halo,.gfx__ring,.gfx__rays,.gfx__flash,.gfx__box,
.gfx__coin,.gfx__spark,.gfx__motes{position:absolute;left:50%;top:50%;pointer-events:none}
.gfx__glow{position:absolute;inset:0;pointer-events:none}

/* ───────── 3. 盒子與兩張圖(硬切換,由閃光遮住)───────── */
.gfx__box{
  z-index:3;
  width:var(--gfx-box);height:var(--gfx-box);
  margin:calc(var(--gfx-box)/-2) 0 0 calc(var(--gfx-box)/-2);
}
.gfx__img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
.gfx__img--open{opacity:0}
.gfx.is-open .gfx__img--closed{opacity:0}
.gfx.is-open .gfx__img--open{opacity:1}

/* 0–700ms 抖動(逐步放大到 1.08)→ 開箱瞬間彈跳 */
.gfx.is-shaking .gfx__box{animation:gfx-shake calc(.7s*var(--gfx-speed)) ease-in-out}
.gfx.is-pop .gfx__box{animation:gfx-pop calc(.55s*var(--gfx-speed)) cubic-bezier(.2,1.6,.4,1)}
@keyframes gfx-shake{
  0%,100%{transform:rotate(0) scale(1)}
  15%{transform:rotate(-6deg) scale(1.02)}
  30%{transform:rotate(6deg) scale(1.03)}
  45%{transform:rotate(-5deg) scale(1.04)}
  60%{transform:rotate(5deg) scale(1.05)}
  80%{transform:rotate(-2deg) scale(1.08)}
}
@keyframes gfx-pop{0%{transform:scale(.86)}100%{transform:scale(1)}}

/* ───────── 4. 常駐光暈 ───────── */
.gfx__halo{
  z-index:1;
  width:var(--gfx-halo);height:var(--gfx-halo);
  margin:calc(var(--gfx-halo)/-2) 0 0 calc(var(--gfx-halo)/-2);
  border-radius:50%;
  background:radial-gradient(circle,rgba(239,222,171,.28) 0%,rgba(239,222,171,0) 65%);
  transition:transform .4s,opacity .4s;
}
.gfx.is-shaking .gfx__halo{transform:scale(1.25);opacity:1}          /* 抖動時蓄力放大 */
.gfx.is-open .gfx__halo{                                              /* 開箱後更亮,停在 1.3 倍 .8 透明 */
  background:radial-gradient(circle,rgba(var(--gfx-gold),.45) 0%,rgba(239,222,171,0) 68%);
  animation:gfx-halo-beat calc(1.2s*var(--gfx-speed)) ease-out forwards;
}
@keyframes gfx-halo-beat{
  0%{transform:scale(1.42);opacity:1}
  100%{transform:scale(1.3);opacity:.8}
}

/* ───────── 5. 閃光(蓋住換圖那一瞬間)───────── */
.gfx__flash{
  z-index:6;
  width:420px;height:420px;margin:-210px 0 0 -210px;
  border-radius:50%;opacity:0;
  background:radial-gradient(circle,#fff 0%,#fff6d6 22%,rgba(255,230,160,.85) 38%,rgba(255,220,140,0) 66%);
}
.gfx.is-flashing .gfx__flash{animation:gfx-flash calc(.62s*var(--gfx-speed)) ease-out forwards}
@keyframes gfx-flash{
  0%{opacity:0;transform:scale(.3)}
  30%{opacity:1;transform:scale(1)}      /* 約 190ms 最亮 → 此刻換圖 */
  100%{opacity:0;transform:scale(1.25)}
}

/* ───────── 6. 光環(開箱時只播一次)───────── */
.gfx__ring{
  z-index:2;
  width:440px;height:440px;margin:-220px 0 0 -220px;
  border-radius:50%;opacity:0;
  mix-blend-mode:screen;                 /* 疊在光芒上時,交會處自然變亮 */
  background:radial-gradient(circle,
    rgba(var(--gfx-gold),0) 34%,rgba(255,240,190,.55) 43%,rgba(var(--gfx-gold),.18) 50%,rgba(var(--gfx-gold),0) 58%);
}
.gfx.is-open .gfx__ring{animation:gfx-ring-once calc(1.1s*var(--gfx-speed)) cubic-bezier(.15,.7,.3,1) forwards}
@keyframes gfx-ring-once{
  0%{transform:scale(.45);opacity:1}
  100%{transform:scale(1.6);opacity:0}
}

/* ───────── 7. 光芒:8 道、柔邊、單層;爆開減速後慢轉 ───────── */
.gfx__rays{
  z-index:2;
  width:var(--gfx-rays);height:var(--gfx-rays);
  margin:calc(var(--gfx-rays)/-2) 0 0 calc(var(--gfx-rays)/-2);
  border-radius:50%;opacity:0;
  background:repeating-conic-gradient(from 0deg,
    rgba(var(--gfx-gold),0) 0deg,rgba(var(--gfx-gold),.6) 10deg,rgba(var(--gfx-gold),0) 20deg,rgba(var(--gfx-gold),0) 45deg);
  -webkit-mask:radial-gradient(circle,#000 14%,rgba(0,0,0,.75) 40%,transparent 68%);
          mask:radial-gradient(circle,#000 14%,rgba(0,0,0,.75) 40%,transparent 68%);
}
.gfx.is-open .gfx__rays{
  animation:
    gfx-rays-burst calc(1.2s*var(--gfx-speed)) cubic-bezier(.1,.8,.3,1) forwards,
    gfx-spin calc(var(--gfx-spin)*var(--gfx-speed)) linear calc(1.2s*var(--gfx-speed)) infinite;
}
@keyframes gfx-rays-burst{
  0%{opacity:0;transform:scale(.35) rotate(-60deg)}
  35%{opacity:1}
  100%{opacity:1;transform:scale(1) rotate(0deg)}
}
@keyframes gfx-spin{to{rotate:360deg}}     /* 用 rotate 屬性,與 burst 的 transform 互不干擾 */

/* 亮部:與光芒同位置、同爆開,外層 .gfx__glow 只做「亮一下就退」的透明度脈動 */
.gfx__glow{z-index:2;opacity:0}
.gfx__rays--bright{
  opacity:1;
  background:repeating-conic-gradient(from 0deg,
    rgba(var(--gfx-gold-hi),0) 0deg,rgba(var(--gfx-gold-hi),.75) 10deg,rgba(var(--gfx-gold-hi),0) 20deg,rgba(var(--gfx-gold-hi),0) 45deg);
}
.gfx.is-open .gfx__glow{animation:gfx-glow-pulse calc(1.2s*var(--gfx-speed)) ease-out both}
.gfx.is-open .gfx__rays--bright{
  animation:
    gfx-rays-burst-flat calc(1.2s*var(--gfx-speed)) cubic-bezier(.1,.8,.3,1) forwards,
    gfx-spin calc(var(--gfx-spin)*var(--gfx-speed)) linear calc(1.2s*var(--gfx-speed)) infinite;
}
@keyframes gfx-glow-pulse{0%{opacity:.7}35%{opacity:0}100%{opacity:0}}
@keyframes gfx-rays-burst-flat{
  0%{transform:scale(.35) rotate(-60deg)}
  100%{transform:scale(1) rotate(0deg)}
}

/* ───────── 8. 金幣與星星(全部 CSS 繪製,位置由 JS 以變數帶入)─────────
   金幣幣面是錢幣符號(--gfx-coin-symbol);.gfx__spark 是飄散的四角閃光點,與幣面無關 */
.gfx__coin{
  z-index:5;
  width:26px;height:26px;margin:-13px;border-radius:50%;opacity:0;
  background:radial-gradient(circle at 35% 30%,#fff3cf 0 18%,#f3cf6f 45%,#b8862f 100%);
  box-shadow:inset 0 0 0 3px rgba(176,127,42,.55),0 0 8px rgba(255,214,120,.6);
}
.gfx__coin::after{                      /* 幣面上的錢幣符號(文字,會跟著金幣一起縮放、旋轉)*/
  content:var(--gfx-coin-symbol);
  position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
  font:800 16px/1 ui-rounded,"SF Pro Rounded",system-ui,-apple-system,"Segoe UI","Helvetica Neue",Arial,sans-serif;
  color:#a8741f;
  text-shadow:0 1px 0 rgba(255,243,207,.75);   /* 浮雕感:下緣一道亮邊 */
}
.gfx.is-burst .gfx__coin{
  animation:gfx-coin calc(var(--d)*var(--gfx-speed)) cubic-bezier(.15,.7,.3,1) calc(var(--delay)*var(--gfx-speed)) forwards;
}
@keyframes gfx-coin{                     /* --x --y --r --s --d --delay 由 JS 逐顆指定 */
  0%{opacity:0;transform:translate(0,0) scale(.4) rotate(0)}
  10%{opacity:1}
  70%{opacity:1;transform:translate(var(--x),var(--y)) scale(var(--s)) rotate(var(--r))}
  100%{opacity:0;transform:translate(var(--x),calc(var(--y) + 60px)) scale(var(--s)) rotate(var(--r))}
}
.gfx__spark{
  z-index:5;
  width:14px;height:14px;margin:-7px;opacity:0;background:#fff8e1;
  clip-path:polygon(50% 0,62% 38%,100% 50%,62% 62%,50% 100%,38% 62%,0 50%,38% 38%);
}
.gfx.is-burst .gfx__spark{animation:gfx-spark calc(.9s*var(--gfx-speed)) ease-out calc(var(--delay)*var(--gfx-speed)) forwards}
@keyframes gfx-spark{
  0%{opacity:0;transform:translate(var(--x),var(--y)) scale(.2)}
  40%{opacity:1;transform:translate(var(--x),var(--y)) scale(1.2)}
  100%{opacity:0;transform:translate(var(--x),var(--y)) scale(.4)}
}

/* ───────── 9. 浮光點 4 顆(開箱後淡入,持續循環)─────────
   三層各用不同週期(互不整除):上飄/左右飄移/忽明忽暗,所以看起來不會同步 */
.gfx__motes{
  z-index:7;width:0;height:0;
  transform:scale(var(--gfx-mote-scale));
  opacity:0;transition:opacity .4s;
}
.gfx.is-open .gfx__motes{opacity:1}
.gfx__mote,.gfx__mote-sway{position:absolute;left:0;top:0;width:0;height:0}
.gfx__mote-dot{
  position:absolute;width:var(--d);height:var(--d);margin:calc(var(--d)/-2);border-radius:50%;
  background:radial-gradient(circle,#fffbea 0%,#fff1c4 30%,rgba(255,236,170,.5) 52%,rgba(255,236,170,0) 74%);
}
@keyframes gfx-mote-rise{                /* --x --y0 --y1 */
  0%{transform:translate(var(--x),var(--y0));opacity:0}
  18%{opacity:1}
  80%{opacity:.8}
  100%{transform:translate(var(--x),var(--y1));opacity:0}
}
@keyframes gfx-mote-sway{0%,100%{translate:calc(var(--a)*-1) 0}50%{translate:var(--a) 0}}   /* --a */
@keyframes gfx-mote-twinkle{0%,100%{opacity:.35}50%{opacity:1}}
.gfx__motes *{animation-play-state:paused}                    /* 開箱前不跑 */
.gfx.is-open .gfx__motes *{animation-play-state:running}

/* ───────── 10. 獎勵數字(選用;放在 .gfx 之後的兄弟節點)───────── */
.gfx-reward{opacity:0;transform:translateY(10px)}
.gfx.is-open ~ .gfx-reward{animation:gfx-rise calc(.5s*var(--gfx-speed)) ease-out calc(.25s*var(--gfx-speed)) forwards}
@keyframes gfx-rise{to{opacity:1;transform:none}}

/* ───────── 11. 效能與無障礙 ───────── */
/* 播放期間才提示 GPU,播完由 JS 移除 */
.gfx.is-playing .gfx__box,.gfx.is-playing .gfx__rays,.gfx.is-playing .gfx__glow,
.gfx.is-playing .gfx__flash,.gfx.is-playing .gfx__ring,
.gfx.is-playing .gfx__coin,.gfx.is-playing .gfx__spark{will-change:transform,opacity}
/* 離開畫面或分頁隱藏 → 暫停所有動畫(JS 切換此 class) */
.gfx.is-paused,.gfx.is-paused *{animation-play-state:paused!important}
/* 系統開了「減少動態效果」:不抖、不噴、不旋轉,只淡入 */
@keyframes gfx-fade-in{from{opacity:0}to{opacity:1}}
@media (prefers-reduced-motion:reduce){
  .gfx__ring,.gfx__glow,.gfx__flash,.gfx__coin,.gfx__spark,.gfx__motes{display:none}
  .gfx.is-shaking .gfx__box{animation:none}
  .gfx.is-open .gfx__rays{animation:gfx-fade-in .3s ease-out forwards}
}

/* ───────── 12. 按鈕內的轉圈(選用)─────────
   點擊後把按鈕設為 disabled,並把按鈕內容換成 <span class="gfx-spinner" aria-hidden="true"></span>。
   轉圈不受 --gfx-speed 影響(慢動作除錯時也維持正常轉速)。 */
.gfx-spinner{
  display:inline-block;width:18px;height:18px;vertical-align:middle;
  border-radius:50%;
  border:2px solid rgba(var(--gfx-spinner-rgb),.25);
  border-top-color:rgb(var(--gfx-spinner-rgb));
  animation:gfx-spinner .8s linear infinite;
}
@keyframes gfx-spinner{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){.gfx-spinner{animation-duration:1.6s}}   /* 載入提示不可拿掉,只放慢 */

11完整 JS(gift-open.js)

js
/*!
 * gift-open.js  v1.0  —  禮物開箱動態控制器(無任何相依套件)
 *
 * 用法
 *   const gift = GiftOpen(document.getElementById('gift'), {
 *     speed: 1,            // 時間倍率(慢動作可設 2、4)
 *     flash: true,         // false=不閃光,兩張圖直接硬切(除錯用)
 *     onOpen: () => {},    // 換成「已開」圖的那一刻
 *     onDone: () => {}     // 整段播完
 *   });
 *   gift.play();           // → Promise,播完 resolve;播放中重複呼叫會回傳同一個 Promise
 *   gift.reset();          // 回到未開狀態(並重新產生金幣、星星)
 *   gift.setSpeed(2);
 *   gift.setFlash(false);  // 開/關閃光(false=兩張圖硬切)
 *   gift.destroy();
 *
 * 時間軸(speed=1,單位 ms)
 *   0     加 is-playing、is-shaking            抖動
 *   700   加 is-flashing                       閃光
 *   890   去 is-shaking;加 is-open is-pop is-burst   閃光最亮 → 換圖+爆開(呼叫 onOpen)
 *   2290  去 is-playing                        播完(呼叫 onDone)
 */
(function (global) {
  'use strict';

  var SHAKE = 700, FLASH_PEAK = 190, SETTLE = 1400;
  var RM = global.matchMedia ? global.matchMedia('(prefers-reduced-motion: reduce)') : { matches: false };

  // 固定種子的亂數:浮光點每次載入長得一樣,方便比對
  function rng(seed) { return function () { seed = seed * 16807 % 2147483647; return (seed - 1) / 2147483646; }; }
  function div(cls, css) { var d = document.createElement('div'); d.className = cls; if (css) d.style.cssText = css; return d; }

  function buildMotes(root) {
    var box = root.querySelector('.gfx__motes');
    if (!box || box.childElementCount) return;
    var r = rng(23), R = function (a, b) { return a + r() * (b - a); };
    for (var i = 0; i < 4; i++) {
      var t = R(4.2, 7), w = R(1.8, 3.2), o = R(1, 2.2);
      var rise = div('gfx__mote',
        '--x:' + R(-65, 65).toFixed(0) + 'px;--y0:' + R(15, 35).toFixed(0) + 'px;--y1:' + R(-145, -100).toFixed(0) + 'px;' +
        'animation:gfx-mote-rise ' + t.toFixed(2) + 's cubic-bezier(.3,.6,.4,1) ' + (-r() * t).toFixed(2) + 's infinite');
      var sway = div('gfx__mote-sway',
        '--a:' + R(6, 14).toFixed(0) + 'px;animation:gfx-mote-sway ' + w.toFixed(2) + 's ease-in-out ' + (-r() * w).toFixed(2) + 's infinite');
      var dot = div('gfx__mote-dot',
        '--d:' + R(6, 12).toFixed(1) + 'px;animation:gfx-mote-twinkle ' + o.toFixed(2) + 's ease-in-out ' + (-r() * o).toFixed(2) + 's infinite');
      sway.appendChild(dot); rise.appendChild(sway); box.appendChild(rise);
    }
  }

  // 金幣 12 枚、星星 8 顆:每次 reset 重新隨機,每一次開箱都不一樣
  // 金幣翻轉限制在 ±60°:幣面的錢幣符號有上下方向,翻太多會倒著顯示
  function spawnParticles(root) {
    [].slice.call(root.querySelectorAll('.gfx__coin,.gfx__spark')).forEach(function (n) { n.remove(); });
    var i, a, dist, c, s;
    for (i = 0; i < 12; i++) {
      a = (-90 + (i - 5.5) * 16 + (Math.random() * 10 - 5)) * Math.PI / 180; dist = 90 + Math.random() * 60;
      c = div('gfx__coin',
        '--x:' + (Math.cos(a) * dist).toFixed(1) + 'px;--y:' + (Math.sin(a) * dist).toFixed(1) + 'px;' +
        '--r:' + (Math.random() * 120 - 60).toFixed(0) + 'deg;--s:' + (.7 + Math.random() * .6).toFixed(2) + ';' +
        '--d:' + (.9 + Math.random() * .4).toFixed(2) + 's;--delay:' + (Math.random() * .15).toFixed(2) + 's');
      root.appendChild(c);
    }
    for (i = 0; i < 8; i++) {
      a = Math.random() * Math.PI * 2; dist = 60 + Math.random() * 80;
      s = div('gfx__spark',
        '--x:' + (Math.cos(a) * dist).toFixed(1) + 'px;--y:' + (Math.sin(a) * dist).toFixed(1) + 'px;' +
        '--delay:' + (.1 + Math.random() * .5).toFixed(2) + 's');
      root.appendChild(s);
    }
  }

  function GiftOpen(root, opts) {
    opts = opts || {};
    var speed = opts.speed || 1, flash = opts.flash !== false, running = null;
    var offscreen = false, hidden = document.hidden, timers = [];
    var imgs = [].slice.call(root.querySelectorAll('.gfx__img'));
    // 先解碼兩張圖,換圖那一刻才不會閃空白
    var ready = Promise.all(imgs.map(function (i) { return i.decode ? i.decode().catch(function () {}) : Promise.resolve(); }));

    function applyPause() { root.classList.toggle('is-paused', offscreen || hidden); }
    var io = global.IntersectionObserver
      ? new IntersectionObserver(function (es) { offscreen = !es[es.length - 1].isIntersecting; applyPause(); }, { threshold: 0.1 })
      : null;
    if (io) io.observe(root);
    function onVis() { hidden = document.hidden; applyPause(); }
    document.addEventListener('visibilitychange', onVis);

    function wait(ms) {
      return new Promise(function (res) { timers.push(setTimeout(res, ms * speed)); });
    }
    function setSpeed(n) {
      speed = n;
      (root.parentElement || document.documentElement).style.setProperty('--gfx-speed', n);
    }
    function setFlash(v) { flash = !!v; }
    function reset() {
      timers.forEach(clearTimeout); timers = []; running = null;
      root.classList.remove('is-playing', 'is-shaking', 'is-flashing', 'is-open', 'is-pop', 'is-burst');
      spawnParticles(root);
    }
    function play() {
      if (running) return running;
      running = ready.then(function () {
        root.classList.add('is-playing');
        var skip = RM.matches;                                   // 系統要求減少動態:不抖、不閃,直接開
        return (skip ? Promise.resolve() : (root.classList.add('is-shaking'), wait(SHAKE)))
          .then(function () { if (flash && !skip) { root.classList.add('is-flashing'); return wait(FLASH_PEAK); } })
          .then(function () {
            root.classList.remove('is-shaking');
            root.classList.add('is-open', 'is-pop', 'is-burst');
            if (opts.onOpen) opts.onOpen();
            return wait(skip ? 300 : SETTLE);
          })
          .then(function () { root.classList.remove('is-playing'); if (opts.onDone) opts.onDone(); });
      });
      return running;
    }
    function destroy() {
      reset(); if (io) io.disconnect(); document.removeEventListener('visibilitychange', onVis);
    }

    buildMotes(root); spawnParticles(root);
    if (speed !== 1) setSpeed(speed);
    return { play: play, reset: reset, setSpeed: setSpeed, setFlash: setFlash, destroy: destroy };
  }

  global.GiftOpen = GiftOpen;
})(window);

12驗收清單

整合完請逐項確認。