交互教程 · 27

AI 写前端之前:先掌握这些交互

市面上的 AI 写前端教程都在教你怎么写提示词,但没人告诉你——先懂交互,再用 AI。这份专题把 27 个最关键的交互术语挨个跑给你看,每条都有可交互 demo,帮你建立自己的交互判断力。

步骤 1 · Hover · 鼠标悬停

查看源码
html
<!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 是最基础的视觉反馈:鼠标移到元素上,元素发生轻微变化。它告诉用户「这里能点」。

常见场景:按钮、卡片、导航菜单、商品列表、操作入口。

常见效果:背景色变深、轻微上浮 + 阴影增强、图片放大、显示隐藏的操作按钮。

不要只说「卡片加点交互感」——「交互感」是虚词。把变化讲清楚:

text
为卡片添加 hover 效果:鼠标悬停时卡片向上位移 4px,阴影增强,过渡时间 200ms,动画要自然克制。同时显示原本隐藏的右上角操作按钮。

注意 hover 在触屏上不存在,移动端要给一个等价的 active 态。

步骤 2 · Focus · 输入聚焦

查看源码
html
<!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:

text
输入框需要明确的 focus 状态:聚焦时边框变蓝,并出现 3px 半透明蓝色 focus ring;失焦后恢复。校验失败时边框变红,下方显示错误文案,错误状态保留用户输入。

focus ring 不是「视觉污染」,是基础设施。不要让 AI 用 outline: none 简单粗暴地干掉它。

步骤 3 · Pressed · 按下反馈

查看源码
html
<!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 &amp; 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、背景色加深、阴影减弱、模拟物理按压。

text
按钮按下时缩到 0.97 倍,背景色略微加深;松开后用 100ms 过渡恢复。不要做夸张的弹跳或跳色。

记住:按钮不是蹦床。pressed 反馈的尺度是「能感觉到」,不是「看得见」。一旦视觉上明显,就过头了。

步骤 4 · Transition · 状态过渡

查看源码
html
<!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 一个明确约束:

text
所有状态变化都使用自然的 transition,时长控制在 150ms 到 250ms,不要使用夸张弹跳、旋转、闪烁等效果。

如果是后台系统,可以再补一句:

text
动效以提升反馈为主,不要做装饰性动画。

很多 AI 一听「动效」,就容易开始表演。但产品里的动效不是为了表演,而是为了反馈。

步骤 5 · Loading · 提交反馈

查看源码
html
<!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 不是「转个圈」,它是一组协同动作:禁用按钮防止重复提交、改文案告知正在做什么、视觉上给一个进行中的指示。

text
提交按钮点击后进入 loading 状态:按钮禁用、左侧显示 spinner、文案从「提交」变为「提交中...」。请求成功后切到 success 态(绿色 + 对勾 + 「已提交」文案),1.5s 后恢复到默认态;失败时切到 error 态并显示错误文案。

按钮自身就能承载完整的「idle → loading → success」状态机,比单独弹个 toast 更聚焦。

步骤 6 · Progress · 进度指示

查看源码
html
<!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:进度指示

进度条用来展示一个操作的完成比例或加载进度,让用户知道系统正在工作。

常见场景:文件上传、页面加载、数据导出、批量操作进度、表单保存。

text
实现一个进度条组件:- 确定进度:根据百分比填充宽度,带平滑过渡动画- 显示当前百分比文字- indeterminate 不确定进度:使用条纹动画表示加载中- 完成后进度条颜色变为绿色(success 态)- 提供「开始」和「重置」按钮控制进度模拟

进度条还有一个常被遗忘的细节:操作完成后要让进度状态明确停留(比如变绿、对勾),而不是直接消失。用户需要「看到」成功反馈。

步骤 7 · Skeleton · 骨架屏

查看源码
html
<!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 强。

text
列表数据加载时使用 skeleton:占位结构需要接近真实卡片布局——标题 60% 宽、描述 90% 宽两行、底部 meta 40% 宽;用 1.4s 的 shimmer 动画提示加载中。数据回来后平滑替换为真实内容。

骨架屏的灵魂是「形状对得上」。如果 skeleton 和真实内容布局差太远,切换时会跳,反而比 spinner 更糟。

步骤 8 · Empty State · 空状态

查看源码
html
<!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:空状态

空状态是最容易被忽略的交互。用户第一次进入项目列表、还没创建任何项目时,页面不能只是空白——空白会让人怀疑:加载失败了?没权限?系统坏了?

好的空状态应该回答两件事:当前为什么是空的,以及下一步可以做什么

text
当列表为空时显示 empty state:- 一个简洁的图标(线性风格,避免插画过度)- 一句说明文案,例如「还没有项目」- 一句副文案告诉用户这个页面是做什么的- 一个主 CTA 按钮,引导用户去创建搜索无结果场景:文案改为「没有找到匹配结果」,CTA 改为「清空筛选条件」。

空状态不是边角料。新用户的第一印象,就从这里开始。

步骤 9 · Error State · 错误状态

查看源码
html
<!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 生成的页面最大的问题是:只处理成功,不处理失败。但真实世界里,请求会失败,网络会抖,接口会报错,用户会乱填。所以错误状态不是异常情况,错误状态是产品体验的一部分。

text
请求失败时显示 error state,不要让页面空白。展示错误说明和「重新加载」按钮,用户点击后重新请求数据。

表单错误要更具体:

text
表单提交失败时,在对应字段下方显示错误文案,并保留用户已经输入的内容,不要清空表单。

权限错误也不要只丢一个 403:

text
如果用户没有权限访问该页面,显示权限不足状态,说明原因,并提供返回首页按钮。

步骤 10 · Toast · 轻提示

查看源码
html
<!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 是一种轻量反馈,常用于保存成功、删除成功、复制成功、操作失败、网络异常。它通常出现在页面右上角、顶部或底部,几秒后自动消失。

text
操作成功后显示 toast 提示,位置在右上角,持续 2 秒后自动消失;失败时显示红色错误 toast,并保留明确的错误文案。

复制按钮可以这样写:

text
用户点击复制按钮后,显示 toast:「已复制到剪贴板」,持续 2 秒后自动消失。

Toast 适合轻提示,但不要什么都用 toast。删除项目、支付确认、重要配置变更这类不可逆操作,需要 modal 做二次确认。

步骤 11 · Tooltip · 悬浮提示

查看源码
html
<!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 是鼠标悬停时出现的小提示,适合解释一个图标、字段、按钮的含义。

text
为信息图标添加 tooltip,鼠标悬停时显示字段说明,位置在图标上方,内容不要超过一行。

禁用按钮也可以用 tooltip 解释原因:

text
禁用按钮 hover 时显示 tooltip,解释为什么当前不可点击。

Tooltip 适合短内容。不要把一大段说明塞进 tooltip。如果内容比较长,可以用 popover。

步骤 12 · Popover · 气泡卡片

查看源码
html
<!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 可以放说明、链接、小表单、快捷操作。

text
点击「更多信息」后显示 popover,内容包含一段说明和一个「查看详情」链接;点击外部区域时关闭。

筛选面板可以这样说:

text
点击筛选按钮后显示 popover,里面包含状态筛选、时间范围筛选和重置按钮。

Popover 适合轻量但不至于简单到 tooltip 的内容。

步骤 13 · Dropdown · 下拉菜单

查看源码
html
<!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 是点击某个入口后出现的菜单。

常见场景:用户头像菜单、更多操作、选择器、排序条件、批量操作。

text
头像点击后显示 dropdown 菜单,包含个人设置、账单、退出登录三个选项;点击页面其他区域时自动关闭,下拉出现时添加 150ms 的淡入和位移动画。

更多操作按钮可以这样说:

text
卡片右上角的更多按钮点击后显示 dropdown,包含「编辑 / 复制 / 删除」三个操作。删除操作使用危险色,并点击后打开确认 modal。

Dropdown 的细节别漏:点击外部要关闭、菜单项要有 hover 状态、当前选中项最好高亮、危险操作要有区分。

步骤 14 · Accordion · 折叠面板

查看源码
html
<!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、文档说明、设置项分组。用户点击标题,内容展开;再次点击,内容收起。

text
FAQ 使用 accordion 展示。默认只展开第一项,点击其他问题时展开对应答案,并自动收起之前展开的项。

如果允许多个展开:

text
设置页面使用 accordion 分组展示,每个分组可以独立展开或收起,多个分组可以同时展开。

Accordion 的好处是节省空间,但不要滥用。如果内容本来就很重要,不应该强行折叠起来让用户自己找。

步骤 15 · Tabs · 标签页

查看源码
html
<!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 用来切换同一层级下的不同内容。

常见场景:概览 / 成员 / 设置、全部 / 进行中 / 已完成、基础信息 / 高级配置 / 日志。

text
页面顶部使用 tabs 切换内容,包含「概览 / 成员 / 设置」三个 tab;当前 tab 需要高亮,下方内容切换时使用轻微 fade transition。

订单列表可以这样写:

text
订单列表使用 tabs 区分「全部 / 待支付 / 已完成 / 已取消」,切换 tab 时刷新列表并保留 loading 状态。

Tabs 的重点是:内容之间应该是平级关系。如果不是平级内容,不要硬做 tabs。

步骤 16 · Stepper · 步骤条

查看源码
html
<!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 把多步骤流程拆成清晰的阶段,让用户知道「自己在哪、还有几步」。

常见场景:注册流程、下单结算、配置向导、设置步骤。

text
实现一个步骤条组件:- 水平展示 4 个步骤,每个步骤有编号和标签- 已完成步骤显示实心高亮,当前步骤有边框高亮,未完成步骤置灰- 步骤之间用连线连接,已完成的连线高亮- 下方有「上一步」和「下一步」按钮- 第一步时「上一步」禁用,最后一步时「下一步」改为「完成」- 切换步骤时更新内容区展示当前步骤信息

Stepper 适合步骤固定的表单,如果流程分支太多,可以考虑使用向导布局而不是步骤条。

步骤 17 · Carousel · 轮播图

查看源码
html
<!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、推荐文章展示、产品截图轮播、用户评价展示。

text
实现一个轮播图组件:- 4 张内容幻灯片,自动轮播间隔 3 秒- 底部有圆点指示器,点击可跳转到对应幻灯片- 左右两侧有前进 / 后退箭头按钮- 切换动画为水平滑入,时长 320ms- 鼠标悬停时暂停自动播放(如有)

轮播图的常见问题是:自动播放太频繁、切换动画太慢、触摸滑动不支持。写提示词时可以加上「所有过渡自然克制」来限制动画幅度。

步骤 18 · Pagination · 分页

查看源码
html
<!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:分页

分页组件把大量数据分成多页展示,是后台管理系统最常用的导航模式。

常见场景:数据表格、搜索结果、文章列表、订单列表。

text
实现一个分页组件:- 展示当前页、总页数和总条数- 页码按钮包含:首页、尾页、当前页前后各 1 页- 页码过多时用省略号(…)折叠中间页- 上一页 / 下一页箭头按钮- 第一页时上一页禁用,最后一页时下一页禁用- 点击页码跳转到对应页并刷新列表数据- 页码切换时有微弱的列表项入场动画

移动端分页建议改为「加载更多」按钮或无限滚动,因为精确的页码按钮在窄屏上难点击。

步骤 19 · Modal · 二次确认

查看源码
html
<!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 的特点是:它会打断当前流程,让用户先处理弹窗里的事情。

text
删除操作需要二次确认弹窗:点击删除后打开 modal,背景加半透明遮罩;弹窗包含标题、说明文案、取消按钮和确认删除按钮;点击取消或 ESC 关闭,点击确认后执行删除。

创建表单可以这样说:

text
点击「新建项目」后打开 modal,里面包含项目名称和描述输入框。提交时进入 loading 状态,成功后关闭 modal 并刷新列表,失败时在 modal 内显示错误信息。

Modal 不是万能容器。如果只是展示侧边详情,用 drawer 可能更合适。

步骤 20 · Drawer · 侧边抽屉

查看源码
html
<!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 更像是在当前页面旁边展开一块内容。

text
点击列表项后,从右侧滑出 drawer 展示详情。drawer 宽度为 420px,背景页面保留但加遮罩,关闭时向右滑出。

移动端菜单可以这样说:

text
移动端点击汉堡菜单后,从左侧滑出 drawer 导航菜单,背景加遮罩,点击遮罩或关闭按钮时收起。

Drawer 很适合后台管理系统,因为它可以让用户在不离开列表页的情况下查看详情。

步骤 21 · Sticky · 吸顶固定

查看源码
html
<!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 是指元素在页面滚动时固定在某个位置。

常见场景:顶部导航栏、表格表头、侧边目录、操作栏、筛选栏。

text
顶部导航栏使用 sticky 效果,页面向下滚动时固定在顶部,并添加轻微阴影区分内容区域。

表格可以这样说:

text
数据表格的表头需要 sticky,在纵向滚动时保持固定,方便用户查看列名。

文档页可以这样说:

text
文章详情页右侧目录使用 sticky 定位,用户滚动正文时目录保持可见,并高亮当前阅读章节。

Sticky 很实用,特别是数据表格、文档站、管理后台这些场景。

步骤 22 · Scroll Animation · 滚动触发

查看源码
html
<!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 是页面滚动时触发的动画,常见于官网、落地页、作品集、活动页。比如页面滚动到某一区域时,卡片逐个淡入。

text
页面滚动到对应区域时,卡片按顺序淡入并向上移动 12px,动画需要轻量,不要影响阅读。

官网首屏下方的功能模块可以这样写:

text
官网首屏下方的功能模块,在进入视口时使用 fade-in-up 动画,延迟依次递增 80ms。

这类动画适合官网和展示型页面。如果是后台管理系统,过多滚动动画只会增加干扰。

步骤 23 · Autocomplete · 自动补全

查看源码
html
<!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 是带下拉建议的输入框,用户输入时自动匹配候选列表。

常见场景:搜索栏、标签选择、用户选择器、地址补全、技术栈选择。

text
实现搜索自动补全组件:- 输入内容后展示匹配的下拉列表- 匹配文本高亮显示- 支持键盘上下键导航和 Enter 选择- 无匹配结果时显示「无匹配结果」- 选择后显示已选标签,标签可移除- 点击外部区域关闭下拉列表- 按 Escape 键关闭下拉列表

数据量小的时候用本地过滤就行。如果是从后端搜索,需要加 debounce(300ms)和 loading 状态。

步骤 24 · Drag & Drop · 拖拽排序

查看源码
html
<!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 属性已能基本满足拖拽需求,不需要额外库。

text
实现一个可拖拽排序的列表:- 每一项左侧显示拖拽手柄图标(⠿)- 鼠标悬停时显示可拖拽提示- 拖拽过程中原项半透明,目标位置高亮- 在目标项上方或下方插入,取决于鼠标释放位置- 拖拽结束后恢复原始透明度- 不要使用第三方库,使用原生 HTML5 drag & drop API

移动端 drag & drop 支持有限。如果移动端也需要拖拽排序,建议使用 touch 事件实现,或者把排序改为上移 / 下移按钮操作。

步骤 25 · Resizable · 可调面板

查看源码
html
<!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 编辑器布局、后台管理系统侧边栏、邮件客户端、文件管理器。

text
实现一个可拖拽调整大小的分栏布局:- 左右两个面板,中间有一条可拖拽的分隔线- 分隔线 hover 时颜色变化,提示可拖拽- 拖拽过程中实时调整面板宽度- 左面板最小宽度 20%,最大宽度 65%- 鼠标松开后保持调整后的比例- 分隔线拖拽时变为 active 状态

可调整面板的要点是:最小 / 最大宽度限制(防止面板被拖到消失),以及拖拽区域足够宽(至少 4px,建议 8px)以便鼠标精准定位。

步骤 26 · Micro-interaction · 微交互

查看源码
html
<!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 是很小的交互反馈,通常发生在一个很小的动作之后。比如点赞后图标填充、收藏后按钮变成已收藏、复制后文案变成「已复制」、开关切换时滑块移动。

text
复制按钮点击后,将按钮文案临时改为「已复制」,持续 1.5 秒后恢复;同时显示一个轻微的成功状态动画。

或者:

text
收藏按钮点击后,图标从空心变为实心,并有一个轻微的 scale 动画,表示操作成功。

微交互不一定要炫。它的价值是让用户感受到操作被系统接收了。产品从「能用」到「好用」,很多时候差的就是这些小反馈。

步骤 27 · Responsive · 响应式交互

查看源码
html
<!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 → 移动端改成点击查看说明
text
请处理响应式交互:桌面端导航菜单 hover 展开,移动端改为点击汉堡按钮打开 drawer;不要依赖 hover 作为移动端唯一交互。

或者:

text
桌面端使用表格展示数据,移动端改为卡片列表展示,保证主要操作按钮在移动端容易点击。

只要你做的是面向真实用户的页面,就一定要补一句:

text
请同时考虑移动端交互,不要依赖 hover。

给 AI 写前端交互提示词的通用模板

如果你不知道怎么写,可以直接套这个模板。

text
请实现一个【组件/页面名称】。
使用场景:- 这个组件用于【具体业务场景】
用户操作:- 用户可以【点击 / 悬停 / 输入 / 拖拽 / 滚动】- 当用户【某个操作】时,页面应该【具体反馈】
状态要求:- 默认状态:- hover 状态:- active / pressed 状态:- loading 状态:- success 状态:- error 状态:- empty 状态:- disabled 状态:
动效要求:- 使用自然克制的过渡动画- 动画时长控制在 150ms 到 250ms- 不要使用夸张弹跳、旋转、闪烁等装饰性动画
响应式要求:- 桌面端:- 移动端:- 移动端不要依赖 hover 作为唯一交互
技术要求:- 代码结构清晰- 组件状态可维护- 不要只写静态样式,要处理真实交互状态

这个模板的关键不是形式,而是它提醒你:不要只描述 UI,还要描述状态和反馈。

一个具体例子:项目卡片组件

很多人会这样说:

text
帮我写一个项目卡片,要好看一点,有交互。

这个提示词基本等于把方向盘交给 AI。更好的写法是:

text
请实现一个项目卡片组件,用于展示项目列表。
卡片内容包括:- 项目名称- 项目描述- 更新时间- 状态标签- 右上角更多操作按钮
交互要求:- 鼠标 hover 卡片时,卡片轻微上浮 4px,阴影增强,过渡时间 200ms- hover 时右上角更多操作按钮从透明变为可见- 点击更多操作按钮时,显示 dropdown 菜单,包含「编辑 / 复制 / 删除」- 点击删除时弹出确认 modal,不要直接删除- 删除成功后显示 toast:「项目已删除」- 请求处理中需要 loading 状态,避免重复点击
状态要求:- loading 时显示 skeleton- 空列表时显示 empty state,并提供「创建项目」按钮- 请求失败时显示 error state,并提供「重新加载」按钮
风格要求:- 视觉克制、现代、接近 Linear / Vercel 后台风格- 不要使用夸张动画- 移动端下卡片宽度自适应,更多操作通过点击触发

这个提示词没有写任何复杂代码,但它把交互说清楚了。AI 拿到它,生成结果会比「好看一点」强太多。

不要只让 AI 写静态页面

很多人用 AI 写前端,最容易掉进一个坑:只让 AI 写静态页面。

比如:

text
帮我写一个 Dashboard 页面。

AI 会给你:左侧菜单、顶部导航、几张数据卡片、一个图表区域、一个表格。看起来好像完成了。

但真实的 Dashboard 不是截图。它应该处理:数据加载中、加载失败、数据为空、筛选条件变化、时间范围切换、表格分页、操作成功 / 失败、移动端布局。

所以你应该这样描述:

text
请实现一个 Dashboard 页面,不要只写静态 UI,需要处理 loading、empty、error 三种状态。数据卡片加载时显示 skeleton,请求失败时显示错误说明和重试按钮,筛选条件变化时重新加载数据并显示局部 loading。

AI Coding 时代非常重要的能力是:你不是在描述一个静态界面,而是在描述一个会变化的系统。

最后

重点不是背单词,而是建立一种意识:前端不是静态截图,而是一系列状态变化

你不需要成为资深前端,但你需要知道:什么是好结果、如何描述好结果、如何判断坏结果。下次用 AI 写前端,不要只说「帮我做得高级一点」。你可以说:

text
请把这个页面当成真实产品来实现,不要只写静态 UI。
需要处理 hover、focus、loading、empty、error、success、disabled 等状态。
交互反馈要明确,动效要克制,过渡时间控制在 150ms 到 250ms。
移动端不要依赖 hover,需要提供点击触发的替代交互。

AI 不怕你啰嗦,AI 怕你含糊。