简介:这是一套基于HTML5的“开红包”互动特效示例,目标读者是需要在社交、电商或营销页面快速植入红包玩法的前端开发者,可解决从交互设计到随机金额展示的完整实现问题。压缩包大小仅130KB,共包含9个文件:index.html入口页,jquery-1.10.1.min.js与base.css负责交互和样式,另有5张png、1张jpg素材图对应红包开启前后、背景与装饰等不同状态。代码采用CSS3动画完成开启过渡,以jQuery监听点击并操作DOM刷新金额与祝福语,通过Math.random()产生每次不同的开奖结果,模块结构清晰,便于直接替换图片和扩展样式,在不同项目中快速复用。这一示例同时涵盖响应式与兼容性处理思路,适合中初级前端开发者研究动效实现与组件封装。目前已有1077人学习下载,小巧实用,可作为同类互动功能的起步模板。
1. 先说结论:开红包特效到底是做什么的,值不值得为它投入
“h5开红包特效”不是简单弹一个“恭喜您中奖”的窗口,而是一套完整的动效叙事:把用户一次点击拆成“悬念—撕开—结果确认”三段节奏。做过活动页的开发者都知道,同样是发奖励,直接跳金额的页面和先拆红包再展示金额的页面,转化率往往差出一截。这个特效适合做节日营销、裂变分享页、会员活动页的开发者——你不需要引入重量级动画库,用 DOM 结构加 Canvas 加几百行 CSS/JS 就能搭出一套可复用、可调参、能应付低端安卓机的开红包方案。本文就按这条链路讲下来:先立住红包结构与拆开手感,再做金光粒子与金额弹出,最后处理状态机、音频、振动以及真机兼容。
2. 红包结构与拆开动画:先把“撕”的手感做出来
开红包的第一观感不是粒子,而是那个盖子怎么“弹开”。盖子弹得好,用户会觉得有阻尼、有韧性;弹得生硬,整个特效就像贴了张会变的图片。所以第一步先不要碰粒子,把红包的 DOM 结构和拆开动画参数定下来。
2.1 三层结构的职责与层级
我把一个红包拆成三层:最底下的结果层、中间的金光粒子层、最上边的封面层。封面层是用户先看到的“红包正面”,拆开时它向上翻起;粒子层是一块透明的 canvas,丢在封面和结果之间,接收拆开瞬间的粒子爆发;结果层放金额数字和“开心收下”按钮,默认透明,只有状态进入 opened 才显示。
这个分层的核心逻辑是:不要用一张整图去做整个红包。整图做起来快,但后续要替换金额、调整按钮位置、做不同运营主题的皮肤,都得重新出图。分开之后,视觉元素各自可替换,粒子层和结果层的显示时机还能独立控制。HTML 结构大致是:
<div class="redpacket">.redpacket { position: relative; width: 320px; height: 420px; perspective: 900px; } .rp-cover { position: absolute; inset: 0; transform-origin: 50% 100%; background: linear-gradient(160deg, #ff5e5e, #c81e1e); border-radius: 14px; transition: transform 420ms cubic-bezier(.22, 1.61, .36, 1), opacity 160ms ease-out; will-change: transform; } .redpacket[data-state="opening"] .rp-cover, .redpacket[data-state="opened"] .rp-cover { transform: rotateX(-160deg) translateY(-80px); opacity: 0.2; }这里cubic-bezier(.22, 1.61, .36, 1)是整篇动效的核心约束。前段曲线把时间用于“抵抗”,后半段快速弹起,视觉上就是盖子一开始有点沉,最后猛地翻开。translateY(-80px)控制盖子翻开后飞多远,数值越大,翻起越夸张。opacity: 0.2是为了让盖子翻起后不完全遮挡下面的金额,又能露出一点颜色残留,增加层次感。
我再强调一下 perspective 的作用:没有它,rotateX 只是平面压扁,看不出“翻页”效果。父容器加perspective: 900px之后,盖子边缘会出现明显的透视变化,这是很多初版红包特效看起来像“被拍扁”的常见原因。
2.3 用>const redpacket = document.querySelector('.redpacket'); const cover = redpacket.querySelector('.rp-cover'); redpacket.addEventListener('pointerdown', () => { if (redpacket.dataset.state !== 'ready') return; redpacket.dataset.state = 'opening'; }); cover.addEventListener('transitionend', () => { if (redpacket.dataset.state === 'opening') { redpacket.dataset.state = 'opened'; } });
为什么用pointerdown而不是click?移动端 click 有一定延迟,活动页用户习惯连续快点,延迟会显得整个红包“迟钝”。pointerdown在手指按下时立刻触发,配合状态锁,不会造成重复动画。transitionend需要监听封面元素而不是整个红包容器,否则其他元素的过渡也会触发这个事件。这段逻辑把“点击—拆开—展示结果”串成了一个闭环,后面加粒子、声音、振动时只需要在状态切换点插入对应函数即可。
3. 金光粒子与金额弹出:Canvas 让拆开瞬间亮起来
盖子翻开的瞬间如果没有金光粒子,视觉上会缺一口气。粒子系统这里我固定用 Canvas,不用 DOM 做几十个发光元素——DOM 节点多了低端机器会掉帧,Canvas 在 60~100 个粒子的规模下表现稳定得多。这一章讲粒子怎么发射、怎么画得好看、以及金额弹出怎么和粒子配合。
3.1 粒子数据模型:速度、重力与衰减
粒子的本质是一堆带着位置、速度、寿命的小点。每个粒子生成时从红包中心向上方扇形区域发射,之后受重力影响减速、下落,同时寿命按指数衰减,最后消失。这个模型写起来不长,但参数很敏感:速度太快像放鞭炮,太慢像洒水;重力太大粒子很快就砸下来,太小则飘在空中不落地。
我常用的粒子结构是这样:
function createBurst(originX, originY, count = 70) { const particles = []; for (let i = 0; i < count; i++) { // 以正上方为中心,左右各约 50 度,保证粒子往上方散开 const angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.8; const speed = 260 + Math.random() * 360; particles.push({ x: originX, y: originY, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 1, decay: 0.92 + Math.random() * 0.08, size: 2 + Math.random() * 3, }); } return particles; } function updateParticle(p, dt) { p.vy += 520 * dt; // 重力,让粒子先上升再下落 p.x += p.vx * dt; p.y += p.vy * dt; p.vx *= 0.98; // 水平方向轻微阻尼 p.life *= p.decay; }count我默认控制在 70 上下,超过 120 在入门机上会有明显掉帧。speed = 260 + Math.random() * 360保证粒子速度存在差异,视觉上才有“一团光炸开”的感觉而不是整齐划一的圆。重力 520 是试出来的值,能让粒子飞到最高点后自然回落。decay控制每一帧生命值乘多少,0.92 表示粒子约 1 秒内淡出;这个值越大,粒子停留越久,金光拖尾越长,但太久会显得腻。
3.2 用离屏 Canvas 预渲染金光贴图
很多初版效果里,每个粒子都在主画布的绘制循环里createRadialGradient创建一个渐变色圆形。这样每帧要创建几十个渐变对象,GC 压力大,而且 Canvas 渐变创建本身比 drawImage 贵得多。我的做法是先用一个离屏 canvas 画好一张泛光圆点贴图,主循环里只做drawImage。
const sprite = document.createElement('canvas'); sprite.width = 64; sprite.height = 64; const sctx = sprite.getContext('2d'); const gradient = sctx.createRadialGradient(32, 32, 0, 32, 32, 32); gradient.addColorStop(0, 'rgba(255, 244, 200, 1)'); gradient.addColorStop(0.4, 'rgba(255, 214, 100, .9)'); gradient.addColorStop(1, 'rgba(255, 190, 40, 0)'); sctx.fillStyle = gradient; sctx.fillRect(0, 0, 64, 64);这张 64x64 贴图中间亮、边缘透明,画出来就是一颗柔和的金色光点。主机循环里绘制时只需要drawImage(sprite, x, y, size, size)。需要注意 principle:这个贴图的边缘渐变要拉得足够宽,不然粒子缩放变小时会看到明显的圆形边界,金色就变成了“金片”而不是“金光”。
真正绘制粒子的循环要同时处理画布尺寸和设备像素比。我见过很多红包特效在 Retina 屏上发虚,就是没按 devicePixelRatio 重置画布尺寸。通用写法是:
const canvas = document.querySelector('.rp-burst'); const ctx = canvas.getContext('2d'); const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = canvas.clientWidth; const h = canvas.clientHeight; canvas.width = w * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); let particles = []; let last = performance.now(); function frame(now) { const dt = Math.min((now - last) / 1000, 1 / 30); last = now; ctx.clearRect(0, 0, w, h); particles = particles.filter(p => p.life > 0.01); for (const p of particles) { updateParticle(p, dt); ctx.globalAlpha = p.life; ctx.drawImage(sprite, p.x - p.size / 2, p.y - p.size / 2, p.size, p.size); } if (particles.length > 0) { requestAnimationFrame(frame); } } function burst(x, y) { particles = createBurst(x, y, 70); requestAnimationFrame(frame); }代码里dt被限制在 1/30 秒以内,是防止页面切后台很久再回来时,一次性跳过大段时间,粒子瞬间飞离屏幕。dpr封顶 2 是因为 3 倍像素比的手机上,2 倍物理像素已经够清晰,继续拉高只会放大 CPU 和内存开销。globalAlpha直接等于粒子的生命值,让粒子在衰减过程中逐渐透明,避免“硬消失”的锯齿感。
3.3 金额弹出动效与图层合成
粒子是背景氛围,真正让用户记住红包的是那个金额跳出来的动作。金额我用 CSS 过渡实现,不放进 Canvas,因为金额需要支持 HTML 富文本样式,比如“¥”小字、数字大号、颜色渐变,Canvas 里写文字排版很痛苦。
金额的默认状态是缩小并往下位移,状态切到 opened 后回弹到正常大小。回弹曲线和盖子拆开用同一组cubic-bezier(.22, 1.61, .36, 1),这样两段动效能感觉到同一种“弹性”,整体手感统一。
.rp-amount { display: block; font-size: 72px; font-weight: 700; color: #fff; transform: translateY(40px) scale(.3); opacity: 0; transition: transform 360ms cubic-bezier(.22, 1.61, .36, 1), opacity 200ms ease-out; } .redpacket[data-state="opened"] .rp-amount { transform: translateY(0) scale(1); opacity: 1; }这里translateY(40px) scale(.3)是金额的“待机位”,藏在红包底部偏下,opened 之后向上归位。动画时长 360ms 比粒子爆发稍长一点,让用户先看到金光炸开,再聚焦到数字上。如果你希望数字带有“闪烁一下”的效果,可以在同一状态里额外加一个filter: brightness(1.2)的过渡,但注意在低端机上 filter 动画会触发重绘,能不加就不加。
图层合层方面,粒子 canvas 不要设置position: absolute以外的合成属性,让它默认和红包分层合成。如果发现粒子在部分安卓机上有残影,可以在 canvas 样式上补一句transform: translateZ(0)把它提升为独立合成层。
4. 完整交互流程:状态机、触感与音频的串联
开红包不是只做视觉就够了。用户点按、拆开、听声音、感觉振动、最后点击“开心收下”再拆下一个,这是一条完整的交互链。任何一个环节“没反应”,都会让用户觉得活动页面坏了。这一章把状态锁、振动、音频、引导全串起来。
4.1 状态机与连点保护
前面用>const Phase = { READY: 'ready', OPENING: 'opening', OPENED: 'opened', }; let phase = Phase.READY; const box = document.querySelector('.redpacket'); const cover = box.querySelector('.rp-cover'); const closeBtn = box.querySelector('.rp-close'); box.addEventListener('pointerdown', onPointerDown); closeBtn.addEventListener('pointerdown', onClose); function onPointerDown(e) { if (phase !== Phase.READY) return; phase = Phase.OPENING; box.dataset.state = Phase.OPENING; vibrate(30); playSound('open'); burst(box.clientWidth / 2, box.clientHeight / 2); } function onClose() { if (phase !== Phase.OPENED) return; phase = Phase.READY; box.dataset.state = Phase.READY; } cover.addEventListener('transitionend', () => { if (phase === Phase.OPENING) { phase = Phase.OPENED; box.dataset.state = Phase.OPENED; } });
Phase是一个纯 JS 状态,和 DOM 的>function vibrate(pattern = 30) { if (navigator.vibrate) { navigator.vibrate(pattern); } } const audioMap = {}; function playSound(name) { try { const audio = audioMap[name] || (audioMap[name] = new Audio(`${name}.mp3`)); audio.currentTime = 0; audio.play().catch(() => {}); } catch (e) { // 音频文件缺失或格式不支持时静默 } }
audioMap做了缓存,同一个音频文件只创建一次 Audio 实例,避免重复 new Audio 造成内存增长。currentTime = 0是为了支持连续多次拆红包时,每次都能从头播放。安卓 WebView 对音频格式支持不一,我建议统一用 MP3(低码率 64kbps 左右就够),不要用需要额外解码器的格式,否则部分机型直接不出声。
4.3 引导、关闭与“再拆一次”的闭环
第一次进入页面的用户,如果满屏只有一个红包,他可能不知道要点击。我通常会在红包下方放一个“点我拆开”的引导提示,带上下浮动的 CSS 动画。这个提示只在本次会话前几次显示,用户拆过三次后就不再展示。实现方式是读sessionStorage里记录的拆开次数,超过 3 则隐藏引导层。
关闭按钮点击后的行为有两种:直接关闭并返回 ready 状态,或者播放一个缩小消失动画后再复位。我建议用后者,因为用户拆完后会看到金额,如果瞬间弹回 ready,视觉上会“闪断”。给整个红包容器加一个关闭态过渡,300ms 内缩到 0.9 透明度降到 0,结束后再重置>.redpacket[data-state="closing"] { transform: scale(.9); opacity: 0; transition: transform 240ms ease-in, opacity 240ms ease-in; } .redpacket[data-state="ready"] { transform: scale(1); opacity: 1; }
重置后还需要把粒子 canvas 清空,避免上一次残留的金光在下次打开时闪烁一下。清空逻辑放在关闭按钮的transitionend里:
function resetPacket() { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); phase = Phase.READY; box.dataset.state = Phase.READY; }到这一步,拆红包的完整闭环就是:点红包 → 振动加声效 → 盖子弹开 → 粒子爆发 → 金额弹出 → 点击收下 → 缩小消失 → 复位。交互过程中的状态推进全靠状态机锁住,不会出现连点错乱。
5. 兼容性避坑:低端机、iOS 浏览器与动效时序的四个翻车现场
在模拟器上跑通不算跑通,真机上拆开的一瞬间才是验收开始。这一章我把这些年踩过的、以及兄弟们反馈过的典型问题按“现象→原因→解决”写清楚,每一条都对应一个具体的排查方向。
5.1 低端安卓一开粒子就掉到 20 帧
现象:入门安卓机(2GB 内存、720p 屏幕)拆红包瞬间,粒子一爆,整个页面像幻灯片,连背景滚动都卡顿。桌面端 Chrome 完全正常。
原因:粒子系统每帧创建 CanvasGradient,并且粒子数没有按设备性能限制;画布尺寸没有按设备像素比处理,导致 GPU 实际填充的像素面积远大于视觉面积。
解决:把粒子贴图预渲染到离屏 canvas,主循环只drawImage;粒子总数控制在 60~80;devicePixelRatio封顶 2;帧间隔 dt 限制在 1/30 秒内。排查时先看 Chrome DevTools 的 Performance 面板,如果帧时间稳定在 16ms 以下说明优化到位。
5.2 iOS Safari 里红包上下抖还带滚动穿透
现象:红包容器用了height: 100vh,iPhone 上地址栏收起/展开时,红包整体上下跳动;手指从红包上划动时,背后的页面跟着滚动。
原因:100vh在 iOS Safari 上等于视口最大高度,而不是当前可见高度,地址栏一变化就会重排;同时 html/body 没有锁定滚动,红包容器无法阻止手指滑动穿透。
解决:进入页面后用 JS 读window.innerHeight赋值给红包容器高度,不要用 vh;拆红包期间给document.body加overflow: hidden,关闭后再移除。代码示例:
function lockScroll(lock) { document.body.style.overflow = lock ? 'hidden' : ''; } const container = document.querySelector('.redpacket'); container.style.height = `${window.innerHeight}px`;这一步放在页面 load 和窗口 resize 时都执行一遍,保证横竖屏切换后高度仍然正确。
5.3 封面弹开瞬间白闪和毛边
现象:盖子翻到一半时,边缘出现一条白边/毛边,粒子与红包交界处看起来像被“咬了一口”。多发生在高分辨率屏幕。
原因:transform 旋转时,半透明边缘被 GPU 做了采样插值,产生了锯齿;粒子 canvas 未按 dpr 缩放时,贴图会被拉伸变糊,视觉上也会被归结为“毛边”。
解决:封面加will-change: transform,让它单独成为一个合成层;盖子使用backface-visibility: hidden消除背面临时采样;粒子画布严格按dpr * clientWidth设置宽度并ctx.scale(dpr, dpr)。如果白闪仍然存在,可以把封面的opacity过渡和 transform 分开到两个元素上,避免透明度和形变同时触发重绘。
5.4 安卓 WebView 振动失灵、iOS 音频第一次播放不响
现象:同一套代码,iOS 上点击第一次红包没有声音,第二次才有;部分安卓 WebView 上报振动无效,检查代码却没发现异常。
原因:iOS 的 WebAudio 和 HTMLAudioElement 都被自动播放策略限制,未解锁前禁止发声,第一次点击实际上是“解锁”过程;Android 某些定制系统在 WebView 里把navigator.vibrate默认禁用。
解决:音频播放必须放在pointerdown回调的同步代码里执行,不能异步延迟。需要预加载解锁时,在用户第一次触摸的pointerdown里先调用audio.play()再立刻pause(),后续播放就正常。振动则永远做能力检测:
if (typeof navigator.vibrate === 'function') { navigator.vibrate(30); }真机验收时不要只看一款手机,至少准备一台安卓入门机、一台主流安卓、一台 iOS 旧款,分别测试声音和振动表现。
5.5 连点导致粒子叠加与金额错乱
现象:用户手速稍快,红包刚拆到一半又点了一下,粒子系统二次触发,两张金光叠在一起,金额还没显示完就跳到下一个状态。
原因:点击事件没有状态锁,动画推进依赖“点击-响应-点击-响应”的因果链,一旦中间动画未结束,状态就乱了。
解决:用 4.1 的状态机锁住所有入口,ready 之外的状态一律忽略点击;关闭按钮同理,只有 opened 状态才响应。状态推进用transitionend+ 最长超时兜底:
let timeoutId; function enterOpening() { phase = Phase.OPENING; box.dataset.state = Phase.OPENING; timeoutId = setTimeout(() => { if (phase === Phase.OPENING) enterOpened(); }, 800); } function enterOpened() { clearTimeout(timeoutId); phase = Phase.OPENED; box.dataset.state = Phase.OPENED; }超时兜底的意义在于:页面切后台导致 transition 不触发时,状态也能被强制推进,不会永远停在半开状态。
6. 进阶技巧:用统一时钟驱动动效,并做真机帧率验收
动画写多了你会发现最隐蔽的问题不是卡顿,而是“不同步”。CSS 过渡用自己的时间基准,Canvas 粒子用performance.now(),两者对齐不好时,盖子已经弹完、粒子才开始炸,观感上像两个素材硬拼在一起。我现在的做法是给所有动效一个统一的时间锚点:CSS 动效全部用 transition/animation 的时长定义,JS 侧记录transitionend实际触发时间,粒子爆发以这个时间点为起点,而不是点击瞬间。这样即使某台机器渲染慢,粒子也能追着盖子的实际运动走。
另一个值得养成习惯的动作,是把所有视觉参数抽成一个配置对象。粒子数量、速度范围、重力、衰减、盖子翻转角度、金额弹出时长,这些值设计师/运营经常改,写死在代码里每次都要翻源码。配置对象形如:
const REDPACKET_CONFIG = { particleCount: 70, speedRange: [260, 620], gravity: 520, decay: 0.92, coverAngle: -160, closeDuration: 240, };运营想调“更闪一点”,改particleCount和decay就行;想调“盖子弹更高”,改coverAngle。改完看一眼真机效果,不用重新编译。
真机验收我按下面的清单做回归,这比模拟器靠谱得多:
| 设备档位 | 关注点 | 验收项 |
|---|---|---|
| 入门安卓(720p/2GB) | 帧率、粒子卡顿 | 拆红包帧时间稳定,无明显幻灯感 |
| 主流安卓(1080p/8GB) | 动画流畅度、声音 | 连续拆 5 次无声音异常 |
| iOS 旧款 | 滚动穿透、vh 抖动 | 地址栏伸缩不跳,背景不滚动 |
| iOS 新款 | 合层闪白、音频解锁 | 首次点击出声,封面无白边 |
验收时打开 Chrome DevTools 的 Performance 面板,勾选 FPS 和 Rendering,拆红包时帧时间超过 33ms 就需要查渲染瓶颈。还想再进一步的话,可以给粒子加一个“首帧密集、尾帧稀疏”的分布策略:前 30 个粒子大而亮,后 40 个粒子小而淡,金光炸开的一瞬间冲击力更强,又不会让低端机同时画 70 个亮点。
我第一次做这套特效时,在真机上看着金光一边闪一边掉帧,当时觉得是粒子数太多,结果发现是 dpr 没处理、渐变每帧重建,改完这两处直接稳到 60 帧。后来每次接活动页动效,我都会先问一句:目标用户最差那台机器是什么?答案不同,粒子数和动画曲线就该不同。这套开红包的架子,希望帮到你。
本文还有配套的精品资源,点击获取