AI 写前端之前:先掌握这些交互
市面上的 AI 写前端教程都在教你怎么写提示词,但没人告诉你——先懂交互,再用 AI。这份专题把 27 个最关键的交互术语挨个跑给你看,每条都有可交互 demo,帮你建立自己的交互判断力。
Step 01 · Hover · 鼠标悬停
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Hover Card</title> <style> :root { --bg: #faf8f3; --fg: #1a1815; --muted: #6b6760; --border: #e6e1d4; --card: #ffffff; --accent: #b9483d; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--fg); font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; display: grid; place-items: center; padding: 32px; } .stage { width: 100%; max-width: 320px; } .stage-label { font: 500 10.5px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.22em; color: var(--muted); text-transform: uppercase; margin-bottom: 14px; } .card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px 18px 16px; cursor: pointer; transition: transform 200ms cubic-bezier(0.2, 0, 0, 1), box-shadow 200ms cubic-bezier(0.2, 0, 0, 1), border-color 200ms ease; box-shadow: 0 1px 0 rgba(20, 18, 14, 0.02); } .card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px -8px rgba(20, 18, 14, 0.12), 0 2px 6px -2px rgba(20, 18, 14, 0.06); border-color: var(--accent); } .row { display: flex; align-items: center; gap: 10px; } .dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); flex: 0 0 auto; } .title { font-weight: 500; font-size: 14.5px; } .desc { margin: 8px 0 14px; color: var(--muted); font-size: 13px; line-height: 1.55; } .meta { display: flex; align-items: center; justify-content: space-between; font: 500 11px/1 ui-monospace, SFMono-Regular, monospace; color: var(--muted); letter-spacing: 0.04em; } .arrow { opacity: 0; transform: translateX(-4px); transition: opacity 200ms ease, transform 200ms ease; } .card:hover .arrow { opacity: 1; transform: translateX(0); } </style> </head> <body> <div class="stage"> <div class="stage-label">Hover the card</div> <article class="card" tabindex="0"> <div class="row"> <span class="dot" aria-hidden="true"></span> <h3 class="title">Atlas Migration</h3> </div> <p class="desc">迁移用户数据到新的存储集群,预计 4 月底完成。</p> <div class="meta"> <span>UPDATED · 2D AGO</span> <span class="arrow" aria-hidden="true">→</span> </div> </article> </div> </body></html>很多人用 AI 写前端时,最大的障碍不是 AI 不会写代码,而是你说不清楚。
你说「做得高级一点」,AI 收到的是一团模糊。它的解决方案是加渐变、加阴影、加毛玻璃、加弹跳动画——最后页面看起来像「产品经理梦里的 SaaS 官网」混着「初学者第一次学 CSS」。
问题不在 AI 不努力,在你把顺序搞反了:
不是先学会提示词再让 AI 写,而是先懂交互,再用 AI。
所以这份专题不想教你怎么调教 AI,它只想做一件事:
让你不写代码也能建立对前端交互的掌握,然后——再去用 AI。
每条目左侧是一个可交互 demo(点 Source 切到源码看实现),右侧是定义、适用场景、和直接能拷给 AI 的提示词模板。
下面开始。先从最基础的视觉反馈说起。
1 · Hover:鼠标悬停效果
Hover 是最基础的视觉反馈:鼠标移到元素上,元素发生轻微变化。它告诉用户「这里能点」。
常见场景:按钮、卡片、导航菜单、商品列表、操作入口。
常见效果:背景色变深、轻微上浮 + 阴影增强、图片放大、显示隐藏的操作按钮。
不要只说「卡片加点交互感」——「交互感」是虚词。把变化讲清楚:
为卡片添加 hover 效果:鼠标悬停时卡片向上位移 4px,阴影增强,过渡时间 200ms,动画要自然克制。同时显示原本隐藏的右上角操作按钮。注意 hover 在触屏上不存在,移动端要给一个等价的 active 态。
Step 02 · Focus · 输入聚焦
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Focus State</title> <style> :root { --bg: #faf8f3; --fg: #1a1815; --muted: #6b6760; --border: #d8d3c5; --card: #ffffff; --ring: #5b8def; --error: #c34a3b; --accent: #b9483d; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--fg); font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; display: grid; place-items: center; padding: 32px; } .stage { width: 100%; max-width: 320px; display: grid; gap: 18px; } .stage-label { font: 500 10.5px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.22em; color: var(--muted); text-transform: uppercase; } .field { display: grid; gap: 6px; } label { font-size: 12px; color: var(--muted); font-weight: 500; letter-spacing: 0.02em; } input { appearance: none; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; font: inherit; color: var(--fg); outline: none; transition: border-color 160ms ease, box-shadow 160ms ease; } input::placeholder { color: color-mix(in oklab, var(--muted) 70%, transparent); } input:hover { border-color: color-mix(in oklab, var(--border) 50%, var(--fg)); } input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent); } .field.error input { border-color: var(--error); } .field.error input:focus { box-shadow: 0 0 0 3px color-mix(in oklab, var(--error) 18%, transparent); } .hint { font-size: 12px; color: var(--muted); min-height: 1em; } .field.error .hint { color: var(--error); } </style> </head> <body> <div class="stage"> <div class="stage-label">Click to focus</div> <div class="field"> <label for="email">EMAIL</label> <input id="email" type="email" placeholder="you@example.com" autocomplete="off" /> <div class="hint">输入框 focus 时边框变蓝并出现 focus ring。</div> </div> <div class="field error"> <label for="email-bad">EMAIL(校验失败示例)</label> <input id="email-bad" type="email" value="not-an-email" autocomplete="off" /> <div class="hint">请输入有效的邮箱地址。</div> </div> </div> </body></html>2 · Focus:聚焦状态
Focus 出现在输入框、搜索框、表单控件上。它让用户明确知道「我现在正在编辑这个字段」,同时也是无障碍的关键——键盘用户全靠 focus 导航。
常见效果:边框变色、出现 focus ring(轻微外发光)、label 上移、显示辅助文案。
很多 AI 生成的表单看起来像半成品,就是因为只写了默认状态,没处理 focus / error / disabled。把这些一起喂给 AI:
输入框需要明确的 focus 状态:聚焦时边框变蓝,并出现 3px 半透明蓝色 focus ring;失焦后恢复。校验失败时边框变红,下方显示错误文案,错误状态保留用户输入。focus ring 不是「视觉污染」,是基础设施。不要让 AI 用 outline: none 简单粗暴地干掉它。
Step 03 · Pressed · 按下反馈
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Pressed State</title> <style> :root { --bg: #faf8f3; --fg: #1a1815; --muted: #6b6760; --border: #d8d3c5; --accent: #b9483d; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--fg); font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; display: grid; place-items: center; padding: 32px; } .stage { display: grid; gap: 18px; place-items: center; } .stage-label { font: 500 10.5px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.22em; color: var(--muted); text-transform: uppercase; } .row { display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; } .btn { appearance: none; cursor: pointer; font: inherit; font-weight: 500; padding: 10px 18px; border-radius: 8px; border: 1px solid var(--fg); background: var(--fg); color: #faf8f3; transition: transform 100ms cubic-bezier(0.2, 0, 0, 1), background-color 120ms ease; user-select: none; } .btn:hover { background: #2a2722; } .btn:active { transform: scale(0.97); background: #000; } .btn.ghost { background: transparent; color: var(--fg); } .btn.ghost:hover { background: rgba(20, 18, 14, 0.05); } .btn.ghost:active { transform: scale(0.97); background: rgba(20, 18, 14, 0.1); } .hint { font-size: 12px; color: var(--muted); text-align: center; max-width: 240px; } </style> </head> <body> <div class="stage"> <div class="stage-label">Press & hold</div> <div class="row"> <button class="btn">Primary</button> <button class="btn ghost">Ghost</button> </div> <p class="hint">按住时按钮缩到 0.97,模拟物理按下感。</p> </div> </body></html>3 · Pressed / Active:按下状态
Pressed 是用户按下按钮的瞬间反馈。细节很小,但缺了它,按钮就「死」了——用户不确定自己到底点上了没有。
常见效果:按钮缩小到 0.96–0.98、背景色加深、阴影减弱、模拟物理按压。
按钮按下时缩到 0.97 倍,背景色略微加深;松开后用 100ms 过渡恢复。不要做夸张的弹跳或跳色。记住:按钮不是蹦床。pressed 反馈的尺度是「能感觉到」,不是「看得见」。一旦视觉上明显,就过头了。
Step 04 · Transition · 状态过渡
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Transition</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:24px; } .stage { display:grid; gap:12px; width:100%; max-width:340px; } .row { display:grid; grid-template-columns:1fr 1fr; gap:10px; } .demo-box { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:16px; text-align:center; cursor:pointer; } .demo-box .title { font-size:11px; font-weight:600; color:var(--muted); margin-bottom:10px; } .demo-box .preview { height:56px; display:grid; place-items:center; } /* color transition */ .color-swatch { width:40px; height:40px; border-radius:8px; background:#bdb6a7; transition:background 300ms cubic-bezier(0.2,0,0,1); } .stage.active .color-swatch { background:var(--accent); } /* scale transition */ .scale-box { width:36px; height:36px; border-radius:6px; background:var(--accent); transition:transform 300ms cubic-bezier(0.2,0,0,1); } .stage.active .scale-box { transform:scale(1.5); } /* translate transition */ .move-dot { width:20px; height:20px; border-radius:50%; background:var(--accent); transition:transform 300ms cubic-bezier(0.2,0,0,1); } .stage.active .move-dot { transform:translateX(40px); } /* border-radius transition */ .shape-box { width:36px; height:36px; background:var(--accent); transition:border-radius 300ms cubic-bezier(0.2,0,0,1); } .stage.active .shape-box { border-radius:50%; } .toggle-btn { appearance:none; border:1px solid var(--fg); background:var(--fg); color:var(--bg); border-radius:8px; padding:9px 0; cursor:pointer; font:inherit; font-weight:500; text-align:center; transition:opacity 200ms; } .toggle-btn:hover { opacity:0.85; } .note { font-size:11px; color:var(--muted); text-align:center; } </style> </head> <body> <div class="stage" id="stage"> <div class="row"> <div class="demo-box" data-name="color"> <div class="title">背景色 300ms</div> <div class="preview"><div class="color-swatch"></div></div> </div> <div class="demo-box" data-name="scale"> <div class="title">缩放 300ms</div> <div class="preview"><div class="scale-box"></div></div> </div> </div> <div class="row"> <div class="demo-box" data-name="move"> <div class="title">位移 300ms</div> <div class="preview"><div class="move-dot"></div></div> </div> <div class="demo-box" data-name="shape"> <div class="title">圆角 300ms</div> <div class="preview"><div class="shape-box"></div></div> </div> </div> <button class="toggle-btn" id="toggle" type="button">切换状态</button> <div class="note">点击按钮,观察四种过渡效果</div> </div> <script> (function () { var stage = document.getElementById('stage'); document.getElementById('toggle').addEventListener('click', function () { stage.classList.toggle('active'); }); })(); </script> </body></html>4 · Transition:过渡动画
Transition 是状态变化时的过渡效果,比如透明度变化、颜色变化、高度变化、位置变化、缩放变化。
没有 transition,页面会显得生硬;但 transition 太多,又会显得油腻。所以你可以给 AI 一个明确约束:
所有状态变化都使用自然的 transition,时长控制在 150ms 到 250ms,不要使用夸张弹跳、旋转、闪烁等效果。如果是后台系统,可以再补一句:
动效以提升反馈为主,不要做装饰性动画。很多 AI 一听「动效」,就容易开始表演。但产品里的动效不是为了表演,而是为了反馈。
Step 05 · Loading · 提交反馈
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Loading State</title> <style> :root { --bg: #faf8f3; --fg: #1a1815; --muted: #6b6760; --border: #d8d3c5; --ok: #2f8f5a; --accent: #b9483d; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--fg); font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; display: grid; place-items: center; padding: 32px; } .stage { display: grid; gap: 16px; place-items: center; } .stage-label { font: 500 10.5px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.22em; color: var(--muted); text-transform: uppercase; } .btn { position: relative; appearance: none; cursor: pointer; font: inherit; font-weight: 500; padding: 10px 22px; min-width: 156px; border-radius: 8px; border: 1px solid var(--accent); background: var(--accent); color: #faf8f3; display: inline-flex; align-items: center; justify-content: center; gap: 8px; transition: background-color 120ms ease, opacity 160ms ease; user-select: none; } .btn:hover { background:#a63d35; } .btn[disabled] { cursor: not-allowed; opacity: 0.85; background: var(--accent); } .btn[data-state='success'] { background: var(--ok); border-color: var(--ok); } .spinner { width: 14px; height: 14px; border-radius: 999px; border: 2px solid rgba(250, 248, 243, 0.35); border-top-color: #faf8f3; animation: spin 0.7s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .check { width: 14px; height: 14px; display: inline-block; } .hint { font-size: 12px; color: var(--muted); text-align: center; max-width: 240px; } </style> </head> <body> <div class="stage"> <div class="stage-label">Click submit</div> <button class="btn" id="btn" type="button"> <span id="label">提交</span> </button> <p class="hint">点击后按钮禁用,文案变为「提交中...」并显示 spinner,1.6s 后切到 success 状态。</p> </div> <script> (function () { var btn = document.getElementById('btn'); var label = document.getElementById('label'); var state = 'idle'; function render() { if (state === 'idle') { btn.removeAttribute('data-state'); btn.removeAttribute('disabled'); btn.innerHTML = '<span id="label">提交</span>'; } else if (state === 'loading') { btn.setAttribute('data-state', 'loading'); btn.setAttribute('disabled', 'true'); btn.innerHTML = '<span class="spinner" aria-hidden="true"></span><span>提交中…</span>'; } else if (state === 'success') { btn.setAttribute('data-state', 'success'); btn.setAttribute('disabled', 'true'); btn.innerHTML = '<svg class="check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 8.5 6.5 12 13 4.5"/></svg><span>已提交</span>'; } } btn.addEventListener('click', function () { if (state !== 'idle') return; state = 'loading'; render(); setTimeout(function () { state = 'success'; render(); setTimeout(function () { state = 'idle'; render(); }, 1400); }, 1600); }); })(); </script> </body></html>5 · Loading:加载状态
很多新手用 AI 写前端只关注正常路径。但真实产品里,请求数据、提交表单、上传文件都需要 loading 状态——否则用户点击按钮后不知道系统有没有收到。
Loading 不是「转个圈」,它是一组协同动作:禁用按钮防止重复提交、改文案告知正在做什么、视觉上给一个进行中的指示。
提交按钮点击后进入 loading 状态:按钮禁用、左侧显示 spinner、文案从「提交」变为「提交中...」。请求成功后切到 success 态(绿色 + 对勾 + 「已提交」文案),1.5s 后恢复到默认态;失败时切到 error 态并显示错误文案。按钮自身就能承载完整的「idle → loading → success」状态机,比单独弹个 toast 更聚焦。
Step 06 · Progress · 进度指示
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Progress Bar</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; --success:#2e9d5e; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:24px; } .shell { width:100%; max-width:360px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:24px; } .section { margin-bottom:20px; } .section:last-child { margin-bottom:0; } .header { display:flex; justify-content:space-between; margin-bottom:6px; font-size:12px; } .bar { height:6px; background:#f2eee5; border-radius:3px; overflow:hidden; } .fill { height:100%; background:var(--accent); border-radius:3px; transition:width 300ms cubic-bezier(0.2,0,0,1); width:0%; } .fill.success { background:var(--success); } .fill.indeterminate { width:30%; animation:shimmer 1.4s ease-in-out infinite; } @keyframes shimmer { 0%{transform:translateX(-100%)} 100%{transform:translateX(400%)} } .row { display:flex; gap:8px; } .btn { flex:1; border:1px solid var(--border); background:var(--card); border-radius:8px; padding:8px 0; cursor:pointer; font:inherit; font-size:13px; color:var(--fg); text-align:center; } .btn:hover { background:#f2eee5; } .btn:disabled { opacity:0.35; cursor:default; } .btn.primary { background:var(--accent); border-color:var(--accent); color:#fff; } .btn.primary:hover { opacity:0.9; } </style> </head> <body> <div class="shell"> <div class="section"> <div class="header"><span>上传文件</span><span id="pct">0%</span></div> <div class="bar"><div class="fill" id="fill"></div></div> </div> <div class="section"> <div class="header"><span>页面加载</span><span>indeterminate</span></div> <div class="bar"><div class="fill indeterminate" id="indet"></div></div> </div> <div class="row"> <button class="btn primary" id="startBtn">开始上传</button> <button class="btn" id="resetBtn" disabled>重置</button> </div> </div> <script> (function () { var fill = document.getElementById('fill'); var pct = document.getElementById('pct'); var startBtn = document.getElementById('startBtn'); var resetBtn = document.getElementById('resetBtn'); var timer = null; var val = 0; var running = false; function start() { if (running) return; if (val >= 100) { val = 0; } running = true; startBtn.disabled = true; resetBtn.disabled = true; fill.classList.remove('success'); timer = setInterval(function () { val += Math.random() * 8 + 2; if (val >= 100) { val = 100; clearInterval(timer); timer = null; running = false; fill.classList.add('success'); startBtn.disabled = false; resetBtn.disabled = false; } fill.style.width = val + '%'; pct.textContent = Math.round(val) + '%'; }, 200); } function reset() { if (running) return; val = 0; fill.style.width = '0%'; fill.classList.remove('success'); pct.textContent = '0%'; } startBtn.addEventListener('click', start); resetBtn.addEventListener('click', reset); })(); </script> </body></html>6 · Progress Bar:进度指示
进度条用来展示一个操作的完成比例或加载进度,让用户知道系统正在工作。
常见场景:文件上传、页面加载、数据导出、批量操作进度、表单保存。
实现一个进度条组件:- 确定进度:根据百分比填充宽度,带平滑过渡动画- 显示当前百分比文字- indeterminate 不确定进度:使用条纹动画表示加载中- 完成后进度条颜色变为绿色(success 态)- 提供「开始」和「重置」按钮控制进度模拟进度条还有一个常被遗忘的细节:操作完成后要让进度状态明确停留(比如变绿、对勾),而不是直接消失。用户需要「看到」成功反馈。
Step 07 · Skeleton · 骨架屏
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Skeleton Loading</title> <style> :root { --bg: #faf8f3; --fg: #1a1815; --muted: #6b6760; --border: #e6e1d4; --card: #ffffff; --accent: #b9483d; --shimmer-1: #ece8dd; --shimmer-2: #f4f1e8; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--fg); font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; display: grid; place-items: center; padding: 32px; } .stage { width: 100%; max-width: 320px; } .stage-label { display: flex; justify-content: space-between; align-items: baseline; font: 500 10.5px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.22em; color: var(--muted); text-transform: uppercase; margin-bottom: 14px; } .reload { cursor: pointer; background: none; border: 0; font: inherit; color: var(--muted); text-transform: uppercase; letter-spacing: 0.22em; padding: 4px 0; } .reload:hover { color: var(--fg); } .list { display: grid; gap: 10px; } .row { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; display: grid; gap: 8px; } .skeleton .bar { height: 10px; border-radius: 4px; background: linear-gradient(90deg, var(--shimmer-1) 0%, var(--shimmer-2) 50%, var(--shimmer-1) 100%); background-size: 200% 100%; animation: shimmer 1.4s ease-in-out infinite; } .skeleton .bar.w-60 { width: 60%; } .skeleton .bar.w-90 { width: 90%; height: 8px; } .skeleton .bar.w-40 { width: 40%; height: 8px; } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } .real .title { font-size: 14px; font-weight: 500; } .real .desc { font-size: 12.5px; color: var(--muted); } .real .meta { font: 500 10.5px/1 ui-monospace, monospace; color: var(--muted); letter-spacing: 0.04em; } .row { transition: opacity 220ms ease; } .row[hidden] { display: none; } </style> </head> <body> <div class="stage"> <div class="stage-label"> <span>Loading list</span> <button class="reload" id="reload" type="button">Reload</button> </div> <div class="list" id="list"></div> </div> <script> (function () { var list = document.getElementById('list'); var reload = document.getElementById('reload'); var data = [ { title: 'Atlas Migration', desc: '迁移用户数据到新存储集群', meta: 'UPDATED · 2D AGO' }, { title: 'Billing Refactor', desc: '订阅与发票模块重构', meta: 'UPDATED · 5D AGO' }, { title: 'Mobile Onboarding', desc: '新版引导流程改造', meta: 'UPDATED · 1W AGO' }, ]; function renderSkeleton() { list.innerHTML = ''; for (var i = 0; i < 3; i++) { var row = document.createElement('div'); row.className = 'row skeleton'; row.innerHTML = '<div class="bar w-60"></div><div class="bar w-90"></div><div class="bar w-40"></div>'; list.appendChild(row); } } function renderReal() { list.innerHTML = ''; data.forEach(function (item) { var row = document.createElement('div'); row.className = 'row real'; row.innerHTML = '<div class="title">' + item.title + '</div>' + '<div class="desc">' + item.desc + '</div>' + '<div class="meta">' + item.meta + '</div>'; list.appendChild(row); }); } function cycle() { renderSkeleton(); setTimeout(renderReal, 1800); } reload.addEventListener('click', cycle); cycle(); })(); </script> </body></html>7 · Skeleton:骨架屏
Skeleton 是另一种 loading 形态。不是简单地转个圈,而是用灰色占位块预先模拟真实页面结构。读者在数据回来前就能感知到「这里有 3 张卡片,每张卡有标题和描述」。
适合场景:列表页、卡片流、详情页、信息流。简言之,结构稳定、占位有意义的地方都比 spinner 强。
列表数据加载时使用 skeleton:占位结构需要接近真实卡片布局——标题 60% 宽、描述 90% 宽两行、底部 meta 40% 宽;用 1.4s 的 shimmer 动画提示加载中。数据回来后平滑替换为真实内容。骨架屏的灵魂是「形状对得上」。如果 skeleton 和真实内容布局差太远,切换时会跳,反而比 spinner 更糟。
Step 08 · Empty State · 空状态
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Empty State</title> <style> :root { --bg: #faf8f3; --fg: #1a1815; --muted: #6b6760; --border: #d8d3c5; --card: #ffffff; --accent: #b9483d; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--fg); font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; display: grid; place-items: center; padding: 32px; } .stage { width: 100%; max-width: 340px; } .panel { border: 1px dashed var(--border); border-radius: 12px; background: var(--card); padding: 36px 24px 28px; text-align: center; display: grid; gap: 14px; place-items: center; } .icon { width: 56px; height: 56px; display: grid; place-items: center; border-radius: 14px; background: #f0ebde; color: var(--muted); } .title { font-size: 15px; font-weight: 500; margin: 0; } .desc { font-size: 13px; color: var(--muted); margin: 0; max-width: 240px; line-height: 1.6; } .cta { margin-top: 6px; appearance: none; cursor: pointer; font: inherit; font-weight: 500; padding: 8px 16px; border-radius: 8px; border: 1px solid var(--accent); background: var(--accent); color: #faf8f3; transition: background-color 120ms ease; } .cta:hover { background: #a63d35; } </style> </head> <body> <div class="stage"> <div class="panel"> <div class="icon" aria-hidden="true"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <rect x="3" y="5" width="18" height="14" rx="2" /> <path d="M3 10h18" /> <path d="M9 15h6" /> </svg> </div> <h3 class="title">还没有项目</h3> <p class="desc">创建第一个项目,把任务、文档和成员组织在一起。</p> <button class="cta" type="button">+ 创建项目</button> </div> </div> </body></html>8 · Empty State:空状态
空状态是最容易被忽略的交互。用户第一次进入项目列表、还没创建任何项目时,页面不能只是空白——空白会让人怀疑:加载失败了?没权限?系统坏了?
好的空状态应该回答两件事:当前为什么是空的,以及下一步可以做什么。
当列表为空时显示 empty state:- 一个简洁的图标(线性风格,避免插画过度)- 一句说明文案,例如「还没有项目」- 一句副文案告诉用户这个页面是做什么的- 一个主 CTA 按钮,引导用户去创建搜索无结果场景:文案改为「没有找到匹配结果」,CTA 改为「清空筛选条件」。空状态不是边角料。新用户的第一印象,就从这里开始。
Step 09 · Error State · 错误状态
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Error State</title> <style> :root { --bg: #faf8f3; --fg: #1a1815; --muted: #6b6760; --border: #d8d3c5; --card: #ffffff; --accent: #b9483d; --danger: #b9473d; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--fg); font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; display: grid; place-items: center; padding: 32px; } .stage { width: 100%; max-width: 360px; } .panel { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 22px; display: grid; gap: 14px; box-shadow: 0 1px 0 rgba(20, 18, 14, 0.03); } .icon { width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; background: #f5e4df; color: var(--danger); } h3 { margin: 0; font-size: 15px; font-weight: 600; } p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.6; } button { justify-self: start; appearance: none; border: 1px solid var(--fg); background: var(--fg); color: var(--bg); border-radius: 8px; padding: 8px 14px; font: inherit; font-weight: 500; cursor: pointer; transition: transform 120ms ease, background-color 160ms ease, opacity 160ms ease; } button:hover { background: #2a2722; } button:active { transform: scale(0.98); } button[disabled] { opacity: 0.75; cursor: wait; } .status { font: 500 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.14em; color: var(--muted); text-transform: uppercase; } </style> </head> <body> <section class="stage"> <div class="panel" id="panel"> <div class="status">Request failed</div> <div class="icon" aria-hidden="true"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"> <path d="M12 8v5" /><path d="M12 17h.01" /><path d="M10.3 4.4 2.7 17.5A2 2 0 0 0 4.4 20h15.2a2 2 0 0 0 1.7-2.5L13.7 4.4a2 2 0 0 0-3.4 0Z" /> </svg> </div> <h3>数据加载失败</h3> <p>网络连接不稳定。保留当前页面结构,并给用户一个明确的重试入口。</p> <button id="retry" type="button">重新加载</button> </div> </section> <script> (function () { var retry = document.getElementById('retry'); retry.addEventListener('click', function () { retry.disabled = true; retry.textContent = '加载中...'; setTimeout(function () { retry.disabled = false; retry.textContent = '重新加载'; }, 1300); }); })(); </script> </body></html>9 · Error State:错误状态
Error state 是请求失败、权限不足、表单校验失败时的展示状态。
很多 AI 生成的页面最大的问题是:只处理成功,不处理失败。但真实世界里,请求会失败,网络会抖,接口会报错,用户会乱填。所以错误状态不是异常情况,错误状态是产品体验的一部分。
请求失败时显示 error state,不要让页面空白。展示错误说明和「重新加载」按钮,用户点击后重新请求数据。表单错误要更具体:
表单提交失败时,在对应字段下方显示错误文案,并保留用户已经输入的内容,不要清空表单。权限错误也不要只丢一个 403:
如果用户没有权限访问该页面,显示权限不足状态,说明原因,并提供返回首页按钮。Step 10 · Toast · 轻提示
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Toast</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; --ok:#2f8f5a; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--fg); font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; display: grid; place-items: center; padding: 32px; overflow: hidden; } .stage { display: grid; place-items: center; gap: 14px; } button { appearance: none; cursor: pointer; font: inherit; font-weight: 500; padding: 9px 16px; border-radius: 8px; border: 1px solid var(--fg); background: var(--fg); color: var(--bg); transition: transform 120ms ease, background-color 160ms ease; } button:hover { background: #2a2722; } button:active { transform: scale(0.98); } .hint { margin: 0; color: var(--muted); font-size: 12px; } .toast { position: fixed; top: 22px; right: 22px; width: min(300px, calc(100vw - 44px)); background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; display: flex; gap: 10px; align-items: flex-start; box-shadow: 0 16px 32px -20px rgba(20, 18, 14, 0.35); opacity: 0; transform: translateY(-8px); pointer-events: none; transition: opacity 180ms ease, transform 180ms ease; } .toast[data-open='true'] { opacity: 1; transform: translateY(0); } .dot { width: 18px; height: 18px; border-radius: 999px; background: #e0f0e6; color: var(--ok); display: grid; place-items: center; flex: 0 0 auto; } .title { font-weight: 600; font-size: 13px; } .desc { color: var(--muted); font-size: 12px; margin-top: 2px; } </style> </head> <body> <div class="stage"> <button id="copy" type="button">复制邀请链接</button> <p class="hint">轻量操作成功后,用 toast 给短反馈。</p> </div> <div class="toast" id="toast" role="status" aria-live="polite"> <span class="dot" aria-hidden="true"> <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 8.5 6.5 12 13 4.5"/></svg> </span> <div><div class="title">已复制到剪贴板</div><div class="desc">2 秒后自动消失,不打断当前流程。</div></div> </div> <script> (function () { var btn = document.getElementById('copy'); var toast = document.getElementById('toast'); var timer; btn.addEventListener('click', function () { clearTimeout(timer); toast.setAttribute('data-open', 'true'); timer = setTimeout(function () { toast.setAttribute('data-open', 'false'); }, 2000); }); })(); </script> </body></html>10 · Toast:轻提示
Toast 是一种轻量反馈,常用于保存成功、删除成功、复制成功、操作失败、网络异常。它通常出现在页面右上角、顶部或底部,几秒后自动消失。
操作成功后显示 toast 提示,位置在右上角,持续 2 秒后自动消失;失败时显示红色错误 toast,并保留明确的错误文案。复制按钮可以这样写:
用户点击复制按钮后,显示 toast:「已复制到剪贴板」,持续 2 秒后自动消失。Toast 适合轻提示,但不要什么都用 toast。删除项目、支付确认、重要配置变更这类不可逆操作,需要 modal 做二次确认。
Step 11 · Tooltip · 悬浮提示
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Tooltip</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:32px; } .field { width:300px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:18px; } .label { display:flex; align-items:center; gap:7px; font-weight:500; margin-bottom:9px; } .info { position:relative; width:18px; height:18px; border-radius:999px; border:1px solid var(--border); display:grid; place-items:center; color:var(--muted); cursor:help; font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace; } .tip { position:absolute; left:50%; bottom:calc(100% + 9px); transform:translate(-50%, 4px); width:max-content; max-width:220px; background:var(--fg); color:var(--bg); border-radius:8px; padding:7px 9px; font:500 12px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif; opacity:0; pointer-events:none; transition:opacity 150ms ease, transform 150ms ease; } .info:hover .tip, .info:focus-visible .tip { opacity:1; transform:translate(-50%,0); } .input { height:40px; border:1px solid var(--border); border-radius:8px; padding:0 11px; color:var(--muted); display:flex; align-items:center; } </style> </head> <body> <div class="field"> <div class="label"> API Key <span class="info" tabindex="0">i<span class="tip">只展示一次,请复制后妥善保存。</span></span> </div> <div class="input">sk_live_••••••••••••</div> </div> </body></html>11 · Tooltip:悬浮提示
Tooltip 是鼠标悬停时出现的小提示,适合解释一个图标、字段、按钮的含义。
为信息图标添加 tooltip,鼠标悬停时显示字段说明,位置在图标上方,内容不要超过一行。禁用按钮也可以用 tooltip 解释原因:
禁用按钮 hover 时显示 tooltip,解释为什么当前不可点击。Tooltip 适合短内容。不要把一大段说明塞进 tooltip。如果内容比较长,可以用 popover。
Step 12 · Popover · 气泡卡片
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Popover</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:32px; } .wrap { position:relative; } .trigger { appearance:none; cursor:pointer; font:inherit; font-weight:500; padding:9px 15px; border-radius:8px; border:1px solid var(--fg); background:var(--fg); color:var(--bg); } .panel { position:absolute; top:calc(100% + 10px); left:50%; width:260px; transform:translate(-50%, -4px); background:var(--card); border:1px solid var(--border); border-radius:12px; padding:14px; box-shadow:0 18px 36px -26px rgba(20,18,14,0.45); opacity:0; pointer-events:none; transition:opacity 160ms ease, transform 160ms ease; } .panel[data-open='true'] { opacity:1; transform:translate(-50%, 0); pointer-events:auto; } h3 { margin:0 0 7px; font-size:14px; } p { margin:0 0 12px; color:var(--muted); font-size:12.5px; line-height:1.6; } .filters { display:flex; flex-wrap:wrap; gap:6px; } .chip { border:1px solid var(--border); border-radius:999px; padding:5px 9px; background:var(--bg); color:var(--muted); font-size:12px; } </style> </head> <body> <div class="wrap"> <button class="trigger" id="trigger" type="button">筛选条件</button> <section class="panel" id="panel"> <h3>快速筛选</h3> <p>Popover 可以承载比 tooltip 更复杂的轻量内容,比如说明、链接或小表单。</p> <div class="filters"><span class="chip">全部</span><span class="chip">进行中</span><span class="chip">已归档</span></div> </section> </div> <script> (function () { var trigger = document.getElementById('trigger'); var panel = document.getElementById('panel'); trigger.addEventListener('click', function (event) { event.stopPropagation(); panel.setAttribute('data-open', panel.getAttribute('data-open') !== 'true' ? 'true' : 'false'); }); document.addEventListener('click', function () { panel.setAttribute('data-open', 'false'); }); })(); </script> </body></html>12 · Popover:气泡卡片
Popover 和 tooltip 有点像,但它能承载更多内容。Tooltip 通常是短文本,Popover 可以放说明、链接、小表单、快捷操作。
点击「更多信息」后显示 popover,内容包含一段说明和一个「查看详情」链接;点击外部区域时关闭。筛选面板可以这样说:
点击筛选按钮后显示 popover,里面包含状态筛选、时间范围筛选和重置按钮。Popover 适合轻量但不至于简单到 tooltip 的内容。
Step 13 · Dropdown · 下拉菜单
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Dropdown Menu</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; --danger:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:32px; } .card { width:280px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:16px; position:relative; box-shadow:0 1px 0 rgba(20,18,14,0.03); } .top { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; } h3 { margin:0; font-size:15px; } p { margin:6px 0 0; color:var(--muted); font-size:12px; } .more { width:32px; height:32px; border-radius:8px; border:1px solid var(--border); background:var(--bg); cursor:pointer; display:grid; place-items:center; color:var(--muted); transition:background-color 160ms ease; } .more:hover { background:#f0ebe1; } .more svg { display:block; } .menu { position:absolute; top:50px; right:14px; min-width:148px; background:var(--card); border:1px solid var(--border); border-radius:10px; padding:6px; box-shadow:0 14px 30px -22px rgba(20,18,14,0.4); opacity:0; transform:translateY(-4px); pointer-events:none; transition:opacity 150ms ease, transform 150ms ease; } .menu[data-open='true'] { opacity:1; transform:translateY(0); pointer-events:auto; } .item { width:100%; border:0; background:transparent; border-radius:7px; padding:8px 9px; text-align:left; cursor:pointer; font:inherit; font-size:13px; color:var(--fg); } .item:hover { background:#f4f0e8; } .item.danger { color:var(--danger); } </style> </head> <body> <article class="card"> <div class="top"> <div><h3>Project Card</h3><p>更多操作放进 dropdown,避免卡片拥挤。</p></div> <button class="more" id="more" type="button" aria-expanded="false" aria-label="更多操作"><svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg></button> </div> <div class="menu" id="menu"> <button class="item" type="button">编辑</button> <button class="item" type="button">复制</button> <button class="item danger" type="button">删除</button> </div> </article> <script> (function () { var more = document.getElementById('more'); var menu = document.getElementById('menu'); function setOpen(value) { menu.setAttribute('data-open', value ? 'true' : 'false'); more.setAttribute('aria-expanded', value ? 'true' : 'false'); } more.addEventListener('click', function (event) { event.stopPropagation(); setOpen(menu.getAttribute('data-open') !== 'true'); }); document.addEventListener('click', function () { setOpen(false); }); })(); </script> </body></html>13 · Dropdown:下拉菜单
Dropdown 是点击某个入口后出现的菜单。
常见场景:用户头像菜单、更多操作、选择器、排序条件、批量操作。
头像点击后显示 dropdown 菜单,包含个人设置、账单、退出登录三个选项;点击页面其他区域时自动关闭,下拉出现时添加 150ms 的淡入和位移动画。更多操作按钮可以这样说:
卡片右上角的更多按钮点击后显示 dropdown,包含「编辑 / 复制 / 删除」三个操作。删除操作使用危险色,并点击后打开确认 modal。Dropdown 的细节别漏:点击外部要关闭、菜单项要有 hover 状态、当前选中项最好高亮、危险操作要有区分。
Step 14 · Accordion · 折叠面板
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Accordion</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:32px; } .accordion { width:min(380px,100%); background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden; } .item + .item { border-top:1px solid var(--border); } .head { width:100%; border:0; background:transparent; padding:14px 16px; display:flex; justify-content:space-between; align-items:center; cursor:pointer; font:inherit; font-weight:500; color:var(--fg); text-align:left; } .head:hover { background:#f5f1e9; } .plus { color:var(--muted); transition:transform 180ms ease; } .item[data-open='true'] .plus { transform:rotate(45deg); } .body { display:grid; grid-template-rows:0fr; transition:grid-template-rows 200ms ease; } .item[data-open='true'] .body { grid-template-rows:1fr; } .inner { overflow:hidden; } p { margin:0; padding:0 16px 15px; color:var(--muted); font-size:13px; line-height:1.65; } </style> </head> <body> <div class="accordion"> <section class="item" data-open="true"><button class="head" type="button">什么时候用 accordion?<span class="plus">+</span></button><div class="body"><div class="inner"><p>适合 FAQ、设置分组、文档说明这类可以按需展开的内容。</p></div></div></section> <section class="item"><button class="head" type="button">可以同时展开多个吗?<span class="plus">+</span></button><div class="body"><div class="inner"><p>可以,但要在提示词里说清楚:单项展开还是多项独立展开。</p></div></div></section> <section class="item"><button class="head" type="button">什么时候不要用?<span class="plus">+</span></button><div class="body"><div class="inner"><p>如果内容本来就很关键,不要强行折叠起来增加寻找成本。</p></div></div></section> </div> <script> (function () { Array.prototype.forEach.call(document.querySelectorAll('.head'), function (head) { head.addEventListener('click', function () { var item = head.parentElement; Array.prototype.forEach.call(document.querySelectorAll('.item'), function (node) { if (node !== item) node.setAttribute('data-open', 'false'); }); item.setAttribute('data-open', item.getAttribute('data-open') !== 'true' ? 'true' : 'false'); }); }); })(); </script> </body></html>14 · Accordion:折叠面板
Accordion 是折叠面板,常见于 FAQ、文档说明、设置项分组。用户点击标题,内容展开;再次点击,内容收起。
FAQ 使用 accordion 展示。默认只展开第一项,点击其他问题时展开对应答案,并自动收起之前展开的项。如果允许多个展开:
设置页面使用 accordion 分组展示,每个分组可以独立展开或收起,多个分组可以同时展开。Accordion 的好处是节省空间,但不要滥用。如果内容本来就很重要,不应该强行折叠起来让用户自己找。
Step 15 · Tabs · 标签页
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Tabs</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:32px; } .panel { width:min(360px,100%); background:var(--card); border:1px solid var(--border); border-radius:12px; padding:14px; } .tabs { display:grid; grid-template-columns:repeat(3,1fr); gap:4px; background:#f0ebe1; border-radius:9px; padding:4px; margin-bottom:14px; } .tab { appearance:none; border:0; border-radius:7px; background:transparent; padding:7px 8px; cursor:pointer; font:inherit; font-size:13px; color:var(--muted); transition:background-color 150ms ease, color 150ms ease; } .tab[aria-selected='true'] { background:var(--card); color:var(--fg); box-shadow:0 1px 0 rgba(20,18,14,0.04); } .content { min-height:92px; border:1px solid var(--border); border-radius:10px; padding:14px; opacity:1; transition:opacity 150ms ease; } h3 { margin:0 0 7px; font-size:15px; } p { margin:0; color:var(--muted); font-size:13px; line-height:1.6; } </style> </head> <body> <section class="panel"> <div class="tabs" role="tablist"> <button class="tab" aria-selected="true" data-tab="overview" type="button">概览</button> <button class="tab" aria-selected="false" data-tab="members" type="button">成员</button> <button class="tab" aria-selected="false" data-tab="settings" type="button">设置</button> </div> <div class="content" id="content"><h3>项目概览</h3><p>Tabs 用来切换同一层级下的平级内容。</p></div> </section> <script> (function () { var copy = { overview: ['项目概览', 'Tabs 用来切换同一层级下的平级内容。'], members: ['成员管理', '切换时只替换内容区域,顶部结构保持稳定。'], settings: ['项目设置', '当前 tab 需要明确高亮,避免用户迷路。'] }; var content = document.getElementById('content'); Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (tab) { tab.addEventListener('click', function () { Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (item) { item.setAttribute('aria-selected', 'false'); }); tab.setAttribute('aria-selected', 'true'); content.style.opacity = '0'; setTimeout(function () { var value = copy[tab.dataset.tab]; content.innerHTML = '<h3>' + value[0] + '</h3><p>' + value[1] + '</p>'; content.style.opacity = '1'; }, 120); }); }); })(); </script> </body></html>15 · Tabs:标签页切换
Tabs 用来切换同一层级下的不同内容。
常见场景:概览 / 成员 / 设置、全部 / 进行中 / 已完成、基础信息 / 高级配置 / 日志。
页面顶部使用 tabs 切换内容,包含「概览 / 成员 / 设置」三个 tab;当前 tab 需要高亮,下方内容切换时使用轻微 fade transition。订单列表可以这样写:
订单列表使用 tabs 区分「全部 / 待支付 / 已完成 / 已取消」,切换 tab 时刷新列表并保留 loading 状态。Tabs 的重点是:内容之间应该是平级关系。如果不是平级内容,不要硬做 tabs。
Step 16 · Stepper · 步骤条
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Stepper</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:24px; } .shell { width:100%; max-width:360px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:24px; } .steps { display:flex; align-items:flex-start; position:relative; margin-bottom:24px; } .step { flex:1; text-align:center; position:relative; } .circle { width:28px; height:28px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; font-size:12px; font-weight:600; border:2px solid var(--border); background:var(--card); color:var(--muted); transition:all 250ms; position:relative; z-index:1; } .step.done .circle { background:var(--accent); border-color:var(--accent); color:#fff; } .step.active .circle { border-color:var(--accent); color:var(--accent); } .label { display:block; margin-top:6px; font-size:11px; color:var(--muted); } .step.active .label { color:var(--accent); font-weight:600; } .step.done .label { color:var(--fg); } .line { position:absolute; top:14px; left:calc(50% + 18px); right:calc(-50% + 18px); height:2px; background:var(--border); z-index:0; } .line.fill { background:var(--accent); } .body { padding:16px; background:#f2eee5; border-radius:10px; margin-bottom:16px; min-height:80px; display:grid; place-items:center; text-align:center; font-size:13px; color:var(--muted); } .nav { display:flex; justify-content:space-between; } .btn { border:1px solid var(--border); background:var(--card); border-radius:8px; padding:8px 16px; cursor:pointer; font:inherit; font-size:13px; color:var(--fg); } .btn:hover { background:#f2eee5; } .btn:disabled { opacity:0.35; cursor:default; } .btn.primary { background:var(--accent); border-color:var(--accent); color:#fff; } .btn.primary:hover { opacity:0.9; } .btn.primary:disabled { opacity:0.35; } </style> </head> <body> <div class="shell"> <div class="steps" id="steps"> <div class="step active"><div class="circle">1</div><span class="label">项目信息</span><div class="line" id="line1"></div></div> <div class="step"><div class="circle">2</div><span class="label">配置</span><div class="line" id="line2"></div></div> <div class="step"><div class="circle">3</div><span class="label">成员</span><div class="line" id="line3"></div></div> <div class="step"><div class="circle">4</div><span class="label">完成</span></div> </div> <div class="body" id="body">请输入项目名称和描述</div> <div class="nav"> <button class="btn" id="back" disabled>← 上一步</button> <button class="btn primary" id="next">下一步 →</button> </div> </div> <script> (function () { var stepEls = [].slice.call(document.querySelectorAll('.step')); var lines = [document.getElementById('line1'), document.getElementById('line2'), document.getElementById('line3')]; var body = document.getElementById('body'); var back = document.getElementById('back'); var next = document.getElementById('next'); var current = 0; var contents = ['请输入项目名称和描述','选择部署方式和环境','邀请团队成员加入','所有配置已完成!']; function render() { stepEls.forEach(function (el, i) { el.classList.remove('done','active'); if (i < current) el.classList.add('done'); else if (i === current) el.classList.add('active'); }); lines.forEach(function (line, i) { line.classList.toggle('fill', i < current); }); body.textContent = contents[current]; back.disabled = current === 0; next.textContent = current === contents.length - 1 ? '完成' : '下一步 →'; } next.addEventListener('click', function () { if (current < contents.length - 1) { current++; render(); } }); back.addEventListener('click', function () { if (current > 0) { current--; render(); } }); })(); </script> </body></html>16 · Stepper:步骤条
Stepper 把多步骤流程拆成清晰的阶段,让用户知道「自己在哪、还有几步」。
常见场景:注册流程、下单结算、配置向导、设置步骤。
实现一个步骤条组件:- 水平展示 4 个步骤,每个步骤有编号和标签- 已完成步骤显示实心高亮,当前步骤有边框高亮,未完成步骤置灰- 步骤之间用连线连接,已完成的连线高亮- 下方有「上一步」和「下一步」按钮- 第一步时「上一步」禁用,最后一步时「下一步」改为「完成」- 切换步骤时更新内容区展示当前步骤信息Stepper 适合步骤固定的表单,如果流程分支太多,可以考虑使用向导布局而不是步骤条。
Step 17 · Carousel · 轮播图
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Carousel</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:20px; } .shell { width:100%; max-width:360px; background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden; } .viewport { position:relative; overflow:hidden; aspect-ratio:16/10; } .track { display:flex; height:100%; transition:transform 400ms cubic-bezier(0.2,0,0,1); will-change:transform; } .slide { flex:0 0 100%; height:100%; display:flex; flex-direction:column; justify-content:flex-end; padding:24px; } .slide .meta { font-size:11px; color:var(--muted); margin-bottom:6px; letter-spacing:0.05em; } .slide .headline { font-size:18px; font-weight:600; line-height:1.3; margin-bottom:6px; } .slide .summary { font-size:12px; color:var(--muted); line-height:1.55; } .slide:nth-child(1) { background:#f5efe6; } .slide:nth-child(2) { background:#eef1f5; } .slide:nth-child(3) { background:#f5edee; } .slide:nth-child(4) { background:#edf3ef; } .foot { display:flex; align-items:center; justify-content:center; gap:12px; padding:12px 14px; border-top:1px solid var(--border); } .btn { border:0; background:transparent; border-radius:8px; padding:6px 12px; cursor:pointer; font:inherit; font-size:13px; color:var(--muted); } .btn:hover { background:#f2eee5; color:var(--fg); } .dots { display:flex; gap:6px; } .dot { width:6px; height:6px; border-radius:50%; background:var(--border); cursor:pointer; border:0; padding:0; transition:background 200ms, transform 200ms; } .dot.active { background:var(--accent); transform:scale(1.25); } </style> </head> <body> <div class="shell"> <div class="viewport"> <div class="track" id="track"> <div class="slide"><div class="meta">设计趋势 · 2026</div><div class="headline">极简主义正在退潮</div><div class="summary">从「少即是多」到「对就是多」,新一代设计语言更注重个性表达和情感连接。</div></div> <div class="slide"><div class="meta">前沿技术 · 深度</div><div class="headline">AI 辅助设计的边界</div><div class="summary">工具越来越强,但判断力仍是设计师不可替代的核心能力。</div></div> <div class="slide"><div class="meta">产品思考 · 观点</div><div class="headline">微交互的价值洼地</div><div class="summary">被低估的细节往往决定产品的真实质感,从 loading 到反馈都值得打磨。</div></div> <div class="slide"><div class="meta">开源项目 · 推荐</div><div class="headline">2026 年值得关注的工具</div><div class="summary">一批轻量级设计工具正在改变团队协作方式,它们更快、更专注。</div></div> </div> </div> <div class="foot"> <button class="btn" id="prev">←</button> <div class="dots" id="dots"></div> <button class="btn" id="next">→</button> </div> </div> <script> (function () { var track = document.getElementById('track'); var slides = track.children; var total = slides.length; var index = 0; var dotsContainer = document.getElementById('dots'); var timer = null; for (var i = 0; i < total; i++) { var dot = document.createElement('button'); dot.className = 'dot' + (i === 0 ? ' active' : ''); dot.dataset.i = i; dot.addEventListener('click', function () { go(Number(this.dataset.i)); }); dotsContainer.appendChild(dot); } function go(i) { if (i < 0) i = total - 1; if (i >= total) i = 0; index = i; track.style.transform = 'translateX(-' + (index * 100) + '%)'; [].slice.call(dotsContainer.children).forEach(function (d, idx) { d.className = 'dot' + (idx === index ? ' active' : ''); }); resetTimer(); } function resetTimer() { if (timer) clearInterval(timer); timer = setInterval(function () { go(index + 1); }, 3000); } document.getElementById('prev').addEventListener('click', function () { go(index - 1); }); document.getElementById('next').addEventListener('click', function () { go(index + 1); }); resetTimer(); })(); </script> </body></html>17 · Carousel:轮播图
Carousel 是内容轮播组件,通常用于首页 Banner、推荐内容、作品展示。
常见场景:官网首屏 Banner、推荐文章展示、产品截图轮播、用户评价展示。
实现一个轮播图组件:- 4 张内容幻灯片,自动轮播间隔 3 秒- 底部有圆点指示器,点击可跳转到对应幻灯片- 左右两侧有前进 / 后退箭头按钮- 切换动画为水平滑入,时长 320ms- 鼠标悬停时暂停自动播放(如有)轮播图的常见问题是:自动播放太频繁、切换动画太慢、触摸滑动不支持。写提示词时可以加上「所有过渡自然克制」来限制动画幅度。
Step 18 · Pagination · 分页
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Pagination</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:24px; } .shell { width:100%; max-width:360px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:20px; } .table { display:flex; flex-direction:column; gap:8px; margin-bottom:16px; min-height:180px; } .row { display:flex; align-items:center; justify-content:space-between; padding:10px 12px; background:#f2eee5; border-radius:8px; font-size:13px; animation:fadeIn 200ms; } @keyframes fadeIn { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:translateY(0)} } .row .tag { font-size:11px; color:var(--muted); background:var(--card); padding:1px 8px; border-radius:20px; } .pages { display:flex; align-items:center; justify-content:center; gap:4px; flex-wrap:wrap; } .page { min-width:30px; height:30px; border:1px solid transparent; border-radius:6px; background:transparent; cursor:pointer; font:inherit; font-size:13px; color:var(--muted); display:inline-flex; align-items:center; justify-content:center; } .page:hover { background:#f2eee5; color:var(--fg); } .page.active { background:var(--accent); color:#fff; border-color:var(--accent); } .page:disabled { opacity:0.3; cursor:default; } .page.ellipsis { cursor:default; border-color:transparent; background:transparent; } .page.ellipsis:hover { background:transparent; } .info { text-align:center; margin-top:12px; font-size:11px; color:var(--muted); } </style> </head> <body> <div class="shell"> <div class="table" id="table"></div> <div class="pages" id="pages"></div> <div class="info" id="info"></div> </div> <script> (function () { var totalItems = 48; var perPage = 4; var totalPages = Math.ceil(totalItems / perPage); var current = 1; var table = document.getElementById('table'); var pages = document.getElementById('pages'); var info = document.getElementById('info'); function render() { var start = (current - 1) * perPage + 1; var end = Math.min(start + perPage - 1, totalItems); var html = ''; for (var i = start; i <= end; i++) { html += '<div class="row"><span>项目 #' + i + '</span><span class="tag">活跃</span></div>'; } table.innerHTML = html; var p = ''; if (current > 1) p += '<button class="page" data-page="' + (current-1) + '">‹</button>'; else p += '<button class="page" disabled>‹</button>'; var pagesToShow = []; if (totalPages <= 7) { for (var i = 1; i <= totalPages; i++) pagesToShow.push(i); } else { pagesToShow.push(1); if (current > 3) pagesToShow.push('…'); var s = Math.max(2, current - 1); var e = Math.min(totalPages - 1, current + 1); for (var i = s; i <= e; i++) pagesToShow.push(i); if (current < totalPages - 2) pagesToShow.push('…'); pagesToShow.push(totalPages); } pagesToShow.forEach(function (pNum) { if (pNum === '…') { p += '<span class="page ellipsis">…</span>'; } else { p += '<button class="page' + (pNum === current ? ' active' : '') + '" data-page="' + pNum + '">' + pNum + '</button>'; } }); if (current < totalPages) p += '<button class="page" data-page="' + (current+1) + '">›</button>'; else p += '<button class="page" disabled>›</button>'; pages.innerHTML = p; info.textContent = '第 ' + current + ' / ' + totalPages + ' 页,共 ' + totalItems + ' 项'; [].slice.call(pages.querySelectorAll('[data-page]')).forEach(function (btn) { btn.addEventListener('click', function () { current = Number(this.dataset.page); render(); }); }); } render(); })(); </script> </body></html>18 · Pagination:分页
分页组件把大量数据分成多页展示,是后台管理系统最常用的导航模式。
常见场景:数据表格、搜索结果、文章列表、订单列表。
实现一个分页组件:- 展示当前页、总页数和总条数- 页码按钮包含:首页、尾页、当前页前后各 1 页- 页码过多时用省略号(…)折叠中间页- 上一页 / 下一页箭头按钮- 第一页时上一页禁用,最后一页时下一页禁用- 点击页码跳转到对应页并刷新列表数据- 页码切换时有微弱的列表项入场动画移动端分页建议改为「加载更多」按钮或无限滚动,因为精确的页码按钮在窄屏上难点击。
Step 19 · Modal · 二次确认
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Modal Dialog</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; --danger:#b9483d; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--fg); font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; display: grid; place-items: center; padding: 32px; } .btn { appearance:none; cursor:pointer; font:inherit; font-weight:500; border-radius:8px; padding:9px 15px; border:1px solid var(--border); background:var(--card); color:var(--fg); transition:background-color 160ms ease, transform 120ms ease; } .btn:hover { background:#f2eee5; } .btn:active { transform:scale(0.98); } .btn.danger { border-color:var(--danger); background:var(--danger); color:#fffaf5; } .btn.danger:hover { background:#a63d35; } .overlay { position:fixed; inset:0; display:grid; place-items:center; padding:24px; background:rgba(26,24,21,0.28); opacity:0; pointer-events:none; transition:opacity 180ms ease; } .overlay[data-open='true'] { opacity:1; pointer-events:auto; } .dialog { width:min(360px,100%); background:var(--card); border:1px solid var(--border); border-radius:12px; padding:20px; box-shadow:0 24px 50px -28px rgba(20,18,14,0.5); transform:translateY(8px) scale(0.98); transition:transform 180ms cubic-bezier(0.2,0,0,1); } .overlay[data-open='true'] .dialog { transform:translateY(0) scale(1); } h3 { margin:0 0 8px; font-size:16px; } p { margin:0 0 18px; color:var(--muted); font-size:13px; line-height:1.6; } .actions { display:flex; justify-content:flex-end; gap:8px; } </style> </head> <body> <button class="btn danger" id="open" type="button">删除项目</button> <div class="overlay" id="overlay" aria-hidden="true"> <section class="dialog" role="dialog" aria-modal="true" aria-labelledby="title"> <h3 id="title">确认删除项目?</h3> <p>这个操作不可撤销。删除前用 modal 打断流程,让用户明确确认。</p> <div class="actions"> <button class="btn" id="cancel" type="button">取消</button> <button class="btn danger" id="confirm" type="button">确认删除</button> </div> </section> </div> <script> (function () { var overlay = document.getElementById('overlay'); var open = document.getElementById('open'); var cancel = document.getElementById('cancel'); var confirm = document.getElementById('confirm'); function setOpen(value) { overlay.setAttribute('data-open', value ? 'true' : 'false'); overlay.setAttribute('aria-hidden', value ? 'false' : 'true'); } open.addEventListener('click', function () { setOpen(true); }); cancel.addEventListener('click', function () { setOpen(false); }); confirm.addEventListener('click', function () { setOpen(false); }); overlay.addEventListener('click', function (event) { if (event.target === overlay) setOpen(false); }); document.addEventListener('keydown', function (event) { if (event.key === 'Escape') setOpen(false); }); })(); </script> </body></html>19 · Modal / Dialog:弹窗
Modal 是弹窗,通常用于需要用户集中注意力处理的事情。
常见场景:删除确认、创建项目、编辑信息、登录注册、重要提示、表单填写。
Modal 的特点是:它会打断当前流程,让用户先处理弹窗里的事情。
删除操作需要二次确认弹窗:点击删除后打开 modal,背景加半透明遮罩;弹窗包含标题、说明文案、取消按钮和确认删除按钮;点击取消或 ESC 关闭,点击确认后执行删除。创建表单可以这样说:
点击「新建项目」后打开 modal,里面包含项目名称和描述输入框。提交时进入 loading 状态,成功后关闭 modal 并刷新列表,失败时在 modal 内显示错误信息。Modal 不是万能容器。如果只是展示侧边详情,用 drawer 可能更合适。
Step 20 · Drawer · 侧边抽屉
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Drawer</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:28px; overflow:hidden; } .list { width:min(360px,100%); background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden; } .row { width:100%; appearance:none; border:0; border-bottom:1px solid var(--border); background:transparent; padding:14px 16px; display:flex; justify-content:space-between; align-items:center; text-align:left; cursor:pointer; font:inherit; color:var(--fg); transition:background-color 160ms ease; } .row:last-child { border-bottom:0; } .row:hover { background:#f4f0e8; } .meta { color:var(--muted); font-size:12px; } .overlay { position:fixed; inset:0; background:rgba(26,24,21,0.22); opacity:0; pointer-events:none; transition:opacity 180ms ease; } .overlay[data-open='true'] { opacity:1; pointer-events:auto; } .drawer { position:fixed; top:0; right:0; height:100%; width:min(360px,88vw); background:var(--card); border-left:1px solid var(--border); padding:22px; transform:translateX(100%); transition:transform 220ms cubic-bezier(0.2,0,0,1); box-shadow:-20px 0 40px -30px rgba(20,18,14,0.45); } .overlay[data-open='true'] .drawer { transform:translateX(0); } .top { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; } h3 { margin:0; font-size:16px; } .close { appearance:none; border:1px solid var(--border); background:var(--bg); border-radius:8px; width:32px; height:32px; cursor:pointer; } p { margin:0 0 14px; color:var(--muted); font-size:13px; line-height:1.65; } .chip { display:inline-flex; border:1px solid var(--border); border-radius:999px; padding:4px 9px; font-size:12px; color:var(--muted); } </style> </head> <body> <div class="list"> <button class="row" type="button"><span>Atlas Migration</span><span class="meta">查看详情</span></button> <button class="row" type="button"><span>Search Rewrite</span><span class="meta">查看详情</span></button> <button class="row" type="button"><span>Billing Audit</span><span class="meta">查看详情</span></button> </div> <div class="overlay" id="overlay"> <aside class="drawer" aria-label="项目详情"> <div class="top"><h3>Atlas Migration</h3><button class="close" id="close" type="button">×</button></div> <p>Drawer 保留了列表上下文,适合详情预览、筛选面板和轻量编辑。</p> <span class="chip">In progress</span> </aside> </div> <script> (function () { var overlay = document.getElementById('overlay'); var close = document.getElementById('close'); function setOpen(value) { overlay.setAttribute('data-open', value ? 'true' : 'false'); } Array.prototype.forEach.call(document.querySelectorAll('.row'), function (row) { row.addEventListener('click', function () { setOpen(true); }); }); close.addEventListener('click', function () { setOpen(false); }); overlay.addEventListener('click', function (event) { if (event.target === overlay) setOpen(false); }); })(); </script> </body></html>20 · Drawer:抽屉
Drawer 是从页面侧边滑出的面板,常见方向是从右侧或左侧滑出。
适合场景:详情预览、设置面板、筛选条件、移动端菜单、保持当前页面上下文的编辑操作。
Modal 是打断流程,Drawer 更像是在当前页面旁边展开一块内容。
点击列表项后,从右侧滑出 drawer 展示详情。drawer 宽度为 420px,背景页面保留但加遮罩,关闭时向右滑出。移动端菜单可以这样说:
移动端点击汉堡菜单后,从左侧滑出 drawer 导航菜单,背景加遮罩,点击遮罩或关闭按钮时收起。Drawer 很适合后台管理系统,因为它可以让用户在不离开列表页的情况下查看详情。
Step 21 · Sticky · 吸顶固定
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Sticky</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; } .frame { height:100vh; overflow:auto; overscroll-behavior:contain; padding:18px; } .bar { position:sticky; top:0; z-index:2; background:color-mix(in oklab, var(--bg) 92%, white); border:1px solid var(--border); border-radius:10px; padding:10px 12px; display:flex; justify-content:space-between; align-items:center; box-shadow:0 8px 18px -18px rgba(20,18,14,0.45); } .title { font-weight:600; } .meta { color:var(--muted); font-size:12px; } .content { max-width:340px; margin:14px auto 60px; display:grid; gap:10px; } .row { height:58px; background:var(--card); border:1px solid var(--border); border-radius:10px; padding:12px; display:flex; align-items:center; justify-content:space-between; } .small { color:var(--muted); font-size:12px; } </style> </head> <body> <div class="frame"> <div class="bar"><span class="title">项目列表</span><span class="meta">Sticky filter</span></div> <div class="content"> <div class="row"><span>Atlas Migration</span><span class="small">Active</span></div> <div class="row"><span>Search Rewrite</span><span class="small">Review</span></div> <div class="row"><span>Billing Audit</span><span class="small">Draft</span></div> <div class="row"><span>Docs Refresh</span><span class="small">Active</span></div> <div class="row"><span>Import Tool</span><span class="small">Paused</span></div> <div class="row"><span>Design QA</span><span class="small">Active</span></div> <div class="row"><span>Access Rules</span><span class="small">Review</span></div> <div class="row"><span>Pipeline v2</span><span class="small">Active</span></div> <div class="row"><span>Theme Engine</span><span class="small">Draft</span></div> <div class="row"><span>API Gateway</span><span class="small">Review</span></div> <div class="row"><span>Cache Layer</span><span class="small">Active</span></div> </div> </div> </body></html>21 · Sticky:吸顶 / 固定
Sticky 是指元素在页面滚动时固定在某个位置。
常见场景:顶部导航栏、表格表头、侧边目录、操作栏、筛选栏。
顶部导航栏使用 sticky 效果,页面向下滚动时固定在顶部,并添加轻微阴影区分内容区域。表格可以这样说:
数据表格的表头需要 sticky,在纵向滚动时保持固定,方便用户查看列名。文档页可以这样说:
文章详情页右侧目录使用 sticky 定位,用户滚动正文时目录保持可见,并高亮当前阅读章节。Sticky 很实用,特别是数据表格、文档站、管理后台这些场景。
Step 22 · Scroll Animation · 滚动触发
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Scroll Animation</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; } .scroll { height:100vh; overflow:auto; overscroll-behavior:contain; padding:24px; scroll-behavior:smooth; } .spacer { height:120px; display:grid; place-items:center; color:var(--muted); font-size:12px; } .grid { display:grid; gap:12px; max-width:360px; margin:0 auto 80px; } .card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:15px; opacity:0; transform:translateY(12px); transition:opacity 360ms ease, transform 360ms cubic-bezier(0.2,0,0,1); } .card[data-visible='true'] { opacity:1; transform:translateY(0); } .card:nth-child(2) { transition-delay:80ms; } .card:nth-child(3) { transition-delay:160ms; } .card:nth-child(4) { transition-delay:240ms; } .card:nth-child(5) { transition-delay:320ms; } .card:nth-child(6) { transition-delay:400ms; } .card:nth-child(7) { transition-delay:480ms; } h3 { margin:0 0 5px; font-size:14px; } p { margin:0; color:var(--muted); font-size:12.5px; line-height:1.6; } </style> </head> <body> <div class="scroll" id="scroll"> <div class="spacer">向下滚动,卡片进入视口后淡入</div> <section class="grid"> <article class="card"><h3>轻量进入</h3><p>只用透明度和 12px 位移,避免影响阅读。</p></article> <article class="card"><h3>顺序延迟</h3><p>相邻卡片延迟 80ms,形成可感知的节奏。</p></article> <article class="card"><h3>展示型页面</h3><p>适合官网、作品集;后台系统要谨慎使用。</p></article> <article class="card"><h3>渐进披露</h3><p>内容随着用户滚动逐步呈现,引导阅读节奏。</p></article> <article class="card"><h3>性能注意</h3><p>大量卡片同时进入视口时,使用 will-change 和硬件加速。</p></article> <article class="card"><h3>视口检测</h3><p>基于 getBoundingClientRect 判断卡片是否进入可视区域。</p></article> <article class="card"><h3>可访问性</h3><p>确保动画不影响内容的可读性,遵循 prefers-reduced-motion。</p></article> </section> </div> <script> (function () { var root = document.getElementById('scroll'); var cards = Array.prototype.slice.call(document.querySelectorAll('.card')); function check() { cards.forEach(function (card) { var rect = card.getBoundingClientRect(); if (rect.top < window.innerHeight - 40) card.setAttribute('data-visible', 'true'); }); } root.addEventListener('scroll', check); check(); })(); </script> </body></html>22 · Scroll Animation:滚动动画
Scroll animation 是页面滚动时触发的动画,常见于官网、落地页、作品集、活动页。比如页面滚动到某一区域时,卡片逐个淡入。
页面滚动到对应区域时,卡片按顺序淡入并向上移动 12px,动画需要轻量,不要影响阅读。官网首屏下方的功能模块可以这样写:
官网首屏下方的功能模块,在进入视口时使用 fade-in-up 动画,延迟依次递增 80ms。这类动画适合官网和展示型页面。如果是后台管理系统,过多滚动动画只会增加干扰。
Step 23 · Autocomplete · 自动补全
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Autocomplete</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:24px; } .shell { width:100%; max-width:340px; position:relative; } .input { width:100%; border:1px solid var(--border); border-radius:10px; padding:10px 14px; font:inherit; font-size:13px; background:var(--card); outline:none; transition:border-color 160ms, box-shadow 160ms; } .input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(185,72,61,0.18); } .menu { position:absolute; top:calc(100% + 4px); left:0; right:0; background:var(--card); border:1px solid var(--border); border-radius:10px; overflow:hidden; display:none; box-shadow:0 8px 24px rgba(20,18,14,0.12); z-index:10; } .menu.open { display:block; } .option { padding:9px 14px; font-size:13px; cursor:pointer; } .option:hover, .option.highlighted { background:#f2eee5; } .option .match { color:var(--accent); font-weight:600; } .empty { padding:14px; text-align:center; font-size:12px; color:var(--muted); } .tag { display:inline-flex; align-items:center; gap:6px; background:#f2eee5; border-radius:20px; padding:4px 10px; font-size:12px; margin-top:10px; } .tag button { border:0; background:transparent; cursor:pointer; color:var(--muted); font-size:14px; line-height:1; padding:0; } </style> </head> <body> <div class="shell"> <input class="input" id="input" placeholder="搜索项目…" autocomplete="off" /> <div class="menu" id="menu"></div> <div id="result"></div> </div> <script> (function () { var data = ['设计规范','组件库','Icon 系统','暗色模式','主题引擎','权限管理','WebSocket 服务','API 网关','数据看板','用户反馈','搜索重构','部署流水线','单元测试','性能监控','日志中心','缓存策略']; var input = document.getElementById('input'); var menu = document.getElementById('menu'); var result = document.getElementById('result'); var highlightIdx = -1; var currentResults = []; function filter(q) { if (!q) return data.slice(0, 8); var lower = q.toLowerCase(); return data.filter(function (item) { return item.toLowerCase().indexOf(lower) !== -1; }); } function highlight(text, query) { if (!query) return text; var idx = text.toLowerCase().indexOf(query.toLowerCase()); if (idx === -1) return text; return text.slice(0, idx) + '<span class="match">' + text.slice(idx, idx + query.length) + '</span>' + text.slice(idx + query.length); } function render() { var query = input.value.trim(); currentResults = filter(query); highlightIdx = -1; if (currentResults.length === 0) { menu.innerHTML = '<div class="empty">无匹配结果</div>'; menu.classList.add('open'); return; } var html = ''; currentResults.forEach(function (item) { html += '<div class="option">' + highlight(item, query) + '</div>'; }); menu.innerHTML = html; menu.classList.add('open'); [].slice.call(menu.children).forEach(function (el) { el.addEventListener('mousedown', function (e) { e.preventDefault(); select(el.textContent); }); }); } function select(text) { input.value = text; menu.classList.remove('open'); result.innerHTML = '<div class="tag">' + text + ' <button id="clearTag" type="button">✕</button></div>'; document.getElementById('clearTag').addEventListener('click', function () { result.innerHTML = ''; input.value = ''; input.focus(); }); } function moveHighlight(direction) { var options = [].slice.call(menu.querySelectorAll('.option')); if (options.length === 0) return; [].forEach.call(options, function (o) { o.classList.remove('highlighted'); }); highlightIdx = Math.max(0, Math.min(highlightIdx + direction, options.length - 1)); options[highlightIdx].classList.add('highlighted'); } input.addEventListener('input', render); input.addEventListener('keydown', function (e) { if (e.key === 'ArrowDown') { e.preventDefault(); moveHighlight(1); } else if (e.key === 'ArrowUp') { e.preventDefault(); moveHighlight(-1); } else if (e.key === 'Enter') { var highlighted = menu.querySelector('.highlighted'); if (highlighted) { e.preventDefault(); select(highlighted.textContent); } } else if (e.key === 'Escape') { menu.classList.remove('open'); } }); document.addEventListener('click', function (e) { if (!e.target.closest('.shell')) menu.classList.remove('open'); }); })(); </script> </body></html>23 · Autocomplete:自动补全
Autocomplete 是带下拉建议的输入框,用户输入时自动匹配候选列表。
常见场景:搜索栏、标签选择、用户选择器、地址补全、技术栈选择。
实现搜索自动补全组件:- 输入内容后展示匹配的下拉列表- 匹配文本高亮显示- 支持键盘上下键导航和 Enter 选择- 无匹配结果时显示「无匹配结果」- 选择后显示已选标签,标签可移除- 点击外部区域关闭下拉列表- 按 Escape 键关闭下拉列表数据量小的时候用本地过滤就行。如果是从后端搜索,需要加 debounce(300ms)和 loading 状态。
Step 24 · Drag & Drop · 拖拽排序
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Drag List</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:24px; } .list { width:100%; max-width:340px; display:flex; flex-direction:column; gap:6px; } .item { display:flex; align-items:center; gap:10px; background:var(--card); border:1px solid var(--border); border-radius:10px; padding:12px 14px; cursor:grab; transition:opacity 160ms, border-color 160ms, box-shadow 160ms; user-select:none; } .item:active { cursor:grabbing; } .item.dragging { opacity:0.45; border-color:var(--accent); } .item.drag-over { border-color:var(--accent); box-shadow:0 0 0 2px rgba(185,72,61,0.3); } .handle { font-size:15px; color:var(--muted); line-height:1; flex-shrink:0; } .label { flex:1; font-size:13px; } .badge { font-size:11px; color:var(--muted); background:#f2eee5; padding:2px 8px; border-radius:20px; } </style> </head> <body> <div class="list" id="list"> <div class="item" draggable="true"><span class="handle">⠿</span><span class="label">设计规范</span><span class="badge">Active</span></div> <div class="item" draggable="true"><span class="handle">⠿</span><span class="label">组件库文档</span><span class="badge">Review</span></div> <div class="item" draggable="true"><span class="handle">⠿</span><span class="label">Icon 系统迁移</span><span class="badge">Draft</span></div> <div class="item" draggable="true"><span class="handle">⠿</span><span class="label">暗色模式</span><span class="badge">Active</span></div> <div class="item" draggable="true"><span class="handle">⠿</span><span class="label">主题引擎 v2</span><span class="badge">Pending</span></div> </div> <script> (function () { var list = document.getElementById('list'); var dragEl = null; list.addEventListener('dragstart', function (e) { dragEl = e.target.closest('.item'); if (!dragEl || !list.contains(dragEl)) return; dragEl.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); }); list.addEventListener('dragend', function (e) { var el = e.target.closest('.item'); if (el) el.classList.remove('dragging'); [].slice.call(list.querySelectorAll('.drag-over')).forEach(function (c) { c.classList.remove('drag-over'); }); dragEl = null; }); list.addEventListener('dragover', function (e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; var target = e.target.closest('.item'); if (!target || target === dragEl) return; [].slice.call(list.querySelectorAll('.drag-over')).forEach(function (c) { c.classList.remove('drag-over'); }); target.classList.add('drag-over'); }); list.addEventListener('dragleave', function (e) { var target = e.target.closest('.item'); if (target && target !== dragEl) target.classList.remove('drag-over'); }); list.addEventListener('drop', function (e) { e.preventDefault(); var target = e.target.closest('.item'); if (!target || !dragEl || target === dragEl) return; target.classList.remove('drag-over'); var rect = target.getBoundingClientRect(); var mid = rect.top + rect.height / 2; if (e.clientY < mid) { list.insertBefore(dragEl, target); } else { list.insertBefore(dragEl, target.nextElementSibling); } }); })(); </script> </body></html>24 · Drag & Drop:拖拽排序
拖拽排序允许用户通过鼠标(或触屏)直接拖动列表项来改变顺序。
常见场景:看板任务排序、收藏夹整理、图片排序、待办列表优先级调整。
HTML5 原生 draggable 属性已能基本满足拖拽需求,不需要额外库。
实现一个可拖拽排序的列表:- 每一项左侧显示拖拽手柄图标(⠿)- 鼠标悬停时显示可拖拽提示- 拖拽过程中原项半透明,目标位置高亮- 在目标项上方或下方插入,取决于鼠标释放位置- 拖拽结束后恢复原始透明度- 不要使用第三方库,使用原生 HTML5 drag & drop API移动端 drag & drop 支持有限。如果移动端也需要拖拽排序,建议使用 touch 事件实现,或者把排序改为上移 / 下移按钮操作。
Step 25 · Resizable · 可调面板
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Resizable Panel</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:24px; } .shell { width:100%; max-width:420px; display:flex; height:240px; background:var(--card); border:1px solid var(--border); border-radius:10px; overflow:hidden; } .left { width:40%; min-width:80px; max-width:70%; overflow:hidden; display:flex; flex-direction:column; } .left-header { padding:12px 14px 8px; font-size:11px; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:0.1em; border-bottom:1px solid var(--border); } .left-items { flex:1; overflow:auto; padding:8px 0; } .left-item { padding:7px 14px; font-size:12px; cursor:pointer; } .left-item:hover, .left-item.active { background:#f2eee5; } .divider { width:4px; cursor:col-resize; background:var(--border); position:relative; flex-shrink:0; transition:background 120ms; } .divider:hover, .divider.active { background:var(--accent); } .right { flex:1; overflow:hidden; display:flex; flex-direction:column; } .right-header { padding:12px 14px 8px; font-size:11px; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:0.1em; } .right-body { flex:1; padding:8px 14px; font-size:12px; color:var(--muted); line-height:1.7; } </style> </head> <body> <div class="shell" id="shell"> <div class="left" id="left"> <div class="left-header">文件</div> <div class="left-items"> <div class="left-item active">index.tsx</div> <div class="left-item">style.css</div> <div class="left-item">utils.ts</div> <div class="left-item">types.ts</div> <div class="left-item">constants.ts</div> </div> </div> <div class="divider" id="divider"></div> <div class="right" id="right"> <div class="right-header">index.tsx</div> <div class="right-body">拖动中间的分隔线<br/>调整左右面板大小<br/>松开后保持新比例</div> </div> </div> <script> (function () { var left = document.getElementById('left'); var divider = document.getElementById('divider'); var shell = document.getElementById('shell'); var dragging = false; divider.addEventListener('mousedown', function (e) { e.preventDefault(); dragging = true; divider.classList.add('active'); }); document.addEventListener('mousemove', function (e) { if (!dragging) return; var rect = shell.getBoundingClientRect(); var pct = ((e.clientX - rect.left) / rect.width) * 100; pct = Math.max(20, Math.min(65, pct)); left.style.width = pct + '%'; }); document.addEventListener('mouseup', function () { if (dragging) { dragging = false; divider.classList.remove('active'); } }); })(); </script> </body></html>25 · Resizable Panel:可调整面板
可调整面板允许用户通过拖拽分隔线来调整左右(或上下)区域的大小比例。
常见场景:IDE 编辑器布局、后台管理系统侧边栏、邮件客户端、文件管理器。
实现一个可拖拽调整大小的分栏布局:- 左右两个面板,中间有一条可拖拽的分隔线- 分隔线 hover 时颜色变化,提示可拖拽- 拖拽过程中实时调整面板宽度- 左面板最小宽度 20%,最大宽度 65%- 鼠标松开后保持调整后的比例- 分隔线拖拽时变为 active 状态可调整面板的要点是:最小 / 最大宽度限制(防止面板被拖到消失),以及拖拽区域足够宽(至少 4px,建议 8px)以便鼠标精准定位。
Step 26 · Micro-interaction · 微交互
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Micro Interaction</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:32px; } .stage { display:flex; align-items:center; gap:16px; flex-direction:column; } .switch-row { display:flex; align-items:center; gap:14px; cursor:pointer; user-select:none; } .track { width:44px; height:24px; border-radius:12px; background:#d8d3c5; position:relative; transition:background 220ms cubic-bezier(0.2,0,0,1); flex-shrink:0; } .track.on { background:var(--accent); } .knob { width:18px; height:18px; border-radius:50%; background:#fff; position:absolute; top:3px; left:3px; transition:transform 220ms cubic-bezier(0.2,0,0,1); box-shadow:0 1px 3px rgba(20,18,14,0.15); } .track.on .knob { transform:translateX(20px); } .label { font-size:14px; } .status { font-size:12px; color:var(--muted); transition:color 220ms; } .status.on { color:var(--accent); font-weight:500; } .second { display:flex; align-items:center; gap:12px; } .icon-btn { appearance:none; border:0; background:transparent; cursor:pointer; width:40px; height:40px; border-radius:10px; display:grid; place-items:center; color:var(--muted); transition:background 160ms, color 160ms, transform 120ms; } .icon-btn:hover { background:#f2eee5; color:var(--fg); } .icon-btn:active { transform:scale(0.9); } .icon-btn.active { color:var(--accent); } .icon-btn svg { width:20px; height:20px; display:block; } .hint { font-size:12px; color:var(--muted); text-align:center; margin:0; } </style> </head> <body> <div class="stage"> <div class="switch-row" id="switchRow"> <div class="track" id="track"><div class="knob"></div></div> <span class="label">通知</span> <span class="status" id="status">已关闭</span> </div> <div class="second"> <button class="icon-btn" id="likeBtn" type="button" aria-label="点赞"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s-8-5.5-8-10c0-3 2.5-5.5 5.5-5.5S12 6.5 12 6.5s2.5-3 5.5-3S21 8 21 11c0 4.5-9 10-9 10z"/></svg> </button> <button class="icon-btn" id="favBtn" type="button" aria-label="收藏"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M12 4.8 14.2 9l4.7.7-3.4 3.3.8 4.7L12 15.5l-4.2 2.2.8-4.7-3.4-3.3 4.7-.7L12 4.8Z"/></svg> </button> </div> <p class="hint">切换开关、点赞收藏——小操作的小反馈</p> </div> <script> (function () { var track = document.getElementById('track'); var status = document.getElementById('status'); var switchRow = document.getElementById('switchRow'); switchRow.addEventListener('click', function () { var on = track.classList.toggle('on'); status.classList.toggle('on', on); status.textContent = on ? '已开启' : '已关闭'; }); function toggleBtn(btn) { btn.classList.toggle('active'); btn.style.transition = 'none'; btn.style.transform = 'scale(1.2)'; requestAnimationFrame(function () { btn.style.transition = ''; btn.style.transform = ''; }); } document.getElementById('likeBtn').addEventListener('click', function () { toggleBtn(this); }); document.getElementById('favBtn').addEventListener('click', function () { toggleBtn(this); }); })(); </script> </body></html>26 · Micro-interaction:微交互
Micro-interaction 是很小的交互反馈,通常发生在一个很小的动作之后。比如点赞后图标填充、收藏后按钮变成已收藏、复制后文案变成「已复制」、开关切换时滑块移动。
复制按钮点击后,将按钮文案临时改为「已复制」,持续 1.5 秒后恢复;同时显示一个轻微的成功状态动画。或者:
收藏按钮点击后,图标从空心变为实心,并有一个轻微的 scale 动画,表示操作成功。微交互不一定要炫。它的价值是让用户感受到操作被系统接收了。产品从「能用」到「好用」,很多时候差的就是这些小反馈。
Step 27 · Responsive · 响应式交互
查看源码
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Responsive Interaction</title> <style> :root { --bg:#faf8f3; --fg:#1a1815; --muted:#6b6760; --border:#d8d3c5; --card:#fff; --accent:#b9483d; } * { box-sizing:border-box; } html, body { height:100%; margin:0; } body { background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif; display:grid; place-items:center; padding:28px; overflow:hidden; } .shell { width:min(390px,100%); background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden; } .nav { height:52px; display:flex; align-items:center; justify-content:space-between; padding:0 14px; border-bottom:1px solid var(--border); } .brand { font-weight:600; } .desktop { display:flex; gap:6px; } .desktop button, .menu-btn { border:0; background:transparent; border-radius:8px; padding:7px 9px; cursor:pointer; font:inherit; color:var(--muted); } .desktop button:hover { background:#f2eee5; color:var(--fg); } .menu-btn { display:none; border:1px solid var(--border); color:var(--fg); } .body { padding:18px; color:var(--muted); font-size:13px; line-height:1.65; min-height:120px; } .drawer { position:fixed; inset:0 auto 0 0; width:min(260px,80vw); background:var(--card); border-right:1px solid var(--border); padding:18px; transform:translateX(-100%); transition:transform 200ms cubic-bezier(0.2,0,0,1); } .drawer[data-open='true'] { transform:translateX(0); } .drawer button { width:100%; border:0; background:transparent; text-align:left; border-radius:8px; padding:10px; font:inherit; color:var(--fg); } .drawer button:hover { background:#f2eee5; } @media (max-width: 420px) { .desktop { display:none; } .menu-btn { display:block; } } </style> </head> <body> <section class="shell"> <nav class="nav"><span class="brand">Console</span><div class="desktop"><button>概览</button><button>成员</button><button>设置</button></div><button class="menu-btn" id="open" type="button">菜单</button></nav> <div class="body">桌面端可以 hover 导航;移动端没有 hover,需要改成点击按钮打开 drawer。</div> </section> <aside class="drawer" id="drawer"><button>概览</button><button>成员</button><button>设置</button></aside> <script> (function () { var open = document.getElementById('open'); var drawer = document.getElementById('drawer'); open.addEventListener('click', function () { drawer.setAttribute('data-open', drawer.getAttribute('data-open') !== 'true' ? 'true' : 'false'); }); })(); </script> </body></html>27 · Responsive Interaction:响应式交互
响应式不只是页面宽度变化,它还包括交互方式的变化。桌面端有鼠标可以 hover,但移动端没有 hover。所以很多桌面端交互,到了移动端需要重新设计。
比如:
- 桌面端导航 hover 展开 → 移动端改成点击汉堡菜单打开 drawer
- 桌面端表格展示很多列 → 移动端改成卡片列表
- 桌面端 tooltip → 移动端改成点击查看说明
请处理响应式交互:桌面端导航菜单 hover 展开,移动端改为点击汉堡按钮打开 drawer;不要依赖 hover 作为移动端唯一交互。或者:
桌面端使用表格展示数据,移动端改为卡片列表展示,保证主要操作按钮在移动端容易点击。只要你做的是面向真实用户的页面,就一定要补一句:
请同时考虑移动端交互,不要依赖 hover。给 AI 写前端交互提示词的通用模板
如果你不知道怎么写,可以直接套这个模板。
请实现一个【组件/页面名称】。使用场景:- 这个组件用于【具体业务场景】用户操作:- 用户可以【点击 / 悬停 / 输入 / 拖拽 / 滚动】- 当用户【某个操作】时,页面应该【具体反馈】状态要求:- 默认状态:- hover 状态:- active / pressed 状态:- loading 状态:- success 状态:- error 状态:- empty 状态:- disabled 状态:动效要求:- 使用自然克制的过渡动画- 动画时长控制在 150ms 到 250ms- 不要使用夸张弹跳、旋转、闪烁等装饰性动画响应式要求:- 桌面端:- 移动端:- 移动端不要依赖 hover 作为唯一交互技术要求:- 代码结构清晰- 组件状态可维护- 不要只写静态样式,要处理真实交互状态这个模板的关键不是形式,而是它提醒你:不要只描述 UI,还要描述状态和反馈。
一个具体例子:项目卡片组件
很多人会这样说:
帮我写一个项目卡片,要好看一点,有交互。这个提示词基本等于把方向盘交给 AI。更好的写法是:
请实现一个项目卡片组件,用于展示项目列表。卡片内容包括:- 项目名称- 项目描述- 更新时间- 状态标签- 右上角更多操作按钮交互要求:- 鼠标 hover 卡片时,卡片轻微上浮 4px,阴影增强,过渡时间 200ms- hover 时右上角更多操作按钮从透明变为可见- 点击更多操作按钮时,显示 dropdown 菜单,包含「编辑 / 复制 / 删除」- 点击删除时弹出确认 modal,不要直接删除- 删除成功后显示 toast:「项目已删除」- 请求处理中需要 loading 状态,避免重复点击状态要求:- loading 时显示 skeleton- 空列表时显示 empty state,并提供「创建项目」按钮- 请求失败时显示 error state,并提供「重新加载」按钮风格要求:- 视觉克制、现代、接近 Linear / Vercel 后台风格- 不要使用夸张动画- 移动端下卡片宽度自适应,更多操作通过点击触发这个提示词没有写任何复杂代码,但它把交互说清楚了。AI 拿到它,生成结果会比「好看一点」强太多。
不要只让 AI 写静态页面
很多人用 AI 写前端,最容易掉进一个坑:只让 AI 写静态页面。
比如:
帮我写一个 Dashboard 页面。AI 会给你:左侧菜单、顶部导航、几张数据卡片、一个图表区域、一个表格。看起来好像完成了。
但真实的 Dashboard 不是截图。它应该处理:数据加载中、加载失败、数据为空、筛选条件变化、时间范围切换、表格分页、操作成功 / 失败、移动端布局。
所以你应该这样描述:
请实现一个 Dashboard 页面,不要只写静态 UI,需要处理 loading、empty、error 三种状态。数据卡片加载时显示 skeleton,请求失败时显示错误说明和重试按钮,筛选条件变化时重新加载数据并显示局部 loading。AI Coding 时代非常重要的能力是:你不是在描述一个静态界面,而是在描述一个会变化的系统。
最后
重点不是背单词,而是建立一种意识:前端不是静态截图,而是一系列状态变化。
你不需要成为资深前端,但你需要知道:什么是好结果、如何描述好结果、如何判断坏结果。下次用 AI 写前端,不要只说「帮我做得高级一点」。你可以说:
请把这个页面当成真实产品来实现,不要只写静态 UI。需要处理 hover、focus、loading、empty、error、success、disabled 等状态。交互反馈要明确,动效要克制,过渡时间控制在 150ms 到 250ms。移动端不要依赖 hover,需要提供点击触发的替代交互。AI 不怕你啰嗦,AI 怕你含糊。
<!doctype html><html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Hover Card</title> <style> :root { --bg: #faf8f3; --fg: #1a1815; --muted: #6b6760; --border: #e6e1d4; --card: #ffffff; --accent: #b9483d; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--fg); font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; display: grid; place-items: center; padding: 32px; } .stage { width: 100%; max-width: 320px; } .stage-label { font: 500 10.5px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.22em; color: var(--muted); text-transform: uppercase; margin-bottom: 14px; } .card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px 18px 16px; cursor: pointer; transition: transform 200ms cubic-bezier(0.2, 0, 0, 1), box-shadow 200ms cubic-bezier(0.2, 0, 0, 1), border-color 200ms ease; box-shadow: 0 1px 0 rgba(20, 18, 14, 0.02); } .card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px -8px rgba(20, 18, 14, 0.12), 0 2px 6px -2px rgba(20, 18, 14, 0.06); border-color: var(--accent); } .row { display: flex; align-items: center; gap: 10px; } .dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); flex: 0 0 auto; } .title { font-weight: 500; font-size: 14.5px; } .desc { margin: 8px 0 14px; color: var(--muted); font-size: 13px; line-height: 1.55; } .meta { display: flex; align-items: center; justify-content: space-between; font: 500 11px/1 ui-monospace, SFMono-Regular, monospace; color: var(--muted); letter-spacing: 0.04em; } .arrow { opacity: 0; transform: translateX(-4px); transition: opacity 200ms ease, transform 200ms ease; } .card:hover .arrow { opacity: 1; transform: translateX(0); } </style> </head> <body> <div class="stage"> <div class="stage-label">Hover the card</div> <article class="card" tabindex="0"> <div class="row"> <span class="dot" aria-hidden="true"></span> <h3 class="title">Atlas Migration</h3> </div> <p class="desc">迁移用户数据到新的存储集群,预计 4 月底完成。</p> <div class="meta"> <span>UPDATED · 2D AGO</span> <span class="arrow" aria-hidden="true">→</span> </div> </article> </div> </body></html> 很多人用 AI 写前端时,最大的障碍不是 AI 不会写代码,而是你说不清楚。
你说「做得高级一点」,AI 收到的是一团模糊。它的解决方案是加渐变、加阴影、加毛玻璃、加弹跳动画——最后页面看起来像「产品经理梦里的 SaaS 官网」混着「初学者第一次学 CSS」。
问题不在 AI 不努力,在你把顺序搞反了:
不是先学会提示词再让 AI 写,而是先懂交互,再用 AI。
所以这份专题不想教你怎么调教 AI,它只想做一件事:
让你不写代码也能建立对前端交互的掌握,然后——再去用 AI。
每条目左侧是一个可交互 demo(点 Source 切到源码看实现),右侧是定义、适用场景、和直接能拷给 AI 的提示词模板。
下面开始。先从最基础的视觉反馈说起。
1 · Hover:鼠标悬停效果
Hover 是最基础的视觉反馈:鼠标移到元素上,元素发生轻微变化。它告诉用户「这里能点」。
常见场景:按钮、卡片、导航菜单、商品列表、操作入口。
常见效果:背景色变深、轻微上浮 + 阴影增强、图片放大、显示隐藏的操作按钮。
不要只说「卡片加点交互感」——「交互感」是虚词。把变化讲清楚:
为卡片添加 hover 效果:鼠标悬停时卡片向上位移 4px,阴影增强,过渡时间 200ms,动画要自然克制。同时显示原本隐藏的右上角操作按钮。注意 hover 在触屏上不存在,移动端要给一个等价的 active 态。
2 · Focus:聚焦状态
Focus 出现在输入框、搜索框、表单控件上。它让用户明确知道「我现在正在编辑这个字段」,同时也是无障碍的关键——键盘用户全靠 focus 导航。
常见效果:边框变色、出现 focus ring(轻微外发光)、label 上移、显示辅助文案。
很多 AI 生成的表单看起来像半成品,就是因为只写了默认状态,没处理 focus / error / disabled。把这些一起喂给 AI:
输入框需要明确的 focus 状态:聚焦时边框变蓝,并出现 3px 半透明蓝色 focus ring;失焦后恢复。校验失败时边框变红,下方显示错误文案,错误状态保留用户输入。focus ring 不是「视觉污染」,是基础设施。不要让 AI 用 outline: none 简单粗暴地干掉它。
3 · Pressed / Active:按下状态
Pressed 是用户按下按钮的瞬间反馈。细节很小,但缺了它,按钮就「死」了——用户不确定自己到底点上了没有。
常见效果:按钮缩小到 0.96–0.98、背景色加深、阴影减弱、模拟物理按压。
按钮按下时缩到 0.97 倍,背景色略微加深;松开后用 100ms 过渡恢复。不要做夸张的弹跳或跳色。记住:按钮不是蹦床。pressed 反馈的尺度是「能感觉到」,不是「看得见」。一旦视觉上明显,就过头了。
4 · Transition:过渡动画
Transition 是状态变化时的过渡效果,比如透明度变化、颜色变化、高度变化、位置变化、缩放变化。
没有 transition,页面会显得生硬;但 transition 太多,又会显得油腻。所以你可以给 AI 一个明确约束:
所有状态变化都使用自然的 transition,时长控制在 150ms 到 250ms,不要使用夸张弹跳、旋转、闪烁等效果。如果是后台系统,可以再补一句:
动效以提升反馈为主,不要做装饰性动画。很多 AI 一听「动效」,就容易开始表演。但产品里的动效不是为了表演,而是为了反馈。
5 · Loading:加载状态
很多新手用 AI 写前端只关注正常路径。但真实产品里,请求数据、提交表单、上传文件都需要 loading 状态——否则用户点击按钮后不知道系统有没有收到。
Loading 不是「转个圈」,它是一组协同动作:禁用按钮防止重复提交、改文案告知正在做什么、视觉上给一个进行中的指示。
提交按钮点击后进入 loading 状态:按钮禁用、左侧显示 spinner、文案从「提交」变为「提交中...」。请求成功后切到 success 态(绿色 + 对勾 + 「已提交」文案),1.5s 后恢复到默认态;失败时切到 error 态并显示错误文案。按钮自身就能承载完整的「idle → loading → success」状态机,比单独弹个 toast 更聚焦。
6 · Progress Bar:进度指示
进度条用来展示一个操作的完成比例或加载进度,让用户知道系统正在工作。
常见场景:文件上传、页面加载、数据导出、批量操作进度、表单保存。
实现一个进度条组件:- 确定进度:根据百分比填充宽度,带平滑过渡动画- 显示当前百分比文字- indeterminate 不确定进度:使用条纹动画表示加载中- 完成后进度条颜色变为绿色(success 态)- 提供「开始」和「重置」按钮控制进度模拟进度条还有一个常被遗忘的细节:操作完成后要让进度状态明确停留(比如变绿、对勾),而不是直接消失。用户需要「看到」成功反馈。
7 · Skeleton:骨架屏
Skeleton 是另一种 loading 形态。不是简单地转个圈,而是用灰色占位块预先模拟真实页面结构。读者在数据回来前就能感知到「这里有 3 张卡片,每张卡有标题和描述」。
适合场景:列表页、卡片流、详情页、信息流。简言之,结构稳定、占位有意义的地方都比 spinner 强。
列表数据加载时使用 skeleton:占位结构需要接近真实卡片布局——标题 60% 宽、描述 90% 宽两行、底部 meta 40% 宽;用 1.4s 的 shimmer 动画提示加载中。数据回来后平滑替换为真实内容。骨架屏的灵魂是「形状对得上」。如果 skeleton 和真实内容布局差太远,切换时会跳,反而比 spinner 更糟。
8 · Empty State:空状态
空状态是最容易被忽略的交互。用户第一次进入项目列表、还没创建任何项目时,页面不能只是空白——空白会让人怀疑:加载失败了?没权限?系统坏了?
好的空状态应该回答两件事:当前为什么是空的,以及下一步可以做什么。
当列表为空时显示 empty state:- 一个简洁的图标(线性风格,避免插画过度)- 一句说明文案,例如「还没有项目」- 一句副文案告诉用户这个页面是做什么的- 一个主 CTA 按钮,引导用户去创建搜索无结果场景:文案改为「没有找到匹配结果」,CTA 改为「清空筛选条件」。空状态不是边角料。新用户的第一印象,就从这里开始。
9 · Error State:错误状态
Error state 是请求失败、权限不足、表单校验失败时的展示状态。
很多 AI 生成的页面最大的问题是:只处理成功,不处理失败。但真实世界里,请求会失败,网络会抖,接口会报错,用户会乱填。所以错误状态不是异常情况,错误状态是产品体验的一部分。
请求失败时显示 error state,不要让页面空白。展示错误说明和「重新加载」按钮,用户点击后重新请求数据。表单错误要更具体:
表单提交失败时,在对应字段下方显示错误文案,并保留用户已经输入的内容,不要清空表单。权限错误也不要只丢一个 403:
如果用户没有权限访问该页面,显示权限不足状态,说明原因,并提供返回首页按钮。10 · Toast:轻提示
Toast 是一种轻量反馈,常用于保存成功、删除成功、复制成功、操作失败、网络异常。它通常出现在页面右上角、顶部或底部,几秒后自动消失。
操作成功后显示 toast 提示,位置在右上角,持续 2 秒后自动消失;失败时显示红色错误 toast,并保留明确的错误文案。复制按钮可以这样写:
用户点击复制按钮后,显示 toast:「已复制到剪贴板」,持续 2 秒后自动消失。Toast 适合轻提示,但不要什么都用 toast。删除项目、支付确认、重要配置变更这类不可逆操作,需要 modal 做二次确认。
11 · Tooltip:悬浮提示
Tooltip 是鼠标悬停时出现的小提示,适合解释一个图标、字段、按钮的含义。
为信息图标添加 tooltip,鼠标悬停时显示字段说明,位置在图标上方,内容不要超过一行。禁用按钮也可以用 tooltip 解释原因:
禁用按钮 hover 时显示 tooltip,解释为什么当前不可点击。Tooltip 适合短内容。不要把一大段说明塞进 tooltip。如果内容比较长,可以用 popover。
12 · Popover:气泡卡片
Popover 和 tooltip 有点像,但它能承载更多内容。Tooltip 通常是短文本,Popover 可以放说明、链接、小表单、快捷操作。
点击「更多信息」后显示 popover,内容包含一段说明和一个「查看详情」链接;点击外部区域时关闭。筛选面板可以这样说:
点击筛选按钮后显示 popover,里面包含状态筛选、时间范围筛选和重置按钮。Popover 适合轻量但不至于简单到 tooltip 的内容。
13 · Dropdown:下拉菜单
Dropdown 是点击某个入口后出现的菜单。
常见场景:用户头像菜单、更多操作、选择器、排序条件、批量操作。
头像点击后显示 dropdown 菜单,包含个人设置、账单、退出登录三个选项;点击页面其他区域时自动关闭,下拉出现时添加 150ms 的淡入和位移动画。更多操作按钮可以这样说:
卡片右上角的更多按钮点击后显示 dropdown,包含「编辑 / 复制 / 删除」三个操作。删除操作使用危险色,并点击后打开确认 modal。Dropdown 的细节别漏:点击外部要关闭、菜单项要有 hover 状态、当前选中项最好高亮、危险操作要有区分。
14 · Accordion:折叠面板
Accordion 是折叠面板,常见于 FAQ、文档说明、设置项分组。用户点击标题,内容展开;再次点击,内容收起。
FAQ 使用 accordion 展示。默认只展开第一项,点击其他问题时展开对应答案,并自动收起之前展开的项。如果允许多个展开:
设置页面使用 accordion 分组展示,每个分组可以独立展开或收起,多个分组可以同时展开。Accordion 的好处是节省空间,但不要滥用。如果内容本来就很重要,不应该强行折叠起来让用户自己找。
15 · Tabs:标签页切换
Tabs 用来切换同一层级下的不同内容。
常见场景:概览 / 成员 / 设置、全部 / 进行中 / 已完成、基础信息 / 高级配置 / 日志。
页面顶部使用 tabs 切换内容,包含「概览 / 成员 / 设置」三个 tab;当前 tab 需要高亮,下方内容切换时使用轻微 fade transition。订单列表可以这样写:
订单列表使用 tabs 区分「全部 / 待支付 / 已完成 / 已取消」,切换 tab 时刷新列表并保留 loading 状态。Tabs 的重点是:内容之间应该是平级关系。如果不是平级内容,不要硬做 tabs。
16 · Stepper:步骤条
Stepper 把多步骤流程拆成清晰的阶段,让用户知道「自己在哪、还有几步」。
常见场景:注册流程、下单结算、配置向导、设置步骤。
实现一个步骤条组件:- 水平展示 4 个步骤,每个步骤有编号和标签- 已完成步骤显示实心高亮,当前步骤有边框高亮,未完成步骤置灰- 步骤之间用连线连接,已完成的连线高亮- 下方有「上一步」和「下一步」按钮- 第一步时「上一步」禁用,最后一步时「下一步」改为「完成」- 切换步骤时更新内容区展示当前步骤信息Stepper 适合步骤固定的表单,如果流程分支太多,可以考虑使用向导布局而不是步骤条。
17 · Carousel:轮播图
Carousel 是内容轮播组件,通常用于首页 Banner、推荐内容、作品展示。
常见场景:官网首屏 Banner、推荐文章展示、产品截图轮播、用户评价展示。
实现一个轮播图组件:- 4 张内容幻灯片,自动轮播间隔 3 秒- 底部有圆点指示器,点击可跳转到对应幻灯片- 左右两侧有前进 / 后退箭头按钮- 切换动画为水平滑入,时长 320ms- 鼠标悬停时暂停自动播放(如有)轮播图的常见问题是:自动播放太频繁、切换动画太慢、触摸滑动不支持。写提示词时可以加上「所有过渡自然克制」来限制动画幅度。
18 · Pagination:分页
分页组件把大量数据分成多页展示,是后台管理系统最常用的导航模式。
常见场景:数据表格、搜索结果、文章列表、订单列表。
实现一个分页组件:- 展示当前页、总页数和总条数- 页码按钮包含:首页、尾页、当前页前后各 1 页- 页码过多时用省略号(…)折叠中间页- 上一页 / 下一页箭头按钮- 第一页时上一页禁用,最后一页时下一页禁用- 点击页码跳转到对应页并刷新列表数据- 页码切换时有微弱的列表项入场动画移动端分页建议改为「加载更多」按钮或无限滚动,因为精确的页码按钮在窄屏上难点击。
19 · Modal / Dialog:弹窗
Modal 是弹窗,通常用于需要用户集中注意力处理的事情。
常见场景:删除确认、创建项目、编辑信息、登录注册、重要提示、表单填写。
Modal 的特点是:它会打断当前流程,让用户先处理弹窗里的事情。
删除操作需要二次确认弹窗:点击删除后打开 modal,背景加半透明遮罩;弹窗包含标题、说明文案、取消按钮和确认删除按钮;点击取消或 ESC 关闭,点击确认后执行删除。创建表单可以这样说:
点击「新建项目」后打开 modal,里面包含项目名称和描述输入框。提交时进入 loading 状态,成功后关闭 modal 并刷新列表,失败时在 modal 内显示错误信息。Modal 不是万能容器。如果只是展示侧边详情,用 drawer 可能更合适。
20 · Drawer:抽屉
Drawer 是从页面侧边滑出的面板,常见方向是从右侧或左侧滑出。
适合场景:详情预览、设置面板、筛选条件、移动端菜单、保持当前页面上下文的编辑操作。
Modal 是打断流程,Drawer 更像是在当前页面旁边展开一块内容。
点击列表项后,从右侧滑出 drawer 展示详情。drawer 宽度为 420px,背景页面保留但加遮罩,关闭时向右滑出。移动端菜单可以这样说:
移动端点击汉堡菜单后,从左侧滑出 drawer 导航菜单,背景加遮罩,点击遮罩或关闭按钮时收起。Drawer 很适合后台管理系统,因为它可以让用户在不离开列表页的情况下查看详情。
21 · Sticky:吸顶 / 固定
Sticky 是指元素在页面滚动时固定在某个位置。
常见场景:顶部导航栏、表格表头、侧边目录、操作栏、筛选栏。
顶部导航栏使用 sticky 效果,页面向下滚动时固定在顶部,并添加轻微阴影区分内容区域。表格可以这样说:
数据表格的表头需要 sticky,在纵向滚动时保持固定,方便用户查看列名。文档页可以这样说:
文章详情页右侧目录使用 sticky 定位,用户滚动正文时目录保持可见,并高亮当前阅读章节。Sticky 很实用,特别是数据表格、文档站、管理后台这些场景。
22 · Scroll Animation:滚动动画
Scroll animation 是页面滚动时触发的动画,常见于官网、落地页、作品集、活动页。比如页面滚动到某一区域时,卡片逐个淡入。
页面滚动到对应区域时,卡片按顺序淡入并向上移动 12px,动画需要轻量,不要影响阅读。官网首屏下方的功能模块可以这样写:
官网首屏下方的功能模块,在进入视口时使用 fade-in-up 动画,延迟依次递增 80ms。这类动画适合官网和展示型页面。如果是后台管理系统,过多滚动动画只会增加干扰。
23 · Autocomplete:自动补全
Autocomplete 是带下拉建议的输入框,用户输入时自动匹配候选列表。
常见场景:搜索栏、标签选择、用户选择器、地址补全、技术栈选择。
实现搜索自动补全组件:- 输入内容后展示匹配的下拉列表- 匹配文本高亮显示- 支持键盘上下键导航和 Enter 选择- 无匹配结果时显示「无匹配结果」- 选择后显示已选标签,标签可移除- 点击外部区域关闭下拉列表- 按 Escape 键关闭下拉列表数据量小的时候用本地过滤就行。如果是从后端搜索,需要加 debounce(300ms)和 loading 状态。
24 · Drag & Drop:拖拽排序
拖拽排序允许用户通过鼠标(或触屏)直接拖动列表项来改变顺序。
常见场景:看板任务排序、收藏夹整理、图片排序、待办列表优先级调整。
HTML5 原生 draggable 属性已能基本满足拖拽需求,不需要额外库。
实现一个可拖拽排序的列表:- 每一项左侧显示拖拽手柄图标(⠿)- 鼠标悬停时显示可拖拽提示- 拖拽过程中原项半透明,目标位置高亮- 在目标项上方或下方插入,取决于鼠标释放位置- 拖拽结束后恢复原始透明度- 不要使用第三方库,使用原生 HTML5 drag & drop API移动端 drag & drop 支持有限。如果移动端也需要拖拽排序,建议使用 touch 事件实现,或者把排序改为上移 / 下移按钮操作。
25 · Resizable Panel:可调整面板
可调整面板允许用户通过拖拽分隔线来调整左右(或上下)区域的大小比例。
常见场景:IDE 编辑器布局、后台管理系统侧边栏、邮件客户端、文件管理器。
实现一个可拖拽调整大小的分栏布局:- 左右两个面板,中间有一条可拖拽的分隔线- 分隔线 hover 时颜色变化,提示可拖拽- 拖拽过程中实时调整面板宽度- 左面板最小宽度 20%,最大宽度 65%- 鼠标松开后保持调整后的比例- 分隔线拖拽时变为 active 状态可调整面板的要点是:最小 / 最大宽度限制(防止面板被拖到消失),以及拖拽区域足够宽(至少 4px,建议 8px)以便鼠标精准定位。
26 · Micro-interaction:微交互
Micro-interaction 是很小的交互反馈,通常发生在一个很小的动作之后。比如点赞后图标填充、收藏后按钮变成已收藏、复制后文案变成「已复制」、开关切换时滑块移动。
复制按钮点击后,将按钮文案临时改为「已复制」,持续 1.5 秒后恢复;同时显示一个轻微的成功状态动画。或者:
收藏按钮点击后,图标从空心变为实心,并有一个轻微的 scale 动画,表示操作成功。微交互不一定要炫。它的价值是让用户感受到操作被系统接收了。产品从「能用」到「好用」,很多时候差的就是这些小反馈。
27 · Responsive Interaction:响应式交互
响应式不只是页面宽度变化,它还包括交互方式的变化。桌面端有鼠标可以 hover,但移动端没有 hover。所以很多桌面端交互,到了移动端需要重新设计。
比如:
- 桌面端导航 hover 展开 → 移动端改成点击汉堡菜单打开 drawer
- 桌面端表格展示很多列 → 移动端改成卡片列表
- 桌面端 tooltip → 移动端改成点击查看说明
请处理响应式交互:桌面端导航菜单 hover 展开,移动端改为点击汉堡按钮打开 drawer;不要依赖 hover 作为移动端唯一交互。或者:
桌面端使用表格展示数据,移动端改为卡片列表展示,保证主要操作按钮在移动端容易点击。只要你做的是面向真实用户的页面,就一定要补一句:
请同时考虑移动端交互,不要依赖 hover。给 AI 写前端交互提示词的通用模板
如果你不知道怎么写,可以直接套这个模板。
请实现一个【组件/页面名称】。使用场景:- 这个组件用于【具体业务场景】用户操作:- 用户可以【点击 / 悬停 / 输入 / 拖拽 / 滚动】- 当用户【某个操作】时,页面应该【具体反馈】状态要求:- 默认状态:- hover 状态:- active / pressed 状态:- loading 状态:- success 状态:- error 状态:- empty 状态:- disabled 状态:动效要求:- 使用自然克制的过渡动画- 动画时长控制在 150ms 到 250ms- 不要使用夸张弹跳、旋转、闪烁等装饰性动画响应式要求:- 桌面端:- 移动端:- 移动端不要依赖 hover 作为唯一交互技术要求:- 代码结构清晰- 组件状态可维护- 不要只写静态样式,要处理真实交互状态这个模板的关键不是形式,而是它提醒你:不要只描述 UI,还要描述状态和反馈。
一个具体例子:项目卡片组件
很多人会这样说:
帮我写一个项目卡片,要好看一点,有交互。这个提示词基本等于把方向盘交给 AI。更好的写法是:
请实现一个项目卡片组件,用于展示项目列表。卡片内容包括:- 项目名称- 项目描述- 更新时间- 状态标签- 右上角更多操作按钮交互要求:- 鼠标 hover 卡片时,卡片轻微上浮 4px,阴影增强,过渡时间 200ms- hover 时右上角更多操作按钮从透明变为可见- 点击更多操作按钮时,显示 dropdown 菜单,包含「编辑 / 复制 / 删除」- 点击删除时弹出确认 modal,不要直接删除- 删除成功后显示 toast:「项目已删除」- 请求处理中需要 loading 状态,避免重复点击状态要求:- loading 时显示 skeleton- 空列表时显示 empty state,并提供「创建项目」按钮- 请求失败时显示 error state,并提供「重新加载」按钮风格要求:- 视觉克制、现代、接近 Linear / Vercel 后台风格- 不要使用夸张动画- 移动端下卡片宽度自适应,更多操作通过点击触发这个提示词没有写任何复杂代码,但它把交互说清楚了。AI 拿到它,生成结果会比「好看一点」强太多。
不要只让 AI 写静态页面
很多人用 AI 写前端,最容易掉进一个坑:只让 AI 写静态页面。
比如:
帮我写一个 Dashboard 页面。AI 会给你:左侧菜单、顶部导航、几张数据卡片、一个图表区域、一个表格。看起来好像完成了。
但真实的 Dashboard 不是截图。它应该处理:数据加载中、加载失败、数据为空、筛选条件变化、时间范围切换、表格分页、操作成功 / 失败、移动端布局。
所以你应该这样描述:
请实现一个 Dashboard 页面,不要只写静态 UI,需要处理 loading、empty、error 三种状态。数据卡片加载时显示 skeleton,请求失败时显示错误说明和重试按钮,筛选条件变化时重新加载数据并显示局部 loading。AI Coding 时代非常重要的能力是:你不是在描述一个静态界面,而是在描述一个会变化的系统。
最后
重点不是背单词,而是建立一种意识:前端不是静态截图,而是一系列状态变化。
你不需要成为资深前端,但你需要知道:什么是好结果、如何描述好结果、如何判断坏结果。下次用 AI 写前端,不要只说「帮我做得高级一点」。你可以说:
请把这个页面当成真实产品来实现,不要只写静态 UI。需要处理 hover、focus、loading、empty、error、success、disabled 等状态。交互反馈要明确,动效要克制,过渡时间控制在 150ms 到 250ms。移动端不要依赖 hover,需要提供点击触发的替代交互。AI 不怕你啰嗦,AI 怕你含糊。