ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

原生JavaScript实现HTML5跑马灯抽奖页面:布局、动效与移动端适配

2026/9/9 2:57:26 拓冰建站 浏览量
原生JavaScript实现HTML5跑马灯抽奖页面:布局、动效与移动端适配 简介HTML5跑马灯抽奖页面是一份面向前端初学者与活动运营开发者的可直接运行案例展现了如何用HTML5、CSS3与JavaScript实现电商活动中常见的滚动抽奖效果。压缩包共19个文件包含一个HTML页面、一个CSS样式表、一个JS脚本及15张PNG/JPG图片素材整体仅167KB其中JS负责随机选奖与跑马灯定时器CSS利用transform和keyframes实现平滑动画与响应式布局图片则用于界面视觉装饰。已有758人学习/下载。通过此案例可掌握HTML5语义化标签、CSS3动画特性和JavaScript随机逻辑的配合方法目录中index.html、js、css、img划分清晰适合作为营销活动页开发参考或前端教学中的完整示例。 做活动页的同学应该都遇到过这种需求一个 html5 跑马灯抽奖页面奖品在灯带里一圈一圈循环用户点一下开始速度先快后慢最后自然停在某个奖品上。这需求看着简单但真上手写起来从布局、跑动节奏到停止定位每个环节都有不少坑。这篇文章我就按实际做一个抽奖页面的完整流程来写先讲原理和方案选型再搭页面结构然后写核心 JS 逻辑最后补上移动端适配和常见问题。适合刚接触前端、需要完成网页设计作业的同学也适合做活动落地页但不想每次从零开始的前端开发者。1. 跑马灯抽奖的原理与方案选型1.1 跑马灯效果的本质跑马灯这个名字很形象最早来自商场门口的 LED 灯带一圈灯泡按顺序亮灭因为人眼有视觉暂留看上去就像光点在流动。网页里的实现思路一模一样准备一组奖品格子用一个 JavaScript 定时器每隔几十毫秒把“当前高亮”的格子往后移一个下标。比如你有 8 个奖品下标从 0 到 7高亮当前是 0下一秒高亮 1再下一秒高亮 2到了 7 之后回到 0用取模运算(currentIndex 1) % 8就能无限循环。用户看到的“灯带跑动”本质上就是高亮样式的快速切换。明白了这一点后面所有代码都是围绕“让高亮下标跑起来”展开的。所谓抽奖结果也不过是让这个下标最终停在某个目标位置上而已。1.2 为什么用原生 HTML5 三件套我在实际项目里见过各种实现方式有人用 Vue 写有人用 React 写还有人直接引一个抽奖插件。说实话如果只是做一个跑马灯抽奖页面我强烈建议用原生 HTML CSS JavaScript理由很直接第一零依赖。不装 npm 包、不引入框架一个index.html文件双击就能运行。交作业、给客户演示、临时活动页都方便。第二状态简单。跑马灯的核心状态就两个当前高亮下标、是否正在抽奖。这种简单逻辑用 React/Vue 的状态管理反而多了一层心智负担。第三可控性强。插件确实开箱即用但样式往往不好改减速曲线、停止定位这些关键参数也很难自定义。自己写的话行高亮节奏全部捏在自己手里。我不是说框架不行如果是大型营销系统里嵌套抽奖模块用框架没问题。但就“跑马灯抽奖页面”这个独立场景来说原生三件套是性价比最高的选择。1.3 整体方案拆解动手之前先把链路理清楚一个完整的跑马灯抽奖页面大致包含下面几块布局层用 CSS Grid 排出一个 3x3 宫格周围 8 个格子放奖品中间放按钮。动效层高亮格子加光晕、缩放等效果跑动过程才会有“灯带感”。控制层定时器驱动高亮下标循环移动通过递增每次触发间隔实现减速。数据层奖品数组 权重数组决定每个奖品大概被抽中的比例。安全层防止重复点击、防止高并发场景下结果被前端篡改。下面从页面骨架开始一层层把这些组装起来。2. 页面骨架与样式细节先搭一个像样的抽奖台2.1 HTML 结构和奖品数据模型我习惯让数据驱动页面奖品列表用 JS 数组维护再动态渲染成格子。这样后面想改成 10 格、12 格甚至环状布局只需要换数据源和 CSS 布局逻辑层完全不用动。先看 HTML 骨架div classlottery-wrap div classprize-grid idprizeGrid !-- 格子由 JS 动态生成 -- div classbtn-start idstartBtn立即抽奖/div /div div classresult-tip idresultTip/div /div对应奖品数据模型const prizesData [ { name: 100元红包, weight: 5 }, { name: 10积分, weight: 15 }, { name: 谢谢参与, weight: 30 }, { name: 5元话费, weight: 10 }, { name: 优惠券, weight: 20 }, { name: 笔记本, weight: 3 }, { name: 再来一次, weight: 12 }, { name: 耳机, weight: 5 } ];这里的weight是权重数值越大越容易被抽中。后面写权重随机算法时会用到。动态生成格子的代码很简单const grid document.getElementById(prizeGrid); prizesData.forEach((item, index) { const div document.createElement(div); div.className prize-item; div.dataset.index index; div.textContent item.name; grid.appendChild(div); });注意按钮是独立放在 HTML 里的没有混进奖品节点。这是个很容易踩的坑如果你用document.querySelectorAll(.prize-item)选中奖品结果里面混了一个按钮奖品数量变成 9停止位置怎么算都对不上。2.2 用 CSS Grid 排出回形布局8 个奖品加中间按钮正好排成一个 3x3 宫格视觉顺序是顺时针回形左上角出发经过上中、右上、右中、右下、下中、左下、左中最后回到左上。用grid-template-areas写最直观.prize-grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-areas: p0 p1 p2 p7 btn p3 p6 p5 p4; gap: 10px; max-width: 480px; margin: 0 auto; } .prize-item[data-index0] { grid-area: p0; } .prize-item[data-index1] { grid-area: p1; } .prize-item[data-index2] { grid-area: p2; } .prize-item[data-index3] { grid-area: p3; } .prize-item[data-index4] { grid-area: p4; } .prize-item[data-index5] { grid-area: p5; } .prize-item[data-index6] { grid-area: p6; } .prize-item[data-index7] { grid-area: p7; } .btn-start { grid-area: btn; }这样排列出来的顺序和跑马灯的移动顺序完全一致索引 0 到 7 正好是按顺时针走的。无论从视觉还是从代码逻辑上都很好理解。2.3 高亮效果让格子有“灯带感”跑马灯之所以像灯带靠的就是高亮格子的光晕。同一个格子平时低调高亮时加亮色边框、金黄色渐变背景、外发光形成强烈的视觉对比。.prize-item { display: flex; align-items: center; justify-content: center; min-height: 100px; background: linear-gradient(145deg, #fdf6ec, #fce3c8); border: 2px solid #f5c98b; border-radius: 12px; font-size: 16px; color: #8a5a23; transition: background 0.15s ease, box-shadow 0.15s ease; } .prize-item.active { border-color: #ffd75e; background: linear-gradient(135deg, #fff4b8, #ffd75e); color: #6b3d00; box-shadow: 0 0 18px rgba(255, 208, 0, 0.85), inset 0 0 12px rgba(255, 255, 255, 0.6); transform: scale(1.05); }这里我给盒子加了transition但只过渡background和box-shadow不要对transform做频繁过渡。跑动过程中如果每一帧都对transform做插值重排低性能手机上会掉帧卡顿。实测下来直接切 class 让高亮格子跳到下一格观感反而是流畅的因为视觉暂留本身就帮我们“脑补”了过渡过程。中心按钮样式往烫金、立体方向做配合整体喜庆氛围.btn-start { border: none; border-radius: 50%; font-size: 20px; font-weight: bold; color: #fff; background: radial-gradient(circle at 30% 20%, #ffb84d, #f56b1d); box-shadow: 0 6px 0 #b34000, 0 8px 20px rgba(180, 60, 0, 0.3); cursor: pointer; } .btn-start:disabled { opacity: 0.6; cursor: not-allowed; }到这一步页面已经是一个静态的抽奖台了。接下来是重头戏让高亮跑起来并且能精准停下来。3. 抽奖逻辑实现快跑、慢停、精准命中3.1 核心引擎从加速到减速的定时器链跑马灯看起来跑得自然关键是速度曲线开始快中间匀速最后慢慢减速停下。这不能靠固定间隔的setInterval得用动态改变延迟时间的setTimeout递归。我的做法是提前算出总步数每一步执行完都把下一次的延迟时间按进度调一下前 60% 逐渐缩短延迟模拟加速后 40% 逐渐拉长延迟模拟减速。const prizes Array.from(document.querySelectorAll(.prize-item)); const startBtn document.getElementById(startBtn); const prizeCount prizes.length; let currentIndex 0; let isRolling false; let timer null; function render(index) { prizes.forEach((item, i) { item.classList.toggle(active, i index); }); } function getRandomTarget() { // 先用均匀随机后面换成权重随机 return Math.floor(Math.random() * prizeCount); } function startRoll() { if (isRolling) return; isRolling true; startBtn.disabled true; const targetIndex getRandomTarget(); const totalRounds 2 Math.floor(Math.random() * 2); // 至少转 2 圈最多 3 圈 const stepsToTarget (targetIndex - currentIndex prizeCount) % prizeCount; const totalSteps prizeCount * totalRounds stepsToTarget; let step 0; let delay 50; function tick() { step; currentIndex (currentIndex 1) % prizeCount; render(currentIndex); if (step totalSteps) { currentIndex targetIndex; render(currentIndex); finishRoll(); return; } const progress step / totalSteps; if (progress 0.6) { // 前半段延迟逐渐缩短模拟加速 delay Math.max(40, delay - 3); } else { // 后半段延迟逐渐拉长模拟减速并且越到后面越明显 const slowFactor (progress - 0.6) / 0.4; delay 40 Math.pow(slowFactor, 2) * 240; } timer setTimeout(tick, delay); } timer setTimeout(tick, delay); } function finishRoll() { isRolling false; startBtn.disabled false; const result prizesData[currentIndex].name; document.getElementById(resultTip).textContent 抽中 result; }这段代码有几个细节值得展开讲。首先是stepsToTarget的计算。假设当前在第 6 格目标在第 2 格8 个一圈不能直接2 - 6那样等于 -4不对。必须用(2 - 6 8) % 8 4意思是还需要走 4 格6 - 7 - 0 - 1 - 2。这个取模公式是所有回环类抽奖的通用基础务必记牢。其次是减速曲线。我用了二次方函数Math.pow(slowFactor, 2) * 240让延迟时间从 40ms 慢慢增长到接近 280ms。如果你用线性增长比如每步固定加 10ms最后几步会像卡顿一样一格一格“蹦”不自然。二次方曲线的好处是越接近终点单位时间内的位移越小符合物理世界物体减速滑行的直觉。最后是总步数。totalRounds控制在 2 到 3 圈之间加上到目标的精确步数这样用户不会觉得“转半天还没停”也不会觉得“刚开始就停了是不是内定”。3.2 权重随机让“谢谢参与”看起来稍微讲点道理业务里最讨厌的就是奖品概率完全平均。8 个奖品如果都是Math.random()那“谢谢参与”和“笔记本”概率一样运营根本没法接受。所以要引入权重随机算法。原理很简单想象一条长度为总权重的线段每个奖品占用一段长度然后随机撒一个点看落进哪段function getWeightedRandomIndex() { const totalWeight prizesData.reduce((sum, item) sum item.weight, 0); let rand Math.random() * totalWeight; for (let i 0; i prizesData.length; i) { rand - prizesData[i].weight; if (rand 0) return i; } return prizesData.length - 1; }把上一节getRandomTarget替换成这个抽奖结果就会按权重分布。这里我要强调一个安全边界如果是真实线上活动这个函数绝不能放在前端。用户打开控制台就能改权重甚至直接调startRoll把目标定到自己想要的位置。正确做法是页面加载时请求后端接口返回一个本次抽奖的targetIndex前端只负责动画展示。页面 demo、课程作业无所谓但涉及真实奖品时一定要记得这条底线。3.3 防重复点击一把简单的状态锁跑马灯运行过程中用户疯狂点按钮如果不做拦截定时器会越叠越多动画直接乱掉。我在startRoll开头用isRolling做了锁if (isRolling) return; isRolling true; window.__testState isRolling;启动时置true结束时置false。同时把按钮设为disabled视觉上也给出反馈。这个思路虽然简单但它是所有“有动画过程”的交互页面的通用套路。3.4 让结果由后端指定一版更接近生产的改造如果后端已经给了targetIndex那startRoll只需要接收参数即可async function onStartBtnClick() { if (isRolling) return; // 请求后端拿到本次结果 const res await fetch(/api/lottery, { method: POST }); const data await res.json(); const targetIndex data.prizeIndex; runRoll(targetIndex); } function runRoll(targetIndex) { isRolling true; startBtn.disabled true; const stepsToTarget (targetIndex - currentIndex prizeCount) % prizeCount; const totalSteps prizeCount * (2 Math.floor(Math.random() * 2)) stepsToTarget; let step 0; let delay 50; function tick() { step; currentIndex (currentIndex 1) % prizeCount; render(currentIndex); if (step totalSteps) { finishRoll(targetIndex); return; } const progress step / totalSteps; if (progress 0.6) { delay Math.max(40, delay - 3); } else { const slowFactor (progress - 0.6) / 0.4; delay 40 Math.pow(slowFactor, 2) * 240; } timer setTimeout(tick, delay); } timer setTimeout(tick, delay); }这个版本里“抽中什么”完全由后端决定前端只负责把动画演得好看。万一后端说中奖但玩家网络中断没拿到结果页面会停在中间状态这种极端情况建议再加超时兜底比如请求超过 3 秒就提示“网络开小差请重试”这也是生产环境的常规处理。4. 视觉与体验优化不让用户觉得是“作业稿”4.1 加一点音效和结果动效页面功能跑通只是第一步。用户感知一个抽奖页面是否“高级”很大程度来自反馈细节。我在实际做这类页面时至少会加三类反馈按钮按下的触感、跑动过程中的“滴滴”声、停下后的结果弹窗。音效用浏览器的 Web Audio API 最省事连音频文件都不用准备直接用振荡器生成短音function playTickSound() { const ctx new (window.AudioContext || window.webkitAudioContext)(); const osc ctx.createOscillator(); const gain ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value 600; gain.gain.setValueAtTime(0.08, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime 0.05); osc.start(ctx.currentTime); osc.stop(ctx.currentTime 0.05); }每走一格调用一次playTickSound()声音短促清脆配合灯光效果跑马灯的感觉就出来了。需要注意浏览器自动播放策略用户没有点击页面之前AudioContext是挂起状态。所以首次点击抽奖按钮时代码里要顺手ctx.resume()之后才能正常出声。停下后的结果弹窗尽量做得有仪式感半透明遮罩、奖品名放大显示、“再来一次”按钮。弹窗交互逻辑不算复杂但它是整个体验闭环的最后一环值得花时间打磨。4.2 移动端适配防止抖动和误触活动页肯定要上手机移动端有几个细节比 PC 更敏感。第一是在head里加 viewport 设置meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno /禁止用户缩放避免快速点击时触发双击放大。第二是点击事件。PC 上用click没问题但移动端click会有一两百毫秒的延迟感知。现代浏览器下可以用pointerup鼠标和触摸统一处理startBtn.addEventListener(click, onStartBtnClick); startBtn.addEventListener(pointerup, onStartBtnClick);这里别用touchstart替代click否则 PC 上用鼠标点击也会触发两次。用pointerup是最稳的。第三是给按钮加touch-action: manipulation.btn-start { touch-action: manipulation; -webkit-tap-highlight-color: transparent; }这行 CSS 能消掉移动端快速点击时的灰色高亮闪烁同时避免部分浏览器把快速点击识别成缩放。第四是安全区。如果你的页面要做成全屏 H5刘海屏和底部小黑条会挡住按钮建议在根容器加body { padding-bottom: env(safe-area-inset-bottom); }这套东西看着琐碎但正是它们决定了一个页面在真机上的“原生感”。4.3 用数据渲染页面方便扩展成 12 格环形前面我坚持数据驱动渲染就是为了让扩展更容易。如果活动要求改成 12 格环形你不需要重写逻辑只需要改两点一是把 CSS Grid 布局换成适合环形的定位比如用绝对定位配合transform: rotate(angle) translate(radius) rotate(-angle)把格子均匀分布成圆环中心放按钮。二是奖品数量变化后取模运算、总步数计算、权重数组全部自动适配。因为核心逻辑用的是prizeCount而不是写死的 8。从 8 格到 12 格跑马灯引擎本身不用动这是数据驱动方案最大的红利。5. 常见问题与调试技巧实录5.1 问题速查表我在知乎和一些技术群里被问过无数次类似的问题干脆整理成一张表遇到问题直接对着排查现象根本原因排查/解决方式停止位置和预设奖品对不上targetIndex - currentIndex可能为负数没有取模用(targetIndex - currentIndex prizeCount) % prizeCount统一换算步数最后几步像“卡顿”而不是减速延迟时间线性增长越到末尾越突兀使用二次方缓动函数delay base Math.pow(progress, 2) * maxDelay快速点击按钮后动画乱了定时器叠加没有状态锁使用isRolling锁运行时直接 return同时把按钮disabled手机点击有明显延迟/闪灰块设了click且没有禁止缩放改用pointerup加touch-action: manipulation切到后台再回来动画停了浏览器大幅降频后台标签页的定时器用visibilitychange监听页面恢复重新校正索引或定时器换成requestAnimationFrame时间戳方案有的奖品基本没出现过均匀随机没有按权重换成权重随机算法注意前端只适合 demo高亮切换看起来不流畅对transform/top/left做频繁过渡高亮切换只改background和box-shadow加上will-change可选的优化5.2 我踩过的几个坑和修复经验第一个坑把按钮选进了奖品列表。最早我在写选择器时用了grid.querySelectorAll(div)中间按钮也被算成一个奖品奖品数量变成 9最后停的位置总是偏一格。后来改成querySelectorAll(.prize-item)并保证按钮没有这个 class问题立刻消失。这类“数据源和视觉元素混在一起”的问题很隐蔽调试时建议在render里打印currentIndex和格子内容对照不要靠肉眼猜。第二个坑减速参数调了很久都不自然。最初我用每次延迟10ms的方式减速效果是一格一格“爬行”特别生硬。后来改成按进度比例计算并用二次方曲线放大末段延迟才终于有了滑行感。经验是减速曲线决定整个抽奖体验的高级感值得多花时间微调。第三个坑高德地图之类的 H5 页面里调试发现跑马灯在部分安卓机上特别卡。排查后发现是格子带的transition: all导致的每一格切换都要对多个属性做插值。只保留必要的过渡属性后流畅了很多。第四个坑线上活动版本因为前端自己决定结果被用户抓包刷出了高价值奖品。从那时候起凡是涉及真实库存的业务我都会把结果计算挪到后端。这不是技术问题是安全边界问题。结尾最后再说一点我的个人体会。类似跑马灯抽奖这种页面技术难点真不在“会不会写”而在于把每一次点击之后的体验做完整。我的习惯是先定好“至少转两圈”的总步数再去调减速曲线最后补音效和光效这样出来的页面基本不会给人一种“简陋原型”的感觉。如果你正准备做 html5 跑马灯抽奖页面建议先用 3x3 八格版跑通整套逻辑再往 12 格环形或其他风格扩展。底层的索引切换、步数对齐、状态锁思路完全通用。我踩过的最深一次坑是在省电模式笔记本上调试所有定时器明显变慢后来把关键业务逻辑里的定时器换成基于时间戳的校正方案才彻底解决。希望这篇把能用的套路都整理出来了能帮你少走几步弯路。本文还有配套的精品资源点击获取