简介这份网页模板围绕情人节与表白场景设计面向希望用前端技术制作个性化纪念页面的初学者与情侣用户提供可直接改造的静态站点用于集中展示相识日期、合照、情感时间线与心意寄语兼顾浪漫氛围与浏览体验。压缩包共43个文件约1.08MB以HTML页面、CSS样式、JavaScript脚本和JPG、PNG图片为主要构成另含字体图标、GIF动效及SVG素材页面、样式、脚本与素材分区明确方便按模块替换与二次调整。已有9240人学习说明其在浪漫主题网页练习中具备一定参考热度。读者可获得多页面的完整布局、Bootstrap响应式适配、爱心鼠标特效、相册展示与背景氛围等实现思路日期文案和图片素材都能换成自己的内容适合作为前端入门练手、课程设计素材以及礼物型页面定制的起点也能帮助理解情感化设计与交互细节的配合。1. 从一张静态贺卡到会动的表白页这个页面到底能做什么情人节前一天晚上十点一个朋友在群里问不会后端能不能做个网页送给女朋友当礼物。答案是可以而且比想象中简单。这套「送给女朋友的精美网页」是一份纯静态成品打开链接先是一段打字机开场白往下滚是照片墙和你们的时间轴中间穿插花瓣飘落和一段背景音乐最后一屏摆两个按钮「愿意」和「再想想」。整份代码只有 HTML、CSS、JavaScript不需要服务器、也不需要数据库改文案换图片就能变成你自己的版本。适合两类人完全没写过前端、想照着改改就发出去的新手和接过类似表白页需求、想看看滚动动画与灯箱怎么组织的老手。2. 技术选型与工程结构一套纯静态页面怎么搭起来2.1 为什么这类页面不该上框架先说我判断的标准状态复杂度。整个页面跑起来真正需要记住的状态只有四个——当前停在第几屏、灯箱有没有打开、背景音乐在不在播、那个「再想想」按钮被点了几次。没有列表渲染、没有表单校验、没有接口轮询这种规模引一个前端框架进来收益基本为零代价却很实在多一层构建链路、多一个 node_modules、多一次打包最后产物体积还要多出几十到上百 KB。首屏体积在这个场景里特别要命。字体、图片、背景音乐已经占了不少带宽一个框架的运行时塞进去等于把「第一句话出现」的时间往后推。原生 HTML CSS JavaScript 压缩后这套页面的逻辑部分通常在 3060KB 之间剩下的全留给照片和音频这才是一个能发给别人、对方点开就出画面的页面该有的分配。动画也一样。CSS 的transition和animation负责静态入场IntersectionObserver负责触发时机花瓣用 canvas 手写全程不引第三方动画库。原因很直接滚动动画库通常会在初始化时遍历一遍所有元素并挂监听照片墙放到三十张以上时反而比手写更卡。当然如果你后面想加留言板、访问统计、多套主题切换那就该老老实实上框架接一个轻后端别硬扛。2.2 目录结构与文件职责目录按「改内容的人」和「改逻辑的人」分开。图片音频单独一个文件夹方便你直接把旧照片拖进去覆盖样式拆成基础重置、主样式、动画三份改配色只动main.css。love-page/ ├── index.html 页面结构所有文案的骨架都在这里 ├── css/ │ ├── reset.css 清掉默认边距、列表样式 │ ├── main.css 配色、排版、布局 —— 改主题只动这个 │ └── animation.css 入场、滚动、灯箱的过渡效果 ├── js/ │ ├── main.js 统一入口负责初始化和串模块 │ ├── typewriter.js 首屏打字机 │ ├── petals.js 背景花瓣 canvas │ ├── gallery.js 照片墙懒加载 灯箱 │ └── timeline.js 时间轴滚动进场 ├── assets/ │ ├── img/ 压缩后的照片命名建议 01.webp、02.webp │ ├── audio/bgm.mp3 背景音乐 │ └── favicon.ico 浏览器标签页图标 └── README.md 写清楚「改哪几个文件就能换成你的」文件职责改动频率index.html文案、章节顺序、照片数量高几乎每次改内容都动它css/main.css主色、字号、间距中换主题时动js/typewriter.js打字速度、停顿、光标低调手感时才动js/gallery.js懒加载提前量、灯箱行为低assets/img/照片本体高新手最容易犯的错是直接把照片丢进根目录然后写img src1.jpg十张之后文件夹就乱了。一开始就按assets/img/01.webp编号后面批量替换才省事。2.3 从零跑起来本地起服务的三种方式不要双击index.html用file://协议在本地开发和最终验证。file://下的音频加载、部分浏览器的 canvas 读取、以及一些相对路径解析行为都跟真实环境不一致你会遇到「本地好好的发出去就崩」的玄学问题。# 方式一Python 自带的静态服务器装了 Python 就有端口挑个不冲突的 cd love-page python3 -m http.server 5173 # 然后浏览器打开 http://localhost:5173 # 方式二有 Node 环境的话用 npx 临时起一个不污染全局 npx serve . -l 5173 # 方式三真机预览手机和电脑连同一个 Wi-Fi # 先查本机内网 IP # macOS ipconfig getifaddr en0 # Linux hostname -I # Windows ipconfig # 查到类似 192.168.1.23 之后手机浏览器访问 http://192.168.1.23:5173端口参数5173只是个习惯撞了换成8080、3000都行改完记得两处一致。真机预览这一步别省这个页面百分之八十的观众是拿手机点开的电脑上滚得再顺手机上100vh多出一截、按钮被地址栏顶出屏幕的情况太常见了。3. 核心模块拆解打字机、花瓣、照片墙、时间轴与按钮3.1 首屏打字机把开场白拆成一个字一个字说开场白是整个页面的第一印象直接p一整段丢上去太生硬。打字机效果的核心不是「逐字显示」这个动作本身而是节奏——标点后停顿长一点读起来才像有人在说话。// js/typewriter.js export function typewriter(el, text, options {}) { const { speed 90, // 每个字的间隔毫秒中文 80~120 手感最好 startDelay 300, // 开场留白等首屏背景铺完再开始 onDone () {}, } options; el.textContent ; el.classList.add(is-typing); // 这个 class 负责显示闪烁光标 // 用 Array.from 拆字符而不是 text[i] // 否则 emoji 和部分生僻字会被截成半个码元显示成乱码方块 const chars Array.from(text); let i 0; let timer null; const step () { if (i chars.length) { el.classList.remove(is-typing); onDone(); return; } const ch chars[i]; el.textContent ch; // 标点后多停 4 倍时长模拟换气 const pause /[。、…—]/.test(ch) ? speed * 4 : speed; timer setTimeout(step, pause); }; timer setTimeout(step, startDelay); // 返回取消函数切屏或页面隐藏时务必调用否则定时器会一直在后台跑 return () clearTimeout(timer); }参数里speed和startDelay是唯一需要你调的onDone一般用来在文案打完后再淡入「往下滑」的箭头避免两个动画抢注意力。Array.from那行是血泪经验直接下标取值在含 emoji 的文案上一定会翻车。调用侧放在入口文件里注意拿到取消函数并保存// js/main.js import { typewriter } from ./typewriter.js; const cancel typewriter( document.querySelector(.intro-text), 有些话想换个方式讲给你听。, { speed: 100, onDone: () document.querySelector(.scroll-hint).classList.add(show) } ); // 页面切到后台就停回来再重新开一轮 document.addEventListener(visibilitychange, () { if (document.hidden) cancel(); });3.2 背景花瓣粒子canvas 只干一件事花瓣层是纯装饰不参与任何交互所以给它pointer-events: none画在z-index: 0内容层压在上面。每片花瓣只维护自己的位置、速度、旋转和透明度落到底部就回收复用不新建对象避免频繁触发垃圾回收造成掉帧。// js/petals.js export function initPetals(canvas, count 40) { const ctx canvas.getContext(2d); let raf null; function resize() { // devicePixelRatio 上限压到 2高分屏按 3 倍渲染粒子一多就掉帧 const dpr Math.min(window.devicePixelRatio || 1, 2); canvas.width canvas.clientWidth * dpr; canvas.height canvas.clientHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后按 CSS 像素坐标画就行 } function create(seedTop false) { const w canvas.clientWidth || window.innerWidth; const h canvas.clientHeight || window.innerHeight; return { x: Math.random() * w, y: seedTop ? -20 - Math.random() * 60 : Math.random() * h, // 首次铺满别让人干等 r: 3 Math.random() * 5, // 半径控制花瓣大小 vy: 0.3 Math.random() * 0.7, // 下落速度越大越快 vx: -0.25 Math.random() * 0.5, // 横向漂移正负混用才有风感 rot: Math.random() * Math.PI, vr: -0.02 Math.random() * 0.04, alpha: 0.35 Math.random() * 0.4, }; } let petals Array.from({ length: count }, () create()); function draw() { ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); for (const p of petals) { p.x p.vx; p.y p.vy; p.rot p.vr; if (p.y canvas.clientHeight 20) Object.assign(p, create(true)); // 回收复用 ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.globalAlpha p.alpha; ctx.fillStyle #f7c8d4; ctx.beginPath(); ctx.ellipse(0, 0, p.r, p.r * 0.6, 0, 0, Math.PI * 2); // 压扁的椭圆更像花瓣 ctx.fill(); ctx.restore(); } raf requestAnimationFrame(draw); } resize(); window.addEventListener(resize, resize); draw(); return { stop: () cancelAnimationFrame(raf) }; }count桌面上 4050 够用手机上建议降到 2430中低端机开 60 片就会明显拖慢滚动。ctx.ellipse那两个半径比控制花瓣胖瘦想要更像桃花就把纵向比例调到 0.5 左右。页面切到后台时记得调stop()不然一个看不见的标签页还在满帧跑动画。3.3 照片墙懒加载加灯箱别一次把几十张图全下下来照片是这个页面体积的大头也是最容易卡的地方。做法是img上不写src只写>!-- index.html 里的照片墙结构 -- div classgallery figure classphoto style--ratio: 3 / 4 !-- src 故意留空靠>// js/gallery.js const grid document.querySelector(.gallery); const io new IntersectionObserver((entries) { entries.forEach((entry) { if (!entry.isIntersecting) return; const img entry.target; img.src img.dataset.src; // 这时才真正发起请求 img.removeAttribute(data-src); io.unobserve(img); // 加载过就别再观察了 }); }, { rootMargin: 200px 0px }); // 提前 200px 预加载滚到时不出现白块 grid.querySelectorAll(img[data-src]).forEach((img) io.observe(img)); // 灯箱用事件委托以后加删照片都不用重新绑事件 grid.addEventListener(click, (e) { const img e.target.closest(img); if (!img) return; openLightbox(img.currentSrc || img.src, img.alt); }); function openLightbox(src, alt) { const box document.querySelector(#lightbox); const pic box.querySelector(img); pic.src src; pic.alt alt || ; box.classList.add(is-open); document.body.style.overflow hidden; // 锁住底层否则手机上背景会跟着滑 } function closeLightbox() { const box document.querySelector(#lightbox); box.classList.remove(is-open); document.body.style.overflow ; } document.querySelector(#lightbox).addEventListener(click, closeLightbox); document.addEventListener(keydown, (e) { if (e.key Escape) closeLightbox(); });rootMargin的200px是提前量网络慢的话调到400pxloadinglazy是浏览器原生懒加载跟自定义的>/* css/animation.css */ .timeline-item { opacity: 0; transform: translateY(32px); transition: opacity .6s ease, transform .6s cubic-bezier(.22, .61, .36, 1); transition-delay: calc(var(--i, 0) * 90ms); /* 逐条错开别一起蹦出来 */ } .timeline-item.is-visible { opacity: 1; transform: translateY(0); }// js/timeline.js const items document.querySelectorAll(.timeline-item); // 每组最多错开 4 档否则第 10 条要等快 1 秒才出现 items.forEach((el, i) el.style.setProperty(--i, i % 4)); const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (!entry.isIntersecting) return; entry.target.classList.add(is-visible); observer.unobserve(entry.target); // 只播一次来回滚不要反复闪 }); }, { threshold: 0.25 }); // 露出 25% 才算进场 items.forEach((el) observer.observe(el));threshold调到 0.1 会让动画更早触发适合条目密集的段落调到 0.5 则要看到一半才动适合大图卡片。--i那个交错延迟如果条目太多一定要取模不然越往后等待越久观感反而拖沓。3.5 「愿意 / 再想想」按钮状态要设计不要靠随机躲避这一屏通常决定整个页面的成败。见过不少版本让「再想想」按钮在鼠标靠近时随机跑位电脑上看着有趣手机上没有 hover一跑就直接跑到屏幕外对方根本点不到——这不是浪漫是交互事故。我更推荐「拒绝按钮逐级弱化」的做法文案换、尺寸缩、透明度降但始终留一个能点到的位置最后才优雅退场。// js/main.js 片段 const noBtn document.querySelector(#btn-no); const yesBtn document.querySelector(#btn-yes); const noTexts [再想想, 真的吗, 再考虑一下, 不许反悔哦]; let noCount 0; noBtn.addEventListener(click, () { noCount 1; noBtn.textContent noTexts[Math.min(noCount, noTexts.length - 1)]; const scale Math.max(0.55, 1 - noCount * 0.12); // 最小缩到 0.55别缩没了 noBtn.style.transform scale(${scale}); if (noCount 3) { noBtn.style.opacity 0.3; noBtn.style.pointerEvents none; // 到次数才真正禁点 } }); yesBtn.addEventListener(click, () { document.querySelector(.final-screen).classList.add(is-celebrating); // 这里可以接撒花粒子、播放一段音效或者跳到一个隐藏的「彩蛋」章节 });noTexts数组就是文案池改这里等于改整个彩蛋的调性scale的下限0.55是保证还能看清字pointerEvents none比直接display: none温和按钮还在原地淡下去视觉上有个交代不会突然消失让人一愣。4. 交互串联与移动端适配从点开链接到最后一屏4.1 分屏滚动scroll-snap 比手写整屏切换省事整屏切换自己算滚动距离容易出现「滑一半卡住」的问题。CSS 的scroll-snap交给浏览器处理手指甩一下自动吸附到下一屏惯性自然。/* css/main.css */ .scroller { height: 100dvh; /* 新单位跟着可视区域走不含浏览器地址栏 */ height: 100vh; /* 兜底老浏览器不认 dvh 就用这行 */ overflow-y: auto; scroll-snap-type: y mandatory; /* mandatory 强制吸附想要更松用 proximity */ scroll-behavior: smooth; -webkit-overflow-scrolling: touch; } .page { height: 100dvh; scroll-snap-align: start; /* 每屏顶边对齐 */ scroll-snap-stop: always; /* 一次只翻一屏防止一甩跳过好几屏 */ }100dvh和100vh必须写两行顺序不能反后写的会覆盖前面的——所以要把dvh放前面、vh放后面让不支持的浏览器回退到vh。滚动容器千万别直接用body某些浏览器上body上的 snap 会失效套一层.scroller最稳。scroll-snap-stop: always这行是防止快速滑动一下跳过三四屏对内容有节奏要求的页面很重要。4.2 背景音乐自动播放必须让用户先动一下手指浏览器普遍禁止在没有用户手势的情况下自动播放音频这不是 bug别去跟它较劲。正确做法是放一个明显的音乐开关或者在第一屏加一句「点任意处开始」。// js/main.js 片段 const bgm document.querySelector(#bgm); const musicBtn document.querySelector(#music-toggle); async function playBgm() { try { bgm.volume 0.35; // 别一上来满音量戴着耳机的人会记你一辈子 bgm.loop true; await bgm.play(); musicBtn.classList.add(is-playing); } catch (err) { // 部分机型、低电量模式会直接拒绝静默失败就行别弹 alert 破坏气氛 console.warn(背景音乐未能播放, err.name); } } musicBtn.addEventListener(click, () { if (bgm.paused) playBgm(); else { bgm.pause(); musicBtn.classList.remove(is-playing); } });volume我一般锁在 0.30.4人声或钢琴曲在这个值上刚好垫底不抢文案。audio标签上加preloadnone让音频等到用户真的点了才开始下载否则一首 3MB 的歌会把首屏图片的带宽挤掉。4.3 移动端适配的四件小事第一件是把viewport写对缺了它整页会按 980px 渲染再缩放字小得没法看。第二件是安全区全面屏底部有一条横条最后一屏的按钮如果不加env(safe-area-inset-bottom)很容易被压住。第三件是点击高亮安卓上点按钮会闪一层灰蓝把-webkit-tap-highlight-color设成透明就安静了。第四件是图片容器固定比例避免加载过程中整页跳动。/* css/main.css */ .page { padding-bottom: env(safe-area-inset-bottom, 0px); /* 全面屏底部横条避让 */ } * { -webkit-tap-highlight-color: transparent; /* 去掉点击灰块 */ } .photo { aspect-ratio: var(--ratio, 1 / 1); /* 事先占好位置图片到了不顶页 */ overflow: hidden; border-radius: 12px; } .photo img { width: 100%; height: 100%; object-fit: cover; /* 裁切填充别拉伸变形 */ }aspect-ratio在老一点的浏览器上不生效可以用padding-top: 133%这类百分比技巧兜底写法麻烦但兼容性更好。字号统一用rem并配合根字号随视口微调不要用px写死否则在小屏手机上标题会横着溢出。4.4 体积预算与上线把首屏压到 1MB 以内分享出去的链接对方大概率是在移动网络下点开的。首屏超过 2MB等待时的白屏会把氛围全毁掉。下面是我给这类页面定的预算资源建议上限处理方式照片单张200KB长边压到 12001600转 webp质量 7580照片总量3MB分屏懒加载首屏只放 12 张背景音乐2MB128kbps 的 mp3截取 6090 秒循环字体0直接用系统字体栈别引中文 webfontJS CSS100KB原生写不引框架压缩完就是上传。把整个love-page目录的内容注意是目录里的文件不是目录本身放到静态托管服务的根目录确保index.html在根路径下能被直接访问。上传后一定用手机流量、关掉 Wi-Fi从聊天窗口里点一次链接从头滚到尾这五分鐘能把大部分问题暴露出来。5. 避坑与排查上线前后最容易翻车的 5 处5.1 路径与白屏类现象本地双击打开一切正常发出去图片全裂、样式全丢。原因写成了/assets/img/01.webp这种以斜杠开头的绝对路径本地file://下会被解析成磁盘根目录居然也能蒙对一旦部署到子路径比如https://example.com/love/就全部指向别处。解决全部改成相对路径assets/img/01.webpcss、js的引用同理link hrefcss/main.css前面不要加斜杠。现象发出去的链接点开是一片白等五六秒才出画面。原因首屏那张大图没压缩或者音频preload设成了auto在后台抢带宽。解决首屏图压到 150KB 以内音频加preloadnone再在body里放一个纯 CSS 的加载层window.onload之后淡出——哪怕只遮 0.8 秒观感也完全不同。5.2 动画与滚动类现象手机上第一屏的「往下滑」箭头被顶到屏幕外怎么滚都看不到。原因用了height: 100vh而移动端浏览器地址栏展开时vh按最大高度算内容区实际更矮。解决加100dvh把dvh写在vh之前让不支持的浏览器回退同时把那个箭头改成position: absolute; bottom: calc(24px env(safe-area-inset-bottom))双保险。现象照片墙滚动时一块一块白图片「啪」地弹出来。原因懒加载的rootMargin太小或者没设图片要等进入视口才开始下载同时容器没有固定高度加载完就把下面的内容整体往下顶。解决rootMargin提到200px400px容器用aspect-ratio或padding-top占位img上再配一个浅色背景色当占位块。5.3 交互与缓存类现象背景音乐点了开关还是没声音换台手机又正常。原因播放调用发生在异步回调或者setTimeout里已经脱离了用户手势上下文被浏览器的自动播放策略拦掉。解决play()必须直接写在点击事件的同步调用栈里别包在await fetch()或setTimeout之后再调同时catch里不要alert安静降级把按钮状态改回「未播放」即可。现象改了文案重新上传自己刷新看到了新的朋友打开还是旧内容或者旧图裂了。原因浏览器和托管方的缓存策略按文件名命中index.html和main.css名字没变就继续用本地缓存。解决样式和脚本引用加版本参数比如main.css?v2图片换了内容就换文件名01.webp改成01b.webpindex.html本身在托管后台设成不缓存或短缓存。6. 二次开发把它改成只属于你们两个人的版本这份代码最好的用法不是直接发出去而是当成一个骨架往里面填自己的东西。我一般按这个顺序改从「一定会动」到「可能要动」排顺序改哪里具体做什么1index.html文案把开场白、时间轴每条的标题和描述换成真实的事别写「第一次见面」这种通用句2assets/img/按01.webp顺序替换同步改alt灯箱里会显示3css/main.css变量主色、圆角、字号一处改完全站生效4js/typewriter.jsspeed调到 100120中文慢一点更耐读5js/main.jsnoTexts文案池换成你们之间的梗想加新章节的话成本很低在.scroller里复制一个section classpage给它一个新 class然后在js/main.js的入口里按需初始化对应模块。比如加一个「我们的歌单」就用一个ul加几条带封面和一句回忆的条目滚动进场直接复用timeline.js的那套 class一行 JS 都不用新写。改造完必须验证三件事缺一不可。第一是本地用python3 -m http.server起服务把浏览器开发者工具的网络限速调到「慢速 3G」再刷一次看首屏多久出字——这一步最能暴露体积问题。第二是真机、关 Wi-Fi、用移动网络从聊天窗口点链接进去从头滚到尾把每个按钮都点一遍特别留意灯箱打开后背景会不会跟着滚。第三是把链接发给一个完全不知情的人什么都不说看他会不会自己往下滑——如果他停在第一屏说明「往下滑」的提示做得不够明显回去把箭头和文字提示加重。从那以后我每次做完这类页面都强制自己在真机弱网下从头到尾走一遍完整链路不管改的只是一个字还是一张图。这条习惯救过我很多次因为这种页面只有一次打开机会对方不会刷新第二遍。希望帮到你。本文还有配套的精品资源点击获取