
简介这是一套面向公司年会、团建活动等场景的HTML抽奖页面源码适合具备基础前端知识、希望快速搭建现场互动环节的开发者使用。它解决了传统抽奖工具样式单一、奖品与人员配置不灵活的问题支持动态配置员工信息、奖品内容与页面样式并保证每位员工只能中奖一次同时内置多种震撼音效可选直接运行index.html即可使用。资源包共20个文件约34.12MB包含html、css、js等核心页面与逻辑文件以及mp4背景视频、mp3音效、png与jpg图片素材和psd设计源文件另附readme说明方便二次修改与素材替换。目前已有6869人学习下载热度较高。通过这份源码读者可以拿到一套可直接运行的完整抽奖方案理解人员去重、奖品灵活配置与音效切换的实现思路并借助现成视觉素材快速调整出符合自身年会风格的抽奖页面。1. 年会抽奖页面的真实需求为什么现成模板总在关键时刻翻车公司年会抽奖这个场景做过一次的人都知道它有多“玄学”。平时测试怎么点都正常老板一上台、投影一开、三百号人盯着大屏要么转盘卡住要么中奖名单重复要么动画还没停就被人喊“内定了”。问题往往不在抽奖逻辑本身而在页面结构、动画时序和随机数种子这三件事上没处理好。用 HTML 加 CSS 加 JavaScript 实现一个酷炫的抽奖页面核心不是把界面做得多花哨而是让“抽奖”这个动作在视觉上可信、在逻辑上可复现、在压力下不崩。这篇文章面向的是需要在一两周内交付一个能上台面的年会抽奖页面的前端或全栈工程师也适合想拿一套源码改吧改吧就用的行政技术岗。我会把从页面骨架、转盘动画、名单管理到现场避坑的完整路径拆开讲源码结构可以直接抄参数可以按你的奖品数量改。2. 抽奖页面的骨架与视觉层从零搭一个能上大屏的 HTML 结构2.1 为什么不用 Canvas 一把梭而是 DOM 加 CSS 做转盘很多人一提到转盘抽奖第一反应是用 Canvas 画扇形、算弧度、逐帧重绘。这条路能走通但年会现场你大概率会遇到两个问题一是投影仪分辨率五花八门Canvas 缩放后文字模糊二是中奖名单要动态渲染到转盘扇区上Canvas 里改文字得整个重绘调试成本高。我一般会选 DOM 加 CSS 的方案每个奖项是一个绝对定位的扇形块用transform: rotate()控制整体旋转文字用普通 HTML 元素渲染清晰度由浏览器保证。转盘的“转动”本质上是给容器加一个 CSS transition 或 requestAnimationFrame 驱动的旋转角度停止时用transition-timing-function做缓出视觉上就是“越转越慢最后停下”。这个方案在 1080P 和 4K 投影上都稳改奖项数量只需要改 DOM 节点数不用碰绘图代码。2.2 最小可运行的 HTML 骨架与奖项数据结构先给一个能直接跑起来的最小结构。奖项数据用一个数组维护每个对象包含奖项名称和权重权重决定中奖概率。转盘扇区的角度根据奖项数量均分如果奖项数量不等宽也可以按权重分配扇区角度但年会场景我建议均分扇区、用权重控制概率视觉上更整齐。!DOCTYPE html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title公司年会抽奖/title style /* 转盘容器固定正方形方便计算旋转中心 */ .wheel-wrap { position: relative; width: 600px; height: 600px; margin: 40px auto; } /* 转盘本体所有扇区都挂在这里 */ .wheel { width: 100%; height: 100%; border-radius: 50%; position: relative; transition: transform 4s cubic-bezier(0.25, 0.1, 0.25, 1); /* 缓出曲线模拟真实转盘减速 */ } /* 每个扇区用 clip-path 切出扇形文字单独定位 */ .sector { position: absolute; width: 50%; height: 50%; left: 50%; top: 0; transform-origin: 0% 100%; clip-path: polygon(0 0, 100% 0, 0 100%); display: flex; align-items: flex-start; justify-content: center; padding-top: 20px; box-sizing: border-box; font-size: 18px; color: #fff; font-weight: bold; } /* 指针固定在顶部 */ .pointer { position: absolute; top: -20px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 20px solid transparent; border-right: 20px solid transparent; border-top: 40px solid #ff3b30; z-index: 10; } /* 抽奖按钮 */ .draw-btn { display: block; margin: 30px auto; padding: 16px 60px; font-size: 24px; background: #ff9500; color: #fff; border: none; border-radius: 40px; cursor: pointer; } .draw-btn:disabled { background: #ccc; cursor: not-allowed; } /style /head body div classwheel-wrap div classpointer/div div classwheel idwheel/div /div button classdraw-btn iddrawBtn开始抽奖/button script // 奖项配置name 是显示文字weight 是中奖权重 const prizes [ { name: 一等奖, weight: 1 }, { name: 二等奖, weight: 3 }, { name: 三等奖, weight: 6 }, { name: 幸运奖, weight: 10 }, { name: 参与奖, weight: 20 }, { name: 谢谢参与, weight: 60 } ]; const wheel document.getElementById(wheel); const drawBtn document.getElementById(drawBtn); const sectorCount prizes.length; const anglePerSector 360 / sectorCount; // 渲染扇区 prizes.forEach((prize, index) { const sector document.createElement(div); sector.className sector; // 每个扇区旋转对应角度背景色交替 sector.style.transform rotate(${index * anglePerSector}deg); sector.style.background index % 2 0 ? #ff6b6b : #feca57; sector.textContent prize.name; wheel.appendChild(sector); }); /script /body /html这段代码里.sector的transform-origin设在左下角配合clip-path切出三角形旋转后拼成圆形。anglePerSector是每个扇区占的角度index * anglePerSector决定每个扇区的起始方向。奖项数组里weight越大中奖概率越高后面抽奖逻辑会用到。注意transition写在.wheel上旋转时改transform就会自动触发动画。指针固定在顶部扇区旋转后指针指向哪个扇区视觉上就是抽中了哪个奖。2.3 用 CSS 变量控制主题色方便现场换皮年会现场经常遇到“老板临时说要换成公司主色调”的情况。如果颜色写死在 CSS 里改起来要翻好几处。我一般会把主色、辅色、文字色抽成 CSS 变量挂在:root上换主题只改变量值。:root { --wheel-color-1: #ff6b6b; --wheel-color-2: #feca57; --btn-bg: #ff9500; --text-color: #ffffff; } .sector:nth-child(odd) { background: var(--wheel-color-1); } .sector:nth-child(even) { background: var(--wheel-color-2); } .draw-btn { background: var(--btn-bg); color: var(--text-color); }这样现场只要在浏览器控制台执行document.documentElement.style.setProperty(--wheel-color-1, #你的颜色)就能立刻换色不用重新部署。参数说明--wheel-color-1和--wheel-color-2是扇区交替色建议选对比明显的两个颜色投影上远距离也能区分--btn-bg是按钮底色最好和扇区色系区分开避免按钮“隐身”。3. 抽奖逻辑与动画时序让“随机”看起来可信且可复现3.1 加权随机算法的实现与参数调优抽奖的核心是随机数。年会场景下纯Math.random()均分概率往往不符合预期因为奖项通常有等级差异。加权随机的做法是把所有奖项的权重累加得到一个总权重然后生成一个0到总权重之间的随机数看它落在哪个奖项的区间里。function weightedRandom(prizes) { // 计算总权重 const totalWeight prizes.reduce((sum, p) sum p.weight, 0); // 生成 [0, totalWeight) 之间的随机数 let random Math.random() * totalWeight; for (let i 0; i prizes.length; i) { if (random prizes[i].weight) { return i; // 返回中奖项索引 } random - prizes[i].weight; } return prizes.length - 1; // 兜底 }逻辑说明totalWeight是所有奖项权重之和random落在哪个区间就返回哪个索引。参数方面weight可以是任意正数不要求整数比例关系决定概率。比如一等奖权重 1、参与奖权重 20那么参与奖的中奖概率是一等奖的 20 倍。这里有个坑如果某个奖项权重设为 0它永远不会被抽中但扇区还在转盘上视觉上会让人以为“这个奖也能抽到”所以要么权重都大于 0要么把权重为 0 的奖项从转盘上移除。3.2 旋转角度计算怎么让指针精准停在目标扇区抽奖时转盘要旋转到目标扇区并且指针要指向该扇区。假设扇区从 0 度开始顺时针排列第i个扇区的中心角度是i * anglePerSector anglePerSector / 2。要让指针固定在顶部即 0 度方向指向这个扇区转盘需要旋转的角度是360 - 中心角度再加上若干整圈让动画更真实。function spinTo(index) { const anglePerSector 360 / prizes.length; // 目标扇区中心角度 const targetAngle index * anglePerSector anglePerSector / 2; // 转盘需要旋转的角度反向旋转到目标角度再加 5 圈 const baseRotation 360 - targetAngle; const extraTurns 5 * 360; // 当前已旋转角度避免每次从零开始 const currentRotation parseFloat(wheel.dataset.rotation || 0); // 计算下一次旋转角度保证总是向前转 const nextRotation currentRotation extraTurns (baseRotation - (currentRotation % 360)); wheel.style.transform rotate(${nextRotation}deg); wheel.dataset.rotation nextRotation; }参数说明extraTurns控制额外转的圈数5 圈大约 4 秒配合transition: transform 4s刚好。currentRotation从dataset里读避免每次抽奖都从 0 度开始导致视觉跳跃。baseRotation - (currentRotation % 360)是为了让最终停止角度始终对准目标扇区不管之前转了多少圈。这里有个细节如果baseRotation小于currentRotation % 360差值会是负数但加上extraTurns后整体还是正的转盘依然向前转。3.3 动画结束回调与防连点处理抽奖按钮在动画期间必须禁用否则用户连点会导致多次抽奖、角度错乱。用transitionend事件监听动画结束恢复按钮状态。drawBtn.addEventListener(click, () { drawBtn.disabled true; const index weightedRandom(prizes); spinTo(index); // 动画结束后恢复按钮并弹出中奖结果 wheel.addEventListener(transitionend, function handler() { wheel.removeEventListener(transitionend, handler); drawBtn.disabled false; alert(恭喜抽中${prizes[index].name}); }); });注意transitionend在transform变化时会触发但如果浏览器标签页在后台动画可能被节流回调延迟。年会现场一般不会切后台但保险起见可以加一个setTimeout兜底时间设为动画时长加 200 毫秒。另外alert在投影上体验不好建议改成页面内的弹层后面章节会讲。4. 名单管理与现场适配从硬编码到可配置的抽奖系统4.1 用 JSON 配置奖项和参与者名单硬编码奖项和名单在年会现场是灾难因为老板随时可能改奖品数量或加人。我一般会把配置抽成 JSON放在单独的文件或页面内的script标签里改配置不用动逻辑代码。const config { prizes: [ { name: 一等奖, weight: 1, count: 1 }, { name: 二等奖, weight: 3, count: 3 }, { name: 三等奖, weight: 6, count: 5 }, { name: 幸运奖, weight: 10, count: 10 } ], participants: [ 张三, 李四, 王五, 赵六 // 实际使用时从名单文件加载 ] };参数说明count表示该奖项的名额数量抽中后要递减减到 0 时该奖项不再参与抽奖。participants是参与者名单抽中的人要从名单里移除避免重复中奖。如果名单很长可以放在外部 JSON 文件里用fetch加载但年会现场网络可能不稳建议直接内嵌在 HTML 里。4.2 中奖名单去重与多轮抽奖的状态管理多轮抽奖时状态管理是关键。每抽中一个人要从participants里删掉同时对应奖项的count减一。如果某个奖项count为 0加权随机时要跳过它。function draw() { // 过滤掉已抽完的奖项 const availablePrizes config.prizes.filter(p p.count 0); if (availablePrizes.length 0) { alert(所有奖项已抽完); return; } const index weightedRandom(availablePrizes); const prize availablePrizes[index]; // 从参与者中随机抽一人 const personIndex Math.floor(Math.random() * config.participants.length); const winner config.participants.splice(personIndex, 1)[0]; prize.count--; // 更新界面显示 updateWinnerList(prize.name, winner); return { prize: prize.name, winner }; }逻辑说明availablePrizes过滤掉count为 0 的奖项保证不会抽出已发完的奖。splice从参与者数组里移除中奖者保证不重复。updateWinnerList是自定义函数把中奖信息渲染到页面侧边或底部。参数方面participants数组在抽奖过程中会变短如果人数少于奖项总数要提前检查并提示。4.3 大屏适配用 vw 和 rem 让页面在投影上不变形年会现场通常用投影仪分辨率可能是 1024x768 或 1920x1080甚至 4K。用固定像素写死尺寸换屏幕就变形。我一般用vw和rem结合转盘容器宽度设为min(80vw, 80vh)保证在任何屏幕上都是正方形且不超出视口。.wheel-wrap { width: min(80vw, 80vh); height: min(80vw, 80vh); margin: 0 auto; } .sector { font-size: clamp(14px, 2vw, 28px); }参数说明min(80vw, 80vh)取视口宽高中较小值的 80%保证转盘始终完整显示。clamp(14px, 2vw, 28px)让文字随屏幕缩放但有最小和最大限制避免小屏看不清、大屏文字过大。投影仪通常色彩偏暗扇区颜色要选高饱和度文字加text-shadow提升可读性。5. 避坑与现场排查抽奖页面最容易翻车的 5 个点5.1 现象转盘转完指针指偏了中奖结果和视觉不一致原因扇区起始角度和旋转角度计算时没有考虑指针位置和扇区排列方向。常见错误是把指针当成 0 度但扇区是从 12 点方向开始顺时针排列导致角度差 90 度或 180 度。解决在纸上画一个圆标出指针位置和扇区编号手动算一遍targetAngle和baseRotation的关系。或者用getBoundingClientRect在动画结束后检测指针指向的扇区做一次校准。5.2 现象连续抽奖时转盘越转越慢最后卡住原因每次抽奖都累加currentRotation但没有重置或取模角度值越来越大浏览器处理超大rotate值时性能下降。解决在spinTo里对nextRotation取模 360但取模后要保证旋转方向不变可以用nextRotation % 360 360的方式。或者每次抽奖前把transform重置为rotate(0deg)但会有视觉跳跃不推荐。5.3 现象中奖名单重复同一个人抽中两次原因抽中后没有从participants数组里移除或者移除的是错误索引。解决用splice移除时确认索引正确personIndex是Math.floor(Math.random() * participants.length)移除后数组长度减一。如果名单是从外部加载的注意异步加载完成前不要开始抽奖。5.4 现象投影上文字模糊扇区颜色看不清原因用了低分辨率图片做背景或者文字颜色和扇区颜色对比度不够。解决扇区用纯色或 CSS 渐变文字加text-shadow: 0 2px 4px rgba(0,0,0,0.5)颜色选白字配深色扇区、黑字配浅色扇区。投影仪对比度通常比显示器低颜色饱和度要调高。5.5 现象点击抽奖按钮没反应控制台报错原因transitionend事件没有触发或者wheel元素被其他元素遮挡。解决检查wheel的z-index和pointer-events确保按钮可点击。如果transitionend不触发加setTimeout兜底。另外如果奖项数组为空weightedRandom会返回-1导致spinTo报错抽奖前要检查数组长度。6. 进阶技巧用 localStorage 做抽奖状态持久化与防刷新年会现场最怕的是抽到一半有人刷新页面所有状态归零。我一般会用localStorage把中奖名单和奖项剩余数量存起来刷新后自动恢复。这个技巧在多次彩排和正式场合之间切换时特别有用不用每次重新配置。// 保存状态 function saveState() { const state { prizes: config.prizes, participants: config.participants, winners: winnerList // 已中奖名单数组 }; localStorage.setItem(annualLotteryState, JSON.stringify(state)); } // 恢复状态 function loadState() { const saved localStorage.getItem(annualLotteryState); if (saved) { const state JSON.parse(saved); config.prizes state.prizes; config.participants state.participants; winnerList state.winners; renderWinnerList(); } } // 每次抽奖后调用 saveState参数说明localStorage存储上限约 5MB存几百人的名单和奖项配置绰绰有余。注意JSON.parse可能抛异常要加try-catch。如果现场有多台电脑轮流抽奖localStorage不共享需要把状态存到后端或共享文件但年会场景一般一台电脑接投影够用。验证方法抽几次奖刷新页面看中奖名单和剩余奖项是否恢复。如果恢复失败检查localStorage的 key 是否一致以及JSON.stringify是否成功。我自己的习惯是正式抽奖前用localStorage.clear()清一次避免彩排数据混入正式环节。另外如果老板要求“抽奖过程可追溯”可以把每次抽奖的时间戳和中奖人追加到一个数组里一并存进localStorage事后导出成 CSV 就能交差。希望帮到你。本文还有配套的精品资源点击获取