
简介这是一套可直接运行的HTML年会抽奖前端源码面向需要快速搭建活动现场互动环节的开发者或组织者。通过JSON配置参与人员数据也可用Ajax动态加载业务API名单具备抽奖、暂停、背景音乐和主题选择等功能视觉效果好。资源包共29个文件压缩后3.58MB包含15张JPG图片用于背景与头像8个JS脚本实现抽奖逻辑与动画3个CSS统一样式另有HTML/HTM页面和MP3背景音乐结构清晰便于部署或二次开发。目前已有5123人学习下载说明其在实际场景中具有不错的参考价值。借助这份源码可快速理解前端抽奖交互的常见实现思路并根据自带数据接口与样式框架灵活扩展员工库、奖品库或定制主题适合年会、庆典、营销活动等场景。1. 好看的年会抽奖页面为什么值得自己写一份 HTML 源码年会抽奖看起来是个小功能一旦现场卡壳就是社死瞬间。与其用 PPT 一页页翻牌不如直接用 HTML CSS JS 写一个滚屏抽奖源码不装软件、不挑设备名单和特效都能随手改。博主推荐这类 HTML 年会抽奖因为源码透明、问题可查行政和新手前端都能接手。这篇文章会从功能拆解讲到可复制的源码再把现场最容易翻车的细节一个个过掉适合准备年会抽奖的 IT 支持、活动负责人和前端初学者。2. 年会抽奖页面的功能拆解从滚屏到中奖名单哪些必须做做年会抽奖最忌讳一上来就写特效。先想清楚现场流程主持人喊“开始”大屏名单高速滚动喊“停”屏幕定格在一个人名这个人上台领奖下一轮开始前已经中奖的人不能再出现。这几个动作听起来简单但背后对应着三个关键需求可制造悬念的滚屏交互、公平且不重复的中奖逻辑、以及方便现场救援的“后悔药”。我接这类小需求时第一步不是写代码而是把需求写成一张检查表。如果只做一场固定人数的抽奖基本逃不开下面这些点。2.1 核心交互滚屏、停稳、既视感滚屏是最容易“有氛围但没公平感”的部分。很多人写滚屏就是一个setInterval每 50 毫秒换一个名字速度几乎不变到最后要停的时候还是同一个速度主持人喊“停”的时机就成了唯一变量。这种方案代码最省但在现场会很尴尬如果名单顺序是固定的观众能看到“规律”甚至能猜出下一个人是谁如果停了之后显示的名字和刚才滚到的位置对不上又会让人觉得有暗箱操作。所以我会做两件事一是名单在开始前先做一次洗牌避免名单本身的顺序被观众摸透二是停止时不是直接取当前滚动到的值而是用Math.random()在候选池里随机定一个中奖者再把屏幕显示成这个结果。这样既保留了“滚屏”的紧张感又让结果真正随机。至于“停”的瞬间和最终显示不一致通常靠一个停稳动画来遮过去——把速度从快变慢最后再翻到结果观众注意力会被动画带走。视觉上的“既视感”还有一个来源名单里有同名或相似名。如果两三个人叫“王芳”抽到的到底是哪个王芳我会在名单里加部门或工号后缀比如“王芳技术部”或者干脆用“王芳-0347”这种格式避免领奖时对不上人。2.2 好看的关键大背景、金光、扫光与字体“好看”这件事没有绝对标准但年会抽奖的视觉套路很统一深色背景、半透明光斑、金色文字、大字重、微微扫过的高光。原因很实际年会大屏一般在报告厅灯光复杂浅色背景会显得发白深色背景反而能把发光文字衬出来。金红撞色是最稳的选择公司有主视觉色就另说。对 HTML 页面来说实现这些不需要图片素材。背景用 CSS 径向渐变文字用text-shadow叠加出光晕扫光效果可以用background-clip: text配一个移动的渐变背景完成。字体建议用系统中文字体栈不要为单个演示页面去加载 woff 字体文件——现场投屏的电脑不一定联网字体加载失败反而拖慢首屏。等页面已经能稳定运行了再去调字体也不迟。需要注意的是很多“好看”的源码模板会堆大量 CSS 动画粒子特效满天飞。但年会现场最怕的是动画吃满 CPU导致点击按钮到滚屏启动延迟一两秒。特效不是越重越好我会在彩排时专门开一次全特效投屏跑几分钟摸一摸电脑风扇和温度。如果机器发烫就去掉粒子保留文字光晕和扫光观感已经足够。2.3 数据与名单管理直接写进数组还是走 JSON名单放哪里决定了现场改人名的速度。最粗暴的做法是把所有名字写死在 JS 数组里优点是双击 HTML 文件就能离线运行不依赖任何服务器。缺点是每次换人都要编辑源码对行政同学不友好。如果名单超过两百人或者有“外请嘉宾”“领导名单”这种临时调整我更建议在页面上放一个 textarea 粘贴区让操作的人直接在页面上改而不是到处找源码文件。三种常见做法的特点如下名单存放方式适合场景现场修改速度离线可用JS 数组写死名单稳定的小场次需要改源码最慢完全离线textarea 粘贴导入名单频繁变动页面内粘贴快完全离线外部 JSON fetch超大型活动或后台管理需要本地 HTTP 服务双击打开时容易受限我一般推荐第二种。哪怕最终源码里还是数组也会在页面上留一个“导入名单”入口。这样现场加人、减人不需要打开编辑器也不怕手滑把代码改坏。至于 JSON 这种方案在纯本地环境里容易踩 CORS 坑浏览器限制比较多除非你有能力起一个本地服务器否则年会这种一次性场景不要硬上。另外名单去重必须在导入时就做。很多人只顾着去重中奖者却忘了名单本身可能有重复Excel 导出时某行被复制了导入后同一个名字出现在池子里两次那么“不再重复抽奖”的逻辑就会出错。我在导入函数里会主动Set去重并且提示“原名单 X 人去重后 Y 人”避免现场出现“为什么这个人还在池子里”的质疑。3. 动手写一份可复用的源码HTML CSS JS 的最小工程这个标题最值钱的部分就是“附源码”。我在这里给的不是某个平台的成包资源而是一个可以直接抄走的最小工程。建议你按文件拆分来维护而不是把几百行全塞进一个 HTML。拆分后CSS 出了问题不会牵连 JS名单和业务代码也能分开。文件结构如下annual-meeting-lottery/ ├── index.html ├── style.css ├── data.js ├── app.js └── names.txt // 可选的名单备份index.html只放结构骨架data.js放名单数据app.js放抽奖逻辑。这套结构的目的很明确年会现场最紧急的改动是换名单打开data.js或页面上的文本框处理即可不会误伤逻辑。3.1 页面结构与抽奖池定义先写index.html重点是“屏幕”和“按钮”两个元素其他都是附加。屏幕用一个div显示当前滚动或中奖的名字按钮负责切换开始/停止状态。!doctype html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title年会抽奖 · 滚屏版/title link relstylesheet hrefstyle.css /head body div classstage div classscreen idscreen年会抽奖/div div classcontrols button idbtn开始/button button idrestartBtn hidden悔一轮/button button idimportBtn hidden导入名单/button /div /div textarea idnamesInput hidden/textarea script srcdata.js/script script srcapp.js/script /body /html这里有两个细节值得说。第一script放在body底部这样app.js执行时idscreen和idbtn已经存在于 DOM 中不需要再包DOMContentLoaded。第二namesInput和importBtn默认隐藏。正常的年会现场不会想看到一个大文本框只有需要临时改名单时才通过一个快捷键或按钮显示出来。我把它们藏在页面角落避免误触。在data.js里定义名单池// 把参与抽奖的所有人放进来用逗号或换行分隔都行 const ALL_NAMES [ 张伟, 王芳技术部, 李娜市场部, 刘洋销售部, // 在这里继续往下加 ];这份文件只做一件事提供初始名单。项目如果长期使用可以把名单放到names.txt里备份每次改完data.js顺手同步一下。注意ALL_NAMES用const声明没问题因为我之后只会改它的数组内容不会重新给这个变量赋值。3.2 抽奖算法与状态控制不重复、可回退核心逻辑在app.js。这里要同时处理“开始滚屏”“停止”“去重”“回退上一轮”四种状态所以我用一个rolling布尔值区分开始和停止用pool表示“还剩下哪些人可抽”用history记录已中奖者。const screen document.getElementById(screen); const btn document.getElementById(btn); const restartBtn document.getElementById(restartBtn); let pool shuffle([...ALL_NAMES]); let history []; // 已中奖名单 let candidateState []; // 每一轮开始前的池子快照用于回退 let rolling false; let timerId null; let cursor 0; // Fisher-Yates 洗牌 function shuffle(arr) { for (let i arr.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [arr[i], arr[j]] [arr[j], arr[i]]; } return arr; } function nextName() { cursor (cursor 1) % pool.length; screen.textContent pool[cursor]; } function start() { if (pool.length 0) { screen.textContent 抽奖已结束; btn.disabled true; return; } if (rolling) return; candidateState [...pool]; // 保存本轮未抽名单 rolling true; timerId setInterval(nextName, 55); btn.textContent 停止; } function stop() { if (!rolling) return; clearInterval(timerId); timerId null; rolling false; const idx Math.floor(Math.random() * pool.length); const winner pool[idx]; history.push(winner); pool.splice(idx, 1); screen.textContent winner; screen.classList.add(winning); btn.textContent 下一轮; restartBtn.hidden false; } function restartRound() { if (rolling || candidateState.length 0) return; history.pop(); // 把上一位中奖者从历史中拿掉 pool [...candidateState]; // 候选池恢复到这轮开始前 cursor 0; screen.textContent 准备下一轮; screen.classList.remove(winning); restartBtn.hidden true; btn.textContent 开始; } btn.addEventListener(click, () rolling ? stop() : start()); restartBtn.addEventListener(click, restartRound);这段代码的逻辑说明pool是候选池初始由ALL_NAMES洗牌后复制出来。每抽中一人就splice掉一个天然保证不重复。history只存已中奖者用来领奖登记和回退。candidateState在每次start时保存当前pool的快照restartRound时用快照恢复。这是给主持人准备的“后悔药”——如果念错了名字、有人没到场可以撤回上一轮。setInterval(nextName, 55)中的 55 毫秒是我测试过的“有紧张感但没有眩晕感”的速度。想更快可以改成 40想更慢改成 70。这个值不用做成分离配置但调完一定用投屏跑一遍。停止时用Math.random()在pool中随机定中奖者而不是取pool[cursor]。cursor只负责滚动显示最终结果由随机数决定避免被卡点操作影响。3.3 入场名单导入与乱序现场临时改名单是最容易翻车的操作。我提供一个隐藏的导入面板把名单按一行一个粘贴进textarea点“导入名单”后自动覆盖ALL_NAMES并重置所有抽奖状态。const importBtn document.getElementById(importBtn); const namesInput document.getElementById(namesInput); function importNames() { const raw (namesInput.value || ).trim(); if (!raw) { alert(名单不能为空); return; } // 兼容换行、逗号、中文逗号、顿号分隔 const list raw .split(/[\r\n,、;]/) .map((s) s.trim()) .filter((s) s.length 0); if (list.length 2) { alert(至少需要两个名字); return; } ALL_NAMES.length 0; ALL_NAMES.push(...new Set(list)); // Set 去重 pool shuffle([...ALL_NAMES]); history []; candidateState []; cursor 0; rolling false; if (timerId) clearInterval(timerId); timerId null; screen.textContent 名单已更新; screen.classList.remove(winning); btn.textContent 开始; restartBtn.hidden true; } importBtn.addEventListener(click, importNames);这段代码的关键是ALL_NAMES.length 0; ALL_NAMES.push(...new Set(list));。它不会重新赋值ALL_NAMES所以data.js里的const声明依然成立new Set(list)会把重复名字去掉并保留第一次出现的顺序。导入后所有历史中奖记录清空候选池重新洗牌等于回到一场新活动的初始状态。如果你需要保留已抽记录比如只补充几个同事进来就不要清history而是把已有名单并入pool。这个导入面板在最终交付时通常会保留。“隐藏”不一定等于“删掉”现场真遇到临时加人少改一行代码就少一分紧张。4. 把效果调成“年会级”抽奖转场、中奖播报、大屏适配源码能跑只是第一步。真正让老板觉得“这钱花得值”的部分是把简单滚动包装成舞台效果。这一章不写复杂框架只做三件事让页面适合大屏投屏让声音和转场有仪式感让换肤变成改几个变量的事。4.1 大屏与投屏适配别让字体小到最后一排看不见年会现场的屏幕通常离观众十米开外字号不能按桌面 UI 来。桌面端 32px 已经很大投到大屏可能只有蚂蚁大小。我会把页面视觉基准放在 1920x1080 上但不是把字体写死成 160px而是用clamp()做响应式范围最小 48px最大 160px并根据视口宽度在中间取。html, body { margin: 0; height: 100%; overflow: hidden; background: radial-gradient(circle at 20% 20%, #221a40, #05020d); font-family: PingFang SC, Microsoft YaHei, sans-serif; } .stage { width: 100vw; height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4vh; } .screen { font-size: clamp(48px, 12vw, 160px); font-weight: 900; letter-spacing: 0.05em; color: #ffd700; text-shadow: 0 0 20px rgba(255, 215, 0, 0.6), 0 0 60px rgba(255, 180, 0, 0.3); } button { font-size: 2rem; padding: 0.5em 2em; cursor: pointer; }这里用vw单位的好处是分辨率从 1920 切到 1366字号跟着视口宽度缩放不会出现投影仪只显示一半的情况。gap: 4vh让屏幕和按钮之间的距离也随高度变化。注意width: 100vw在滚动条存在时会撑出横向溢出所以我特意在body上加了overflow: hidden把滚动条直接禁掉。投屏全屏也是一个单独的按钮。进入全屏用document.documentElement.requestFullscreen()退出用document.exitFullscreen()。我把这个按钮放在页面右下角方便在观众看不到的角度操作function toggleFullscreen() { if (document.fullscreenElement) { document.exitFullscreen(); } else { document.documentElement.requestFullscreen(); } } document.getElementById(fullBtn).addEventListener(click, toggleFullscreen);这里只针对现代浏览器兼容代码会额外判断webkitRequestFullscreen但普通年会现场的 Chrome 已经够用。如果你发现点击全屏没反应优先检查外层容器有没有overflow限制以及浏览器是否是旧版 Edge。4.2 转场与音效用 Web Audio 做“叮”的一声年会抽奖没有声音会很干但给页面放一个 MP3 音频文件又会遇到加载失败、格式不支持的问题。最稳的方案是用 Web Audio API 现场合成提示音不需要任何素材代码只有几十行。let audioCtx null; function ensureAudio() { if (audioCtx) return; audioCtx new AudioContext(); } function playTone(freq 880, duration 0.08) { if (!audioCtx) return; const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); osc.type sine; osc.frequency.value freq; gain.gain.setValueAtTime(0.08, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime duration); osc.connect(gain).connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime duration); }调用方式很简单在start()里ensureAudio(); playTone(660, 0.05);在stop()里playTone(990, 0.15);。浏览器有自动播放策略必须等用户至少点击过一次按钮之后AudioContext才能正常出声。所以我用“开始”按钮作为触发第一次点击时顺便初始化音频上下文。这个合成音的设计逻辑是开始用短而低的“嗒嗒”停止用长而高的“叮”。频率 660 和 990 正好是五度关系听起来比较像庆典。如果你想让声音更丰富可以再加一个不和谐音程的 oscillator但年会现场建议克制音量控制在背景音乐之下不要盖过主持人麦克风。4.3 皮肤定制把默认金改成本年度主题色不同公司年会有不同主题色今天用“科技蓝”明年可能变“中国红”。如果每个颜色都去源码里搜#ffd700很容易漏改。我习惯把颜色和背景收敛成 CSS 变量换肤只改最上面几行。:root { --prize-color: #ffd700; --bg-gradient: radial-gradient(circle at 30% 30%, #1b1545, #070311); } .screen { color: var(--prize-color); text-shadow: 0 0 20px rgba(255, 215, 0, 0.5), 0 0 60px rgba(255, 180, 0, 0.25); } body { background: var(--bg-gradient); } body.theme-red { --prize-color: #ff3c3c; --bg-gradient: radial-gradient(circle at 30% 30%, #4a0e0e, #1a0303); }换主题时给body加一个theme-redclass 就行。如果要给中奖者名字加“扫光”效果常见做法是用background-clip: text配合动画.screen.shine { position: relative; background: linear-gradient(90deg, transparent 20%, rgba(255,255,255,0.4) 50%, transparent 80%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: shine 2.5s linear infinite; } keyframes shine { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }这个扫光和text-shadow是冲突的color: transparent会让文字填充变成透明text-shadow还会按透明文字的外框投影效果容易变成脏边。所以我的做法是滚屏阶段用text-shadow金色光晕中奖定格后临时切到“扫光” class让名字亮一下然后自动移除。两种效果不叠加各管各的节奏。5. 年会抽奖源码的避坑记录5 个最该提前排查的细节这套源码逻辑不复杂但年会现场压力大任何一个“小问题”都会被放大。下面是我踩过的坑每条都按“现象、原因、解决”来说留着给你当排查清单。5.1 现象第一次点击“开始”按钮屏幕毫无反应原因通常是app.js运行报错按钮事件根本没绑定上。常见诱因是script标签放在head中、执行时document.getElementById找不到元素或者data.js里名字漏了引号导致整个脚本解析失败后面代码全部不执行。我一直把script放在body底部就是为了避免这个坑。解决方法是先打开浏览器开发者工具看 Console 里有没有红字报错。没有报错的话检查btn是不是null有时页面里有多个idbtngetElementById只返回第一个事件的绑定对象可能被别的元素占用了。彩排前用手机拍一下控制台截图比现场瞎猜快得多。5.2 现象同一轮抽完中奖名单里出现重复名字原因最直接候选池pool没有在抽完后移除中奖者或者名单本身导入时有重复值。如果用的是“从ALL_NAMES里随机抽、但不从数组里删”的方案那每个人被抽中的概率不变重复是必然的。解决方法是把“候选池”和“全量名单”严格分开。每抽中一人立刻splice或按名字过滤掉。另外导入名单时一定用new Set()先做一遍去重。我遇到过 Excel 导出时把某个部门名单复制了两遍这种问题不提前查只有中奖后才会暴露。5.3 现象大屏上字体一会儿过大溢出一会儿小到看不清楚原因是只用固定px字号没有做视口适配。不同会议室的分辨率不一样有些投屏还会自动缩放固定 120px 在 1366 宽的屏幕上可能挤成一行不够放在 2560 宽的屏幕上又显得很小。解决方法是把字号改成clamp(48px, 12vw, 160px)页面整体布局用vw/vh或 flex 居中。如果名单里有“王芳技术部”这种长文本尤其要测试最长的名字确认在最小分辨率下也不会换行。彩排时把笔记本分辨率切到 1366x768 跑一遍这是最保守的验证方式。5.4 现象停止时大屏显示的名字和最终中奖结果对不上这个坑发生在“滚动显示”和“最终随机”两套逻辑混在一起时。比如停止时先取pool[cursor]显示然后再用Math.random()算得中奖者却没有更新屏幕文本观众就会看到“屏幕停在一个名字主持人念的是另一个名字”场面非常尴尬。解决方法是让“最终中奖结果”成为唯一事实在stop()中先用随机数确定winner再把这个winner写入screen.textContent。不要依赖cursor作为中奖值。如果你想做“减速后定在某个名字”的动画也应该先随机出结果再通过动画让视觉最终停在结果上而不是让动画自己决定结果。5.5 现象刷新页面后中奖名单全部丢失下一轮重复抽了已中奖的人原因是history只存在内存里页面一刷新就归零。年会现场经常有人手滑按到刷新键或者投屏电脑因为休眠被唤醒导致页面重载这时候所有中奖记录全没了全场只能重新来过。解决方法是把history同步进localStorage。每抽中一个人就执行localStorage.setItem(annualMeetingHistory, JSON.stringify(history))页面加载时如果本地有历史记录就恢复history并同步过滤pool。这样刷新后还能继续。要注意的是恢复时判断“这条历史是否属于当前奖项/当前名单”如果名单被重新导入过旧记录就别恢复了。6. 往生产级走把本地网页变成可维护的“年会工具”以及验证方法最后这一章我从“能跑通的 demo”讲到“能托付的年会工具”。如果你只是拿源码应急做到前几章已经够用但如果你想在彩排时做到心里有底我建议再加两层历史持久化和自动化验证。历史持久化其实很简单。在stop()里加一行localStorage.setItem(annualMeetingHistory, JSON.stringify(history));在restartRound()里同样同步一次。页面加载时读取这个 key如果里面不是空数组就把history恢复出来再把pool从ALL_NAMES中排除这些人try { const saved JSON.parse(localStorage.getItem(annualMeetingHistory) || []); history.push(...saved.filter((n) ALL_NAMES.includes(n))); pool shuffle(ALL_NAMES.filter((n) !history.includes(n))); } catch (e) { history []; }这行代码最大的价值不是防刷新而是防“误操作”。现场有人按了 F5主持人不会慌因为历史还在。我第一次独立做年会抽奖时没有这行结果现场重刷后名单乱套只能硬着头皮手动记到现在我还会把这段代码当成默认配置。自动化验证则针对抽奖逻辑。年会前我会在控制台跑一个模拟函数检查“所有人都能且只能被抽中一次”function simulateDraw(times 200) { const bag Array.from({ length: times }, (_, i) N i); const history []; while (bag.length 0) { const idx Math.floor(Math.random() * bag.length); history.push(bag[idx]); bag.splice(idx, 1); } return new Set(history).size times; } console.log(模拟结果, simulateDraw(500));这个函数不依赖页面只测试“随机取人 删除”这个核心动作。如果返回false说明逻辑里有重复或遗漏如果返回true再去验证 UI。我一般会用一份 200 人的假名单跑三遍完整流程并在第一遍时故意按“刷新”键确认 localStorage 能救场。另外真正上节目之前我会把抽奖次数从一次扩大到整个年会流程比如三等奖 30 人、二等奖 10 人、一等奖 3 人中间穿插现场临时加名额。做法是给每个奖项准备独立的historyKey写死后逐个清空。不要把所有奖项共用一个历史记录否则一等奖会跳过已经在三等奖中过的人除非你们明确规定“所有奖项不重复中奖”。我最早做年会抽奖时把名单写死在源码里也没有历史备份现场主持人报错一个名字我只能紧急改代码。后来我把名单导入、池子剔除、localStorage 这三件事当成默认方案再也没在台上翻过车。希望帮到你。本文还有配套的精品资源点击获取