简介一个基于 HTML、CSS 和 JavaScript 编写的情人节表白页面源码包面向希望用创意方式表达爱意的程序员与普通用户也适合作为 520 或情人节的特别礼物。页面在浏览器中直接运行无需额外插件依托逐行打印文字、文字随机变色、玫瑰飘落、烟花绽放和背景音乐等动态效果营造浪漫的夜空氛围适合情人节、纪念日、生日等场景。压缩包内含 8 个文件主要类型包括 HTML、CSS、JavaScript、PNG 图片与 MP3 音乐分别承担页面结构、视觉样式、交互特效、装饰素材和背景声音资源包仅约 524KB轻量易用。源码目录结构清晰使用者可快速定位并修改文字内容、颜色、字体大小、背景元素以及音乐文件定制出完全个人化的表白页面同时也能从中学习前端动效与音视频处理的综合实践。目前已有 5706 人学习下载无论是送给心仪之人还是作为前端练习都是实用且有温度的选择。1. 520 表白页不是花架子这份 HTML/CSS/JS 代码包解决什么问题每年 520 前后总有朋友来问我有没有现成的表白页代码。这份资源就是答案一段用 HTML、CSS 和 JavaScript 写好的浪漫单页打开即是爱心特效、打字机情话、可点击的交互卡片。它的价值不在于特效多炫而在于能直接用——不需要框架不用配后端拿下来替换文案和图片就能跑起来。适合三类人想送点特别礼物的程序员、被 deadline 逼着做班级或社团表白页的学生、想借 520 热度做 H5 但不想从零写的前端新人。下面按「结构原理 → 跑通步骤 → 踩坑 → 进阶」的顺序把这份资源拆开讲。2. 表白页的骨架HTML 结构、CSS 动画与 JS 交互的配合逻辑这种表白页看着花哨拆开就三层HTML 负责把「标题、情话、按钮、卡片」这些内容摆成结构CSS 负责把「爱心飘落、文字渐入、卡片弹出」这些动效渲染出来JavaScript 负责「打字机打字、按钮响应、音乐播放」这些行为。三层各干各的不要混在一个文件里写后面替换文案时会少踩很多坑。2.1 HTML 语义结构不要让情话堆在一个 div 里很多从零手写的表白页长这样一个div套一个div所有内容平铺在 body 里CSS 选择器全靠div div p去猜。这份资源的 HTML 骨架是分层明确的我把它简化成下面的结构!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title写给最特别的你/title link relstylesheet hrefstyle.css /head body !-- 爱心粒子容器所有漂浮的心形由 JS 动态挂载到这里 -- div idhearts aria-hiddentrue/div !-- 主内容区标题、打字机情话、两个按钮 -- main classstage h1 idgreeting亲爱的/h1 p idtyped-text/p div classactions button idbtn-yes typebutton我愿意/button button idbtn-no typebutton再想想/button /div /main !-- 最终卡片默认隐藏点击「我愿意」后才显示 -- div idcard classhidden p余生请多指教/p /div !-- 背景音乐autoplay 属性会被浏览器忽略原因见避坑章 -- audio idbgm srcassets/love.mp3 loop preloadauto/audio script srcscript.js/script /body /html这里有三个容易被忽视的设计点。第一#hearts容器上加了aria-hiddentrue因为爱心粒子是纯装饰元素读屏软件读到一串「心、心、心」没有意义还会干扰视障用户加了这个属性后辅助技术会跳过它。第二按钮都写了typebutton。如果不写按钮在表单上下文里会默认变成typesubmit点击会触发页面刷新或跳转表白页刷新一下就前功尽弃了。第三#typed-text是打字机效果的落点它在一开始应该是空的由 JS 往里面逐字写入内容。这样设计的好处是如果 JS 加载失败页面至少显示标题和按钮不会出现「情话永远打不出来的空白页」。在实际的代码包里图片和音乐一般放在 assets 目录CSS 和 JS 单独成文件和这里的骨架一致。你拿到手的第一件事应该是打开 index.html 看结构而不是直接去看 CSS 那一大坨动画。2.2 CSS 动画爱心粒子与渐入渐出的关键帧设计表白页的「浪漫感」大部分来自 CSS 动画核心是keyframes。以爱心粒子为例它从屏幕顶部落到屏幕底部同时旋转、改变透明度让观感上不是死板地直落而是有「飘」的感觉/* 爱心粒子的漂浮动画 */ keyframes heartFall { 0% { transform: translateY(-10vh) rotate(0deg); opacity: 0; } 15% { opacity: 0.9; /* 先快速显形避免从屏幕外飞进来时突兀 */ } 100% { transform: translateY(110vh) rotate(360deg); opacity: 0.2; /* 落到底部时半透明模拟远近层次 */ } } .heart { position: fixed; top: 0; left: 0; font-size: 18px; /* 实际使用中每个粒子会通过 JS 随机设置 */ animation: heartFall linear forwards; /* 播放一次结束态停在 100% */ pointer-events: none; /* 关键粒子不拦截按钮的点击事件 */ will-change: transform, opacity; }几个参数说明。translateY(-10vh)让粒子从视口上方 10% 的位置开始110vh确保它完全落到视口底部之外中间不会被截断。linear是匀速如果想更自然可以改成ease-in让粒子下落时越来越快接近重力效果但那样需要把每个粒子的起始延迟调得更分散才好看。注意这里没有写infinite而是用forwards。原因是一旦设成无限循环这个元素的动画永远不会结束animationend事件不会触发DOM 节点只增不减页面停留半小时后低端手机直接卡死。常见做法是每个粒子只播一次播完销毁再由 JS 补一个新粒子屏幕上的粒子数量始终稳定。后面避坑章会讲对应的清理逻辑。pointer-events: none是这里最容易漏的一行。如果不加飘落的爱心会盖在按钮上方鼠标点「我愿意」的时候实际点在爱心上按钮点不动。线上翻车案例里十个有八个是这个原因但它太不起眼排查起来像玄学。will-change: transform, opacity是给浏览器的提示让它提前为这两个属性建合成层。注意别滥用——如果页面上几十个元素都加will-change反而会占满 GPU 内存。这份资源里只对粒子这种持续动画的元素加。渐入渐出的文字效果用的是另一组关键帧原理相同opacity从 0 到 1配合translateY(20px)到translateY(0)让文字有一个「向上浮出并显形」的过渡。这类动画时长建议控制在 0.6 到 1 秒太短看不出来太长会让等待的人失去耐心表白页的节奏比特效重要。2.3 JavaScript 交互打字机、按钮反馈与状态切换JavaScript 在这份资源里承担三件事打字机效果、爱心粒子生成、按钮交互。打字机效果是最容易写崩的部分很多人用setInterval时没处理清除逻辑导致按钮出现后文字还在继续「打字」或者多次进入页面时定时器叠加。常见做法是用 Promise 包装让流程可控// 打字机效果逐字写入返回 Promise 便于后续串联 function typeWriter(el, text, speed 120) { let i 0; el.textContent ; return new Promise((resolve) { const timer setInterval(() { el.textContent text[i]; i; if (i text.length) { clearInterval(timer); // 关键打完后必须清除定时器 resolve(); } }, speed); }); } // 串联流程先打招呼再打字最后显示按钮 async function startShow() { const greet document.getElementById(greeting); const typed document.getElementById(typed-text); const actions document.querySelector(.actions); greet.style.opacity 0; greet.style.transition opacity 1s; void greet.offsetHeight; // 强制重排让 transition 生效 greet.style.opacity 1; // 标题先渐入 await typeWriter(typed, 想陪你走过每一个四季, 100); actions.classList.remove(hidden); // 情话打完后按钮再出现 }这里值得说三个点。第一typeWriter返回 Promise是刻意设计的。打字机是「打完这段才显示按钮」流程有先后依赖用async/await写出来是直线代码比层层回调好读好改。如果你只想要打字效果不关心完成时机可以把 Promise 去掉但那就没法在打完字后触发后续动作。第二greet.style.opacity 0之后必须隔一行强制重排void greet.offsetHeight再改成1。如果不做这一步浏览器会把两次样式修改合并到同一帧过渡直接被跳过标题是瞬闪出来的而不是渐入。这是新手经常撞的墙写了transition但动画不出现。第三按钮交互里最经典的「再想想」按钮逃跑逻辑用的是监听mouseenter事件改变按钮位置。但要注意这个写法在手机上是失效的移动端没有 hover 概念而且如果按钮是position: fixed并随机移动可能会盖住屏幕上的其他元素。代码包里通常会在移动端改成另一套逻辑比如按一次按钮文字就变成「不许拒绝」这个细节避坑章会细讲。爱心粒子的生成不复杂按配置的个数循环创建元素给每个元素随机 left、随机动画时长、随机延迟然后挂到#hearts容器里。唯一要注意的是动画结束后要清理 DOM 节点否则页面停留半小时后 DOM 里可能堆了几千个爱心节点低端手机直接卡死。3. 半小时跑通本地服务、文案替换与参数调优拿到代码包后我建议的路径是先在本地跑起来看原始效果 → 改文案和图片 → 调动画参数 → 部署。不要一上来就改代码不然你不知道哪些改动引入了问题。3.1 本地跑起来不要直接双击 index.html很多人的第一反应是双击 index.html 在浏览器里打开。这个做法在纯静态页面里大部分时候能看但这份资源里有音频文件直接file://协议打开时部分浏览器会因为本地文件的跨域限制拒绝加载音频表现为「页面正常、音乐不响」。更稳的做法是起一个本地 HTTP 服务# 在项目根目录执行Python 3 自带 http.server零依赖 cd /path/to/confession-page python3 -m http.server 8080 # 浏览器访问 # http://localhost:8080/如果你机器上没装 Python 但有 Node也可以npx serve . # 需要联网拉取 serve 工具端口 8080 如果被占用换一个没在用的端口即可比如 3000、8000。我一般用 8080因为数字好记且大多数机器上不会冲突。起服务后不要关掉终端窗口关掉服务就停了。为什么要走 HTTP 而不是直接双击文件除了音频跨域之外还有一个原因是后面部署到线上时线上环境就是 HTTP(S) 访问本地环境越接近线上踩的坑越少。你用file://测过没问题的页面部署后可能出现相对路径错乱、资源加载失败的问题到时候就不好定位了。3.2 文案与图片替换三个文件里改哪里代码包做到「能直接改」的关键是把所有可变内容收敛到一个配置对象里。你不需要去 CSS 或 HTML 里翻来找去只改配置就行。典型的配置长这样// 通常在 script.js 顶部改这里等于改整个页面的内容 const CONFIG { greeting: 亲爱的遇见你是最幸运的事, loveText: 从第一次聊天到现在我还是会反复心动。 谢谢你出现在我的生活里剩下的路想和你慢慢走。, photoList: [ { src: assets/img/01.jpg, caption: 第一次一起吃饭 }, { src: assets/img/02.jpg, caption: 某个周末的日落 }, { src: assets/img/03.jpg, caption: 你笑得最好看的那天 } ], bgm: assets/music/love.mp3, heartColor: #ff4d6d, heartCount: 20 };替换文案时最需要注意的是字符串里的引号。JavaScript 字符串用单引号包裹时文案里不能再出现单引号否则会提前截断字符串导致整个脚本报错页面瘫痪。比如「Its you」这类英文文案就容易踩这个坑要么把文案里的单引号转义为\要么把整个字符串改用双引号或反引号包裹。图片路径是另一个高频出错点。assets/img/01.jpg是相对当前页面的路径如果 index.html 在根目录、图片在 assets/img 下这样写没问题。但如果你把 index.html 移到了子目录这个路径就要相应加../。我的习惯是项目根目录放 index.html所有资源统一放 assets 下路径永远从根目录出发部署时整个文件夹原样上传路径不会乱。替换图片时还要注意文件名。很多网上下载的图片文件名是中文或带空格的比如「我们的合照 .jpg」这种文件在 HTTP 服务下可能正常但在部分静态托管平台上会因 URL 编码问题 404。我一般统一改成拼音或日期格式比如trip-2024-01.jpg省心。3.3 节奏调优动画速度、音乐音量、滚动时机表白页的情绪节奏比特效密度重要。参数调优的表格可以参考参数位置参考值调大后的效果调小后的效果heartCountCONFIG20画面更满、更热闹干净、适合文字多的页面粒子动画时长CSS heartFall6~10s飘得慢、更惬意飘得快、略显急促typeSpeedtypeWriter 调用处100~120ms/字读起来更从容有紧迫感、像在赶时间audio.volumescript.js 内0.5掩盖翻页和环境声不易察觉、像没放音乐按钮延迟出现startShow 内打字完成后等待更久、吊胃口节奏更快、不拖沓实际调的时候我一般先调heartCount和打字速度这两个参数对观感影响最直接。heartCount从 20 往下调成 10画面立刻清净一半适合文字比较多、需要逐行读的内容往上调到 40低端手机会开始掉帧不建议再往上。audio.volume要注意的是很多代码包默认没设置音量直接用音频文件自身音量而网上下载的音乐源音量可能偏大或偏小。我会在 JS 初始化时统一设一次audio.volume 0.5然后在按钮里再让用户手动调整这样至少不会一打开就被震到。滚动时机是指「页面是否需要滚动」。大部分表白页是单屏设计所有内容在首屏内完成如果你要加照片墙或长文案页面会变长这时要把打字机的位置和滚动联动——文字打完后自动滚到下一页。实现上就是监听打字机完成事件调用element.scrollIntoView({ behavior: smooth })这个放到进阶章讲。4. 避坑指南表白页上线前最容易翻车的五个问题这一章全是血泪经验。下面每条都是真实踩过的坑按「现象 → 原因 → 解决」写建议你在发链接之前对照着检查一遍。4.1 音乐没声音浏览器自动播放策略拦路现象桌面上打开页面一切正常但把链接发到手机上在移动端浏览器里打开后背景音乐完全不响控制台报NotAllowedError。原因主流浏览器的自动播放策略是「必须有用户手势才能播放有声内容」。audio autoplay这个属性在桌面端也许能生效但在移动端基本等于没写浏览器直接拒绝播放。解决把自动播放改成「首次点击时播放」。最简单的方式是给页面加一个「点击开始」的遮罩按钮点击后先播放音乐再进入动画流程const bgm document.getElementById(bgm); const cover document.getElementById(cover); // 首次触摸或点击时浏览器的用户手势条件满足播放会被允许 cover.addEventListener(click, () { bgm.volume 0.5; bgm.play().catch(() console.warn(播放失败)); cover.classList.add(hidden); });注意play()返回的是一个 Promise如果播放被拒绝异常会在 Promise 里抛出来不 catch 的话控制台会红一片。我见过有人写了bgm.play()没加.catch()结果用户点了三十次按钮都没有声音页面还在疯狂报错。4.2 图片全裂相对路径和文件名编码问题现象本地起服务测试时图片正常部署到线上后照片墙全部 404或者只有部分图片能显示。原因两个。一是图片路径写成了绝对路径比如/assets/img/01.jpg部署在子目录时根路径指向错误二是文件名带中文、空格或特殊字符服务器返回的 URL 编码和本地文件系统不一致。解决所有资源路径统一用相对路径并且把文件名全部改成英文字母、数字和下划线。部署前在本地把整个项目目录打包传到服务器后再用浏览器无痕窗口访问一次逐个检查图片是否加载成功——浏览器的网络面板里 404 的网络请求会标红一目了然。4.3 手机上「再想想」按钮点不到mouseenter 不触发现象桌面浏览器里「再想想」按钮会追着鼠标跑体验很好但同一个页面在手机上打开按钮一按就触发根本没法「逃跑」。原因移动端没有 hover 状态mouseenter事件在触摸屏上不会触发。代码包里如果只写了mouseenter的逃跑逻辑在手机上它就是一个普通按钮点击就执行默认事件。解决用能力检测区分设备判断是否存在触屏事件const isTouchDevice ontouchstart in window; if (isTouchDevice) { // 触屏设备不逃跑改成点一次变一次文字 noBtn.addEventListener(click, () { noBtn.textContent 真的不再想想; noBtn.style.transform scale(1.05); }); } else { // 桌面端保留逃跑逻辑 noBtn.addEventListener(mouseenter, moveBtn); }这个改法还有一个好处触屏设备上「再想想」按钮变成了可交互的梗比死板地让它逃跑更有温度。4.4 移动端打开布局乱掉缺少 viewport 或固定像素宽度现象电脑浏览器里页面完美居中发到手机上打开文字变得巨大布局全部错位滚动条还出现在奇怪的位置。原因移动浏览器对没有viewportmeta 标签的页面默认按 980px 的宽度渲染再整体缩小适配屏幕。结果就是 CSS 里写的 100% 被解析成 980px 的 100%手机上自然错乱。解决确保head里有这行meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno同时检查 CSS 里有没有写死的width: 1000px之类的固定宽度有就改成max-width: 100%。我碰到过一个案例页面上有个元素宽度写死了 800px桌面看没事手机上一半内容被截掉检查了半天才发现是这里。4.5 动画掉帧卡顿粒子数量和 DOM 节点失控现象页面前 10 秒很流畅越往后越卡最后低端手机上滑动页面像幻灯片。原因两个叠加因素。一是粒子数量太多超过 40 个固定定位元素持续做动画二是生成粒子的代码只创建节点、不清理节点页面停留时间越长DOM 里累积的爱心越多。解决把heartCount控制在 1525 之间并且给每个粒子的动画加结束清理逻辑// 爱心动画结束后从 DOM 中移除该节点避免无限堆积 heart.addEventListener(animationend, () { heart.remove(); spawnOneHeart(); // 销毁后补一个新粒子数量始终稳定 });注意这个方案的前提是动画必须是一次性的、有限时长的不能是infinite否则animationend永远不会触发清理逻辑就白写了。和 2.2 里forwards的写法是对应的。如果你还想更激进可以用requestAnimationFrame Canvas 替代 DOM 粒子性能能提升数倍但代码复杂度也上去了。对表白页这种场景DOM 粒子 控制数量 及时清理已经足够流畅。5. 进阶改造从「能看」到「好看」的四个升级点基础版本能跑通之后可以根据对方的使用场景做针对性升级。下面四个升级点是从实际需求里提炼的每个都能独立落地改坏了也不影响主流程。5.1 照片墙用 CSS Grid 实现错落相册照片是表白页里最能拉近距离的内容。常见做法是把照片墙做成响应式的 Grid 布局每张照片带一个说明文字。代码包里预留了photoList配置渲染逻辑可以这样写// 根据 photoList 动态生成照片墙网格 const gallery document.getElementById(gallery); CONFIG.photoList.forEach((item, index) { const figure document.createElement(figure); figure.innerHTML img src${item.src} alt${item.caption} loadinglazy figcaption${item.caption}/figcaption ; // 隔张错位偶数张往下沉一点形成错落感 if (index % 2 1) { figure.style.transform translateY(20px); } gallery.appendChild(figure); });对应 CSS 用 Grid手机上大约两到三列桌面端四到六列不需要写媒体查询#gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; padding: 16px; }repeat(auto-fit, minmax(140px, 1fr))的意思是最小列宽 140px能放下几列就放几列自动换行。手机上 140px 一列大约放下 23 张桌面端能放下 46 张。loadinglazy是给图片加懒加载照片多的时候首屏速度不会拖垮。5.2 背景音乐自动播放被拦截后的双保险避坑章讲了一重保险点击封面播放这里再补一重给音乐加一个开关按钮让用户可以主动关掉或重新打开。表白页的场景里对方可能在看的时候旁边有人音乐开开关关的需求真实存在const musicToggle document.getElementById(music-toggle); let isPlaying false; musicToggle.addEventListener(click, () { if (isPlaying) { bgm.pause(); musicToggle.textContent 播放音乐; } else { bgm.currentTime 0; // 从头播放避免从暂停处续播的突兀感 bgm.play().catch(() { musicToggle.textContent 点这里开启声音; }); musicToggle.textContent 暂停音乐; } isPlaying !isPlaying; });注意bgm.currentTime 0这行用户点开音乐时如果是从暂停状态续播会从上次中断的位置接着放容易让人误以为音乐卡了。每次打开都从头放体验更干净。5.3 移动端适配细节比你想的更多移动端和电脑端的差异比想象中大。三个细节第一部分较新的 CSS 属性在部分手机浏览器内核里不支持测试时必须用真机扫码不能只看电脑模拟器。第二按钮的点击延迟问题——连续点「我愿意」按钮可能触发第二次重复事件。简单处理是在点击后立即禁用按钮btnYes.addEventListener(click, () { btnYes.disabled true; // 避免重复点击触发多次弹窗 btnNo.disabled true; showFinalCard(); });disabled化的按钮样式会和原来不同记得在 CSS 里补一个button:disabled { opacity: 0.6; cursor: default; }不至于变得很丑。第三如果你的页面里有底部固定元素在部分全面屏手机上会被系统手势条挡住。可以加上padding-bottom: env(safe-area-inset-bottom)做适配这个属性只有支持刘海屏安全区的设备才生效不加也不影响主流程加了更稳妥。5.4 纪念日倒计时把「在一起多少天」算清楚倒计时和纪念日是表白页里很戳人的数字玩法。计算两个日期之间相隔的天数常见的坑是直接把时间戳相减再除以 86400000遇到时区差异会有偏差。更稳的写法是把日期归零到当天零点再算function daysSince(dateStr) { const start new Date(dateStr); const today new Date(); // 都归零到当天 0 点避免小时差影响计算 const startDay new Date(start.getFullYear(), start.getMonth(), start.getDate()); const todayDay new Date(today.getFullYear(), today.getMonth(), today.getDate()); return Math.round((todayDay - startDay) / 86400000); } document.getElementById(days).textContent 我们已经相识 ${daysSince(2023-05-20)} 天;new Date(year, month, date)这种构造方式会把时间归零到当天零点再相减就只剩整天的差值Math.round兜底处理误差。注意月份从 0 开始但这里传的是已经构造好的 Date 对象不受影响。这几个升级点单拎出来都不难但组合在一起表白页就从「模板感很重」变成了「专门为对方做的东西」后者才是这份资源真正的价值。6. 发链接之前按这个验证清单走一遍代码改完、部署上线别急着把链接发出去。以下是我每次准备发出之前强制自己走完的验证流程每一条都对应过真实的翻车现场。第一步真机实测。用手机浏览器打开一次不要只看电脑上的开发者工具模拟器。重点检查三件事音乐在首次点击后是否正常响起「再想想」按钮在触屏上是否是可交互的页面在手机上是否有错位或字体重叠。第二步断网测试。把手机开飞行模式再打开页面如果页面还有内容显示说明静态资源都本地化了如果一片空白或图片全裂说明有资源依赖外部链接对方拿到链接后可能因为网络环境加载缓慢。第三步把文案从头到尾读一遍。名字有没有打错、日期有没有写错、标点是不是中文标点这些低级错误特别容易在被感动的那一刻被发现。打字机速度和音乐音量也在这个环节模拟对方的视角感受一遍让节奏平缓一些。第四步备份原始文件。改前把原始代码包留一份改后每一版都另存一份。表白页这种东西后面大概率还要改文案、调音乐有个后悔药比重新写一遍靠谱得多。从那次我把日期写错、对方截图发回来之后我每次发链接前都强制自己走一遍这四步字斟句酌地读文案、真机打开听音乐、断网测静态资源。那天晚上对方发来一段话说页面打开那一刻背景音乐响起她眼眶红了。做前端这么久这是少有的、代码直接带来情绪反馈的时刻。这份资源不难难的是别在最后一步翻车。希望帮到你。本文还有配套的精品资源点击获取 SEO 优化官网定制响应式建站教育培训建站