web-design-guidelines by ehmo/platform-design-skills
npx skills add https://github.com/ehmo/platform-design-skills --skill web-design-guidelines适用于无障碍、高性能、响应式 Web 界面的框架无关规则。基于 WCAG 2.2、MDN Web 文档和现代 Web 平台 API。
无障碍性不是可选项。本节中的大多数规则都映射到 WCAG 2.2 的 A 级或 AA 级成功标准。少数最佳实践规则(在文中注明)针对 AAA 级或超越了 WCAG。
按元素的预期用途使用它们。语义化结构提供免费的无障碍性、SEO 和阅读器模式支持。
| 元素 | 用途 |
|---|---|
<main> | 页面主要内容(每页一个) |
<nav> | 导航块 |
<header> | 介绍性内容或导航辅助 |
<footer> | 最近章节内容的页脚 |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
<article>| 自包含、可独立分发的内容 |
<section> | 带有标题的主题分组 |
<aside> | 附带相关内容(侧边栏、标注) |
<figure> / <figcaption> | 插图、图表、代码列表 |
<details> / <summary> | 可展开/折叠的披露小部件 |
<dialog> | 模态或非模态对话框 |
<time> | 机器可读的日期/时间 |
<mark> | 高亮/引用文本 |
<address> | 最近文章/正文的联系信息 |
<!-- 良好示例 -->
<main>
<article>
<h1>文章标题</h1>
<p>内容...</p>
</article>
<aside>相关链接</aside>
</main>
<!-- 不良示例:div 泛滥 -->
<div class="main">
<div class="article">
<div class="title">文章标题</div>
<div class="content">内容...</div>
</div>
</div>
反面模式:使用 <div> 或 <span> 作为交互元素。当存在 <button> 时,绝不要写 <div onclick>。
每个交互元素都必须有一个无障碍名称。优先使用可见文本;仅当可见文本不足时(SC 4.1.2)才使用 aria-label 或 aria-labelledby。
<!-- 纯图标按钮:需要 aria-label -->
<button aria-label="关闭对话框">
<svg aria-hidden="true">...</svg>
</button>
<!-- 通过 labelledby 关联 -->
<h2 id="section-title">通知</h2>
<ul aria-labelledby="section-title">...</ul>
<!-- 冗余:可见文本已足够 -->
<button>保存更改</button> <!-- 不需要 aria-label -->
所有交互元素必须可以通过键盘到达和操作(SC 2.1.1)。
使用原生交互元素(<button>、<a href>、<input>、<select>),它们默认支持键盘访问。
自定义小部件需要 tabindex="0" 以进入 Tab 键顺序,并需要 keydown 处理程序来激活。
绝不使用大于 0 的 tabindex 值。
在模态框内捕获焦点;关闭时返回焦点。
// 模态框的焦点捕获 dialog.addEventListener('keydown', (e) => { if (e.key === 'Tab') { const focusable = dialog.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } });
绝不要在未提供可见替代方案的情况下移除焦点轮廓(SC 2.4.7,以及 WCAG 2.2 中增强的 SC 2.4.11 (AA) 和 SC 2.4.12 (AAA))。
/* 良好示例:自定义焦点指示器 */
:focus-visible {
outline: 3px solid var(--focus-color, #4A90D9);
outline-offset: 2px;
}
/* 仅在支持 :focus-visible 时移除默认样式 */
:focus:not(:focus-visible) {
outline: none;
}
/* 不良示例:移除所有焦点样式 */
/* *:focus { outline: none; } */
WCAG 2.2 要求焦点指示器的最小面积为组件周长的 2 倍像素,并且与相邻颜色的对比度达到 3:1。
提供一种机制来跳过重复的内容块(SC 2.4.1)。
<body>
<a href="#main-content" class="skip-link">跳转到主要内容</a>
<nav>...</nav>
<main id="main-content">...</main>
</body>
.skip-link {
position: absolute;
top: -100%;
left: 0;
z-index: 1000;
padding: 0.75rem 1.5rem;
background: var(--color-primary);
color: var(--color-on-primary);
}
.skip-link:focus {
top: 0;
}
每个 <img> 都必须有一个 alt 属性(SC 1.1.1)。
信息性图像:描述内容和功能。alt="显示第四季度销售额翻倍的条形图"。
装饰性图像:使用 alt=""(空字符串),以便屏幕阅读器跳过它们。
功能性图像(在链接/按钮内):描述操作。alt="搜索"。
复杂图像:使用 alt 进行简短描述,链接到详细描述或使用 <figcaption>。
保持最低对比度比率(SC 1.4.3、1.4.6、1.4.11)。
| 内容 | 最低比率 |
|---|---|
| 普通文本(<24px / <18.66px 粗体) | 4.5:1 |
| 大文本(>=24px / >=18.66px 粗体) | 3:1 |
| UI 组件和图形对象 | 3:1 |
不要仅依赖颜色来传达信息(SC 1.4.1)。将颜色与图标、文本或图案结合使用。
/* 检查这些标记的对比度 */
:root {
--text-primary: #1a1a2e; /* 在白色上:约 16:1 */
--text-secondary: #555770; /* 在白色上:约 6.5:1 */
--text-disabled: #767693; /* 在白色上:约 4.5:1,临界值 */
}
每个表单输入都必须有一个程序化关联的标签(SC 1.3.1、3.3.2)。
<!-- 显式标签(首选) -->
<label for="email">电子邮件地址</label>
<input id="email" type="email" autocomplete="email">
<!-- 隐式标签(可接受) -->
<label>
电子邮件地址
<input type="email" autocomplete="email">
</label>
<!-- 绝不要:仅使用占位符作为标签 -->
<!-- <input placeholder="电子邮件"> -->
用文本识别和描述错误(SC 3.3.1)。使用 aria-describedby 或 aria-errormessage 将错误消息链接到输入。
<label for="email">电子邮件</label>
<input id="email" type="email" aria-describedby="email-error" aria-invalid="true">
<p id="email-error" role="alert">请输入有效的电子邮件地址,例如 name@example.com</p>
向屏幕阅读器宣布动态内容更改(SC 4.1.3)。
<!-- 礼貌型:用户空闲时宣布 -->
<div aria-live="polite" aria-atomic="true">
找到 3 个结果
</div>
<!-- 急切型:中断当前语音 -->
<div role="alert">
您的会话将在 2 分钟后过期。
</div>
<!-- 状态消息 -->
<div role="status">
文件上传成功。
</div>
默认使用 aria-live="polite"。保留 role="alert" / aria-live="assertive" 用于时间敏感的警告。
| 角色 | 用途 | 原生等效元素 |
|---|---|---|
button | 可点击操作 | <button> |
link | 导航 | <a href> |
tab / tablist / tabpanel | 标签页界面 | 无 |
dialog | 模态框 | <dialog> |
alert | 急切型实时区域 | 无 |
status | 礼貌型实时区域 | <output> |
navigation | 导航地标 | <nav> |
main | 主地标 | <main> |
complementary | 旁注地标 | <aside> |
search | 搜索地标 | <search> (HTML5) |
img | 图像 | <img> |
list / listitem | 列表 | <ul>/<li> |
heading | 标题(带 aria-level) | <h1>-<h6> |
menu / menuitem | 菜单小部件 | 无 |
tree / treeitem | 树状视图 | 无 |
grid / row / gridcell | 数据网格 | <table> |
progressbar | 进度条 | <progress> |
slider | 范围输入 | <input type="range"> |
switch | 切换开关 | <input type="checkbox"> |
规则:优先使用原生 HTML 而非 ARIA。仅当模式没有原生元素存在时才使用 ARIA。
当交互元素有可见文本时,其无障碍名称必须包含该可见文本作为子字符串(SC 2.5.3)。语音控制用户(Dragon NaturallySpeaking、macOS 语音控制)通过说出可见标签来激活控件。如果 aria-label 替换或与可见文本矛盾,语音命令将失败。
<!-- 正确:aria-label 包含可见文本作为子字符串 -->
<button aria-label="从购物车中删除商品">删除</button>
<!-- 正确:不需要 aria-label —— 可见文本就是无障碍名称 -->
<button>保存更改</button>
<!-- 正确:图标按钮 —— 无可见文本,aria-label 没问题 -->
<button aria-label="关闭对话框">
<svg aria-hidden="true">...</svg>
</button>
<!-- 错误:aria-label 用不同文本覆盖了可见文本 -->
<button aria-label="移除">删除</button>
<!-- 错误:aria-label 不包含可见的 "提交" -->
<button aria-label="继续下一步">提交</button>
规则:当存在可见文本时,aria-label 必须包含该可见文本(逐字,不区分大小写)。当可见文本足够时,最好完全不使用 aria-label。
为最小的视口编写基础样式。使用 min-width 媒体查询来增加复杂性。
/* 基础:移动端 */
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
/* 平板电脑 */
@media (min-width: 48rem) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* 桌面端 */
@media (min-width: 64rem) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
使用 clamp()、min() 和 max() 进行无需断点的流式尺寸调整。
/* 流式排版 */
h1 {
font-size: clamp(1.75rem, 1.2rem + 2vw, 3rem);
}
/* 流式间距 */
.section {
padding: clamp(1.5rem, 4vw, 4rem);
}
/* 流式容器 */
.container {
width: min(90%, 72rem);
margin-inline: auto;
}
基于组件自身的容器而非视口来调整组件尺寸。
.card-container {
container-type: inline-size;
container-name: card;
}
@container card (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 200px 1fr;
}
}
@container card (min-width: 700px) {
.card {
grid-template-columns: 300px 1fr;
gap: 2rem;
}
}
在内容布局断裂的地方设置断点,而不是在设备宽度处。常见的起始点:
/* 基于内容,而非 "iPhone" 或 "iPad" */
@media (min-width: 30rem) { /* ~480px:单列变得拥挤 */ }
@media (min-width: 48rem) { /* ~768px:可容纳 2 列 */ }
@media (min-width: 64rem) { /* ~1024px:可容纳侧边栏 + 内容 */ }
@media (min-width: 80rem) { /* ~1280px:宽的多列 */ }
触摸目标的最小尺寸为 44x44 CSS 像素(WCAG SC 2.5.5 AAA;SC 2.5.8 在 AA 级仅要求 24x24px)。相邻目标之间至少提供 24px 的间距。
button, a, input, select, textarea {
min-height: 44px;
min-width: 44px;
}
/* 在不改变视觉尺寸的情况下扩大点击区域 */
.icon-button {
position: relative;
width: 24px;
height: 24px;
}
.icon-button::after {
content: "";
position: absolute;
inset: -10px; /* 扩大可点击区域 */
}
始终包含在文档 <head> 中:
<meta name="viewport" content="width=device-width, initial-scale=1">
绝不要使用 maximum-scale=1 或 user-scalable=no —— 这会破坏捏合缩放的无障碍性(SC 1.4.4)。
内容必须在 320px 宽度下重新布局,而无需水平滚动(SC 1.4.10)。
/* 防止溢出 */
img, video, iframe, svg {
max-width: 100%;
height: auto;
}
/* 包含长单词/URL */
.prose {
overflow-wrap: break-word;
}
/* 表格:滚动容器,而非页面 */
.table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
每个输入都需要一个可见的、程序化关联的标签。参见第 1.8 节。
对常见字段使用 autocomplete 以启用浏览器自动填充(SC 1.3.5)。
<input type="text" autocomplete="name" name="full-name">
<input type="email" autocomplete="email" name="email">
<input type="tel" autocomplete="tel" name="phone">
<input type="text" autocomplete="street-address" name="address">
<input type="text" autocomplete="postal-code" name="zip">
<input type="text" autocomplete="cc-name" name="card-name">
<input type="text" autocomplete="cc-number" name="card-number">
<input type="password" autocomplete="new-password" name="password">
<input type="password" autocomplete="current-password" name="current-pw">
使用正确的 type 来触发适当的移动键盘和原生验证。
| 类型 | 用于 |
|---|---|
email | 电子邮件地址 |
tel | 电话号码 |
url | URL |
number | 带微调器的数值(不用于电话、邮政编码、卡号) |
search | 搜索字段(显示清除按钮) |
date / time / datetime-local | 时间值 |
password | 密码(触发密码管理器) |
text 配合 inputmode="numeric" | 不带微调器的数字数据(PIN 码、邮政编码) |
<input type="tel" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code">
在 blur 时验证(而不是每次按键)。显示成功和错误状态。
<div class="field" data-state="error">
<label for="username">用户名</label>
<input id="username" type="text" aria-describedby="username-hint username-error" aria-invalid="true">
<p id="username-hint" class="hint">3-20 个字符,仅限字母和数字</p>
<p id="username-error" class="error" role="alert">用户名必须至少 3 个字符</p>
</div>
.field[data-state="error"] input {
border-color: var(--color-error);
box-shadow: 0 0 0 1px var(--color-error);
}
.field[data-state="error"] .error { display: block; }
.field:not([data-state="error"]) .error { display: none; }
使用 <fieldset> 分组相关输入,并使用 <legend> 为分组添加标签。
<fieldset>
<legend>邮寄地址</legend>
<label for="street">街道</label>
<input id="street" type="text" autocomplete="street-address">
<!-- ... -->
</fieldset>
<fieldset>
<legend>首选联系方式</legend>
<label><input type="radio" name="contact" value="email"> 电子邮件</label>
<label><input type="radio" name="contact" value="phone"> 电话</label>
</fieldset>
以视觉和程序化方式指示必填字段。使用 required 属性和可见标记。
<label for="name">
全名 <span aria-hidden="true">*</span>
<span class="sr-only">(必填)</span>
</label>
<input id="name" type="text" required autocomplete="name">
如果大多数字段都是必填的,则改为指示哪些是可选的。
不要禁用提交按钮。相反,在提交时验证并显示错误。
<!-- 良好:始终启用,提交时验证 -->
<button type="submit">创建账户</button>
<!-- 不良:禁用的按钮没有解释 -->
<!-- <button type="submit" disabled>创建账户</button> -->
禁用的按钮无法传达用户无法继续的原因。如果必须禁用,请提供可见的解释。
通过提示和错误文本将格式示例、约束和恢复文本放在相关字段旁边。绝不要仅在介绍性文本中解释要求,并期望用户稍后记住它们。
使用系统字体堆栈以获得性能,或使用具有适当后备的 Web 字体。
/* 系统字体堆栈 */
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* 等宽字体堆栈 */
code, pre, kbd {
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
}
/* 带有后备和 size-adjust 的 Web 字体 */
@font-face {
font-family: "CustomFont";
src: url("/fonts/custom.woff2") format("woff2");
font-display: swap;
font-weight: 100 900;
}
body {
font-family: "CustomFont", system-ui, sans-serif;
}
使用 rem 设置字体大小和间距。使用 em 进行组件相对尺寸调整。
html {
font-size: 100%; /* = 默认 16px,尊重用户偏好 */
}
body {
font-size: 1rem; /* 16px */
}
h1 { font-size: 2.5rem; } /* 40px */
h2 { font-size: 2rem; } /* 32px */
h3 { font-size: 1.5rem; } /* 24px */
small { font-size: 0.875rem; } /* 14px */
/* 绝不要:font-size: 16px; (忽略用户缩放设置) */
正文文本行高至少为 1.5(SC 1.4.12)。段落间距至少为字体大小的 2 倍。
body {
line-height: 1.6;
}
h1, h2, h3 {
line-height: 1.2;
}
p + p {
margin-top: 1em;
}
将行长限制在大约 75 个字符以提高可读性。
.prose {
max-width: 75ch;
}
/* 或用于内容列 */
.content {
max-width: 40rem; /* 根据字体不同,大约 65-75ch */
margin-inline: auto;
}
对数据使用真正的引号、正确的破折号和等宽数字。
/* 智能引号 */
q { quotes: "\201C" "\201D" "\2018" "\2019"; } /* 先双引号后单引号 */
/* 用于对齐数据的等宽数字 */
.data-table td {
font-variant-numeric: tabular-nums;
}
/* 用于正文的旧式数字(可选) */
.prose {
font-variant-numeric: oldstyle-nums;
}
/* 正确的列表标记 */
ul { list-style-type: disc; }
ol { list-style-type: decimal; }
按顺序使用 h1 到 h6。绝不跳过层级。每页一个 h1。
<!-- 良好 -->
<h1>页面标题</h1>
<h2>章节</h2>
<h3>小节</h3>
<h2>另一个章节</h2>
<!-- 不良:跳过 h2 -->
<h1>页面标题</h1>
<h3>小节</h3> <!-- h2 在哪里? -->
如果您需要的视觉样式与层级不同,请使用 CSS 类:
<h2 class="text-lg">视觉上较小但语义上是 h2</h2>
对初始加载时不可见的图像使用原生延迟加载。
<!-- 首屏:急切加载,添加 fetchpriority -->
<img src="hero.webp" alt="主图" fetchpriority="high" width="1200" height="600">
<!-- 首屏以下:延迟加载 -->
<img src="feature.webp" alt="功能图" loading="lazy" width="600" height="400">
始终指定 width 和 height 以防止布局偏移(CLS)。
<img src="photo.webp" alt="描述" width="800" height="600">
/* 保持宽高比的响应式图像 */
img {
max-width: 100%;
height: auto;
}
对第三方源使用 preconnect,对关键资源使用 preload。
<head>
<!-- 预连接到关键的第三方源 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- 预加载关键资源 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/css/critical.css" as="style">
<!-- 对非关键源进行 DNS 预取 -->
<link rel="dns-prefetch" href="https://analytics.example.com">
</head>
仅在需要时加载 JavaScript。使用动态 import() 进行基于路由和基于组件的分割。
// 基于路由的分割
const routes = {
'/dashboard': () => import('./pages/dashboard.js'),
'/settings': () => import('./pages/settings.js'),
};
// 基于交互的分割
button.addEventListener('click', async () => {
const { openEditor } = await import('./editor.js');
openEditor();
});
对于超过几百项的列表,仅渲染可见行。
// 概念:虚拟滚动
// 仅渲染视口 + 缓冲区内的项目
const visibleStart = Math.floor(scrollTop / itemHeight);
const visibleEnd = visibleStart + Math.ceil(containerHeight / itemHeight);
const buffer = 5;
const renderStart = Math.max(0, visibleStart - buffer);
const renderEnd = Math.min(totalItems, visibleEnd + buffer);
批量进行 DOM 读取和写入。绝不要交错进行。
// 不良:读写读写(强制同步布局)
elements.forEach(el => {
const height = el.offsetHeight; // 读
el.style.height = height + 10 + 'px'; // 写
});
// 良好:批量读取,然后批量写入
const heights = elements.map(el => el.offsetHeight); // 全部读取
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px'; // 全部写入
});
will-change仅将 will-change 应用于将要动画化的元素,并在动画完成后移除它。
/* 良好:作用域限定且临时 */
.card:hover {
will-change: transform;
}
.card.animating {
will-change: transform, opacity;
}
/* 不良:全局 will-change */
/* * { will-change: transform; } */
在用户操作后,立即确认新状态。如果工作无法在短时间内完成,请显示进度、骨架屏、乐观 UI 或 aria-busy 反馈,而不是让界面保持不变。
始终提供减少动效的替代方案(SC 2.3.3,AAA 级)。
/* 正常定义动画 */
.fade-in {
animation: fadeIn 300ms ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* 为偏好减少动效的用户移除或减少动效 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
// 在 JavaScript 中检查
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
仅对 transform 和 opacity 进行动画处理,以实现平滑的 60fps 动画。这些在 GPU 合成器线程上运行。
/* 良好:仅合成器属性 */
.slide-in {
transition: transform 200ms ease-out, opacity 200ms ease-out;
}
/* 不良:触发布局/绘制 */
.slide-in-bad {
transition: left 200ms, width 200ms, height 200ms;
}
内容闪烁频率绝不要超过每秒 3 次(SC 2.3.1)。这可能引发癫痫。
对悬停、焦点、打开/关闭和其他状态变化使用过渡,以提供连续性。
.dropdown {
opacity: 0;
transform: translateY(-4px);
transition: opacity 150ms ease-out, transform 150ms ease-out;
pointer-events: none;
}
.dropdown.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
动画应传达状态、引导注意力或显示空间关系。绝不要仅为装饰而添加动画。
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0f17;
--text: #e4e4ef;
--surface: #1c1c2e;
--border: #2e2e44;
}
}
将所有主题值定义为自定义属性。通过更改属性值来切换主题。
:root {
color-scheme: light dark;
/* 浅色主题(默认) */
--color-bg: #ffffff;
--color-surface: #f5f5f7;
--color-text-primary: #1a1a2e;
--color-text-secondary: #555770;
--color-border: #d1d1e0;
--color-primary: #2563eb;
--color-primary-text: #ffffff;
--color-error: #dc2626;
--color-success: #16a34a;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f0f17;
--color-surface: #1c1c2e;
--color-text-primary: #e4e4ef;
--color-text-secondary: #a0a0b8;
--color-border: #2e2e44;
--color-primary: #60a5fa;
--color-primary-text: #0f0f17;
--color-error: #f87171;
--color-success: #4ade80;
}
}
告知浏览器支持的配色方案,以便原生 UI 元素(滚动条、表单控件)适配。
<meta name="color-scheme" content="light dark">
验证浅色和深色模式下的对比度比率。深色模式通常存在深色表面上文本对比度低的问题。
为浅色和深色上下文提供适当的图像。
<picture>
<source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">
<img src="logo-light.svg" alt="公司徽标">
</picture>
/* 或者对简单情况使用 CSS 滤镜 */
@media (prefers-color-scheme: dark) {
.decorative-img {
filter: brightness(0.9) contrast(1.1);
}
}
使用 @media (prefers-contrast: more) 和 @media (prefers-contrast: forced) 来尊重用户的对比度偏好。prefers-contrast: more 响应 macOS/iOS 系统设置中的“增加对比度”;prefers-contrast: forced 响应 Windows 高对比度模式 —— 这是一个完全覆盖颜色的独特操作系统功能。
/* 默认主题 */
:root {
--color-text: #555770;
--color-border: #d1d1e0;
--color-bg: #ffffff;
}
/* 高对比度模式:更强的文本和边框颜色 */
@media (prefers-contrast: more) {
:root {
--color-text: #1a1a2e; /* 更深的文本以获得更高的比率 */
--color-border: #1a1a2e; /* 更强的边框 */
--color-bg: #ffffff;
}
/* 确保交互元素有清晰的轮廓 */
button, input, select, textarea {
border: 2px solid currentColor;
}
}
/* 强制颜色(Windows 高对比度模式) */
@media (prefers-contrast: forced) {
/* 使用系统颜色关键字以尊重操作系统调色板 */
:root {
--color-text: ButtonText;
--color-bg: ButtonFace;
--color-border: ButtonBorder;
}
}
每个有意义的视图都应具有唯一的 URL。用户应该能够为任何状态添加书签、分享和重新加载。
// 更新 URL 而无需完全重新加载页面
function updateFilters(filters) {
const params = new URLSearchParams(filters);
history.pushState(null, '', `?${params}`);
renderResults(filters);
}
// 加载时从 URL 恢复状态
const params = new URLSearchParams(location.search);
const initialFilters = Object.fromEntries(params);
处理 popstate 以支持浏览器导航。
window.addEventListener('popstate', () => {
const params = new URLSearchParams(location.search);
renderResults(Object.fromEntries(params));
});
在导航中指示当前页面或部分。对活动链接使用 aria-current="page"。
<nav aria-label="主导航
Framework-agnostic rules for accessible, performant, responsive web interfaces. Based on WCAG 2.2, MDN Web Docs, and modern web platform APIs.
Accessibility is not optional. Most rules in this section map to WCAG 2.2 success criteria at Level A or AA. A small number of best-practice rules (noted inline) target Level AAA or go beyond WCAG.
Use elements for their intended purpose. Semantic structure provides free accessibility, SEO, and reader-mode support.
| Element | Purpose |
|---|---|
<main> | Primary page content (one per page) |
<nav> | Navigation blocks |
<header> | Introductory content or navigational aids |
<footer> | Footer for nearest sectioning content |
<article> | Self-contained, independently distributable content |
<section> | Thematic grouping with a heading |
<aside> | Tangentially related content (sidebars, callouts) |
<figure> / <figcaption> | Illustrations, diagrams, code listings |
<details> / <summary> | Expandable/collapsible disclosure widget |
<dialog> | Modal or non-modal dialog boxes |
<time> | Machine-readable dates/times |
<mark> | Highlighted/referenced text |
<address> | Contact information for nearest article/body |
<!-- Good -->
<main>
<article>
<h1>Article Title</h1>
<p>Content...</p>
</article>
<aside>Related links</aside>
</main>
<!-- Bad: div soup -->
<div class="main">
<div class="article">
<div class="title">Article Title</div>
<div class="content">Content...</div>
</div>
</div>
Anti-pattern : Using <div> or <span> for interactive elements. Never write <div onclick> when <button> exists.
Every interactive element must have an accessible name. Prefer visible text; use aria-label or aria-labelledby only when visible text is insufficient (SC 4.1.2).
<!-- Icon-only button: needs aria-label -->
<button aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>
<!-- Linked by labelledby -->
<h2 id="section-title">Notifications</h2>
<ul aria-labelledby="section-title">...</ul>
<!-- Redundant: visible text is enough -->
<button>Save Changes</button> <!-- No aria-label needed -->
All interactive elements must be reachable and operable via keyboard (SC 2.1.1).
Use native interactive elements (<button>, <a href>, <input>, <select>) which are keyboard-accessible by default.
Custom widgets need tabindex="0" to enter tab order and keydown handlers for activation.
Never use tabindex values greater than 0.
Trap focus inside modals; return focus on close.
// Focus trap for modal dialog.addEventListener('keydown', (e) => { if (e.key === 'Tab') { const focusable = dialog.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } });
Never remove focus outlines without providing a visible replacement (SC 2.4.7, enhanced SC 2.4.11 (AA) and SC 2.4.12 (AAA) in WCAG 2.2).
/* Good: custom focus indicator */
:focus-visible {
outline: 3px solid var(--focus-color, #4A90D9);
outline-offset: 2px;
}
/* Remove default only when :focus-visible is supported */
:focus:not(:focus-visible) {
outline: none;
}
/* Bad: removing all focus styles */
/* *:focus { outline: none; } */
WCAG 2.2 requires focus indicators to have a minimum area of the perimeter of the component times 2px, with 3:1 contrast against adjacent colors.
Provide a mechanism to skip repeated blocks of content (SC 2.4.1).
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav>...</nav>
<main id="main-content">...</main>
</body>
.skip-link {
position: absolute;
top: -100%;
left: 0;
z-index: 1000;
padding: 0.75rem 1.5rem;
background: var(--color-primary);
color: var(--color-on-primary);
}
.skip-link:focus {
top: 0;
}
Every <img> must have an alt attribute (SC 1.1.1).
Informative images : describe the content and function. alt="Bar chart showing sales doubled in Q4".
Decorative images : use alt="" (empty string) so screen readers skip them.
Functional images (inside links/buttons): describe the action. alt="Search".
Complex images : use alt for short description, link to long description or use <figcaption>.
Maintain minimum contrast ratios (SC 1.4.3, 1.4.6, 1.4.11).
| Content | Minimum Ratio |
|---|---|
| Normal text (<24px / <18.66px bold) | 4.5:1 |
| Large text (>=24px / >=18.66px bold) | 3:1 |
| UI components and graphical objects | 3:1 |
Do not rely on color alone to convey information (SC 1.4.1). Pair color with icons, text, or patterns.
/* Check contrast of these tokens */
:root {
--text-primary: #1a1a2e; /* on white: ~16:1 */
--text-secondary: #555770; /* on white: ~6.5:1 */
--text-disabled: #767693; /* on white: ~4.5:1, borderline */
}
Every form input must have a programmatically associated label (SC 1.3.1, 3.3.2).
<!-- Explicit label (preferred) -->
<label for="email">Email address</label>
<input id="email" type="email" autocomplete="email">
<!-- Implicit label (acceptable) -->
<label>
Email address
<input type="email" autocomplete="email">
</label>
<!-- Never: placeholder as sole label -->
<!-- <input placeholder="Email"> -->
Identify and describe errors in text (SC 3.3.1). Link error messages to inputs with aria-describedby or aria-errormessage.
<label for="email">Email</label>
<input id="email" type="email" aria-describedby="email-error" aria-invalid="true">
<p id="email-error" role="alert">Enter a valid email address, e.g. name@example.com</p>
Announce dynamic content changes to screen readers (SC 4.1.3).
<!-- Polite: announced when user is idle -->
<div aria-live="polite" aria-atomic="true">
3 results found
</div>
<!-- Assertive: interrupts current speech -->
<div role="alert">
Your session will expire in 2 minutes.
</div>
<!-- Status messages -->
<div role="status">
File uploaded successfully.
</div>
Use aria-live="polite" by default. Reserve role="alert" / aria-live="assertive" for time-sensitive warnings.
| Role | Purpose | Native Equivalent |
|---|---|---|
button | Clickable action | <button> |
link | Navigation | <a href> |
tab / tablist / tabpanel | Tab interface | None |
Rule : Prefer native HTML over ARIA. Use ARIA only when no native element exists for the pattern.
When an interactive element has visible text, its accessible name must contain that visible text as a substring (SC 2.5.3). Voice control users (Dragon NaturallySpeaking, macOS Voice Control) speak the visible label to activate controls. If aria-label replaces or contradicts the visible text, voice commands fail.
<!-- Correct: aria-label contains visible text as substring -->
<button aria-label="Delete item from cart">Delete</button>
<!-- Correct: no aria-label needed — visible text is the accessible name -->
<button>Save Changes</button>
<!-- Correct: icon button — no visible text, aria-label is fine -->
<button aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>
<!-- Incorrect: aria-label overrides visible text with different text -->
<button aria-label="Remove">Delete</button>
<!-- Incorrect: aria-label does not contain visible "Submit" -->
<button aria-label="Proceed to next step">Submit</button>
Rule : When visible text is present, aria-label must include that visible text (verbatim, case-insensitively). Prefer no aria-label at all when visible text is sufficient.
Write base styles for the smallest viewport. Layer complexity with min-width media queries.
/* Base: mobile */
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
/* Tablet */
@media (min-width: 48rem) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Desktop */
@media (min-width: 64rem) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
Use clamp(), min(), and max() for fluid sizing without breakpoints.
/* Fluid typography */
h1 {
font-size: clamp(1.75rem, 1.2rem + 2vw, 3rem);
}
/* Fluid spacing */
.section {
padding: clamp(1.5rem, 4vw, 4rem);
}
/* Fluid container */
.container {
width: min(90%, 72rem);
margin-inline: auto;
}
Size components based on their container, not the viewport.
.card-container {
container-type: inline-size;
container-name: card;
}
@container card (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 200px 1fr;
}
}
@container card (min-width: 700px) {
.card {
grid-template-columns: 300px 1fr;
gap: 2rem;
}
}
Set breakpoints where your content breaks, not at device widths. Common starting points:
/* Content-based, not "iPhone" or "iPad" */
@media (min-width: 30rem) { /* ~480px: single column gets cramped */ }
@media (min-width: 48rem) { /* ~768px: room for 2 columns */ }
@media (min-width: 64rem) { /* ~1024px: room for sidebar + content */ }
@media (min-width: 80rem) { /* ~1280px: wide multi-column */ }
Minimum 44x44 CSS pixels for touch targets (WCAG SC 2.5.5 AAA; SC 2.5.8 requires only 24x24px at AA). Provide at least 24px spacing between adjacent targets.
button, a, input, select, textarea {
min-height: 44px;
min-width: 44px;
}
/* Enlarge tap area without changing visual size */
.icon-button {
position: relative;
width: 24px;
height: 24px;
}
.icon-button::after {
content: "";
position: absolute;
inset: -10px; /* expands clickable area */
}
Always include in the document <head>:
<meta name="viewport" content="width=device-width, initial-scale=1">
Never use maximum-scale=1 or user-scalable=no -- these break pinch-to-zoom accessibility (SC 1.4.4).
Content must reflow at 320px width without horizontal scrolling (SC 1.4.10).
/* Prevent overflow */
img, video, iframe, svg {
max-width: 100%;
height: auto;
}
/* Contain long words/URLs */
.prose {
overflow-wrap: break-word;
}
/* Tables: scroll container, not page */
.table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
Every input needs a visible, programmatically associated label. See section 1.8.
Use autocomplete for common fields to enable browser autofill (SC 1.3.5).
<input type="text" autocomplete="name" name="full-name">
<input type="email" autocomplete="email" name="email">
<input type="tel" autocomplete="tel" name="phone">
<input type="text" autocomplete="street-address" name="address">
<input type="text" autocomplete="postal-code" name="zip">
<input type="text" autocomplete="cc-name" name="card-name">
<input type="text" autocomplete="cc-number" name="card-number">
<input type="password" autocomplete="new-password" name="password">
<input type="password" autocomplete="current-password" name="current-pw">
Use the right type to trigger appropriate mobile keyboards and native validation.
| Type | Use For |
|---|---|
email | Email addresses |
tel | Phone numbers |
url | URLs |
number | Numeric values with spinners (not for phone, zip, card numbers) |
search | Search fields (shows clear button) |
date / time / |
<input type="tel" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code">
Validate on blur (not on every keystroke). Show success and error states.
<div class="field" data-state="error">
<label for="username">Username</label>
<input id="username" type="text" aria-describedby="username-hint username-error" aria-invalid="true">
<p id="username-hint" class="hint">3-20 characters, letters and numbers only</p>
<p id="username-error" class="error" role="alert">Username must be at least 3 characters</p>
</div>
.field[data-state="error"] input {
border-color: var(--color-error);
box-shadow: 0 0 0 1px var(--color-error);
}
.field[data-state="error"] .error { display: block; }
.field:not([data-state="error"]) .error { display: none; }
Group related inputs with <fieldset> and label the group with <legend>.
<fieldset>
<legend>Shipping Address</legend>
<label for="street">Street</label>
<input id="street" type="text" autocomplete="street-address">
<!-- ... -->
</fieldset>
<fieldset>
<legend>Preferred contact method</legend>
<label><input type="radio" name="contact" value="email"> Email</label>
<label><input type="radio" name="contact" value="phone"> Phone</label>
</fieldset>
Indicate required fields visually and programmatically. Use required attribute and visible markers.
<label for="name">
Full name <span aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input id="name" type="text" required autocomplete="name">
If most fields are required, indicate which are optional instead.
Do not disable the submit button. Instead, validate on submit and show errors.
<!-- Good: always enabled, validate on submit -->
<button type="submit">Create Account</button>
<!-- Bad: disabled button with no explanation -->
<!-- <button type="submit" disabled>Create Account</button> -->
Disabled buttons fail to communicate why the user cannot proceed. If you must disable, provide a visible explanation.
Place format examples, constraints, and recovery text next to the relevant field via hint and error text. Never explain requirements only once in introductory copy and expect users to remember them later.
Use system font stacks for performance, or web fonts with proper fallbacks.
/* System font stack */
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Monospace stack */
code, pre, kbd {
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
}
/* Web font with fallbacks and size-adjust */
@font-face {
font-family: "CustomFont";
src: url("/fonts/custom.woff2") format("woff2");
font-display: swap;
font-weight: 100 900;
}
body {
font-family: "CustomFont", system-ui, sans-serif;
}
Use rem for font sizes and spacing. Use em for component-relative sizing.
html {
font-size: 100%; /* = 16px default, respects user preference */
}
body {
font-size: 1rem; /* 16px */
}
h1 { font-size: 2.5rem; } /* 40px */
h2 { font-size: 2rem; } /* 32px */
h3 { font-size: 1.5rem; } /* 24px */
small { font-size: 0.875rem; } /* 14px */
/* Never: font-size: 16px; (ignores user zoom settings) */
Body text line height of at least 1.5 (SC 1.4.12). Paragraph spacing at least 2x font size.
body {
line-height: 1.6;
}
h1, h2, h3 {
line-height: 1.2;
}
p + p {
margin-top: 1em;
}
Limit line length to approximately 75 characters for readability.
.prose {
max-width: 75ch;
}
/* Or for a content column */
.content {
max-width: 40rem; /* roughly 65-75ch depending on font */
margin-inline: auto;
}
Use real quotes, proper dashes, and tabular numbers for data.
/* Smart quotes */
q { quotes: "\201C" "\201D" "\2018" "\2019"; } /* curly double then single */
/* Tabular numbers for aligned data */
.data-table td {
font-variant-numeric: tabular-nums;
}
/* Oldstyle numbers for running prose (optional) */
.prose {
font-variant-numeric: oldstyle-nums;
}
/* Proper list markers */
ul { list-style-type: disc; }
ol { list-style-type: decimal; }
Use h1 through h6 in order. Never skip levels. One h1 per page.
<!-- Good -->
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>
<!-- Bad: skipping h2 -->
<h1>Page Title</h1>
<h3>Subsection</h3> <!-- Where is h2? -->
If you need visual styling that differs from the hierarchy, use CSS classes:
<h2 class="text-lg">Visually smaller but semantically h2</h2>
Use native lazy loading for images not visible on initial load.
<!-- Above fold: load eagerly, add fetchpriority -->
<img src="hero.webp" alt="Hero image" fetchpriority="high" width="1200" height="600">
<!-- Below fold: lazy load -->
<img src="feature.webp" alt="Feature image" loading="lazy" width="600" height="400">
Always specify width and height to prevent layout shift (CLS).
<img src="photo.webp" alt="Description" width="800" height="600">
/* Responsive images with aspect ratio preservation */
img {
max-width: 100%;
height: auto;
}
Use preconnect for third-party origins and preload for critical resources.
<head>
<!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- Preload critical resources -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/css/critical.css" as="style">
<!-- DNS prefetch for non-critical origins -->
<link rel="dns-prefetch" href="https://analytics.example.com">
</head>
Load JavaScript only when needed. Use dynamic import() for route-based and component-based splitting.
// Route-based splitting
const routes = {
'/dashboard': () => import('./pages/dashboard.js'),
'/settings': () => import('./pages/settings.js'),
};
// Interaction-based splitting
button.addEventListener('click', async () => {
const { openEditor } = await import('./editor.js');
openEditor();
});
For lists exceeding a few hundred items, render only visible rows.
// Concept: virtual scrolling
// Render only items in viewport + buffer
const visibleStart = Math.floor(scrollTop / itemHeight);
const visibleEnd = visibleStart + Math.ceil(containerHeight / itemHeight);
const buffer = 5;
const renderStart = Math.max(0, visibleStart - buffer);
const renderEnd = Math.min(totalItems, visibleEnd + buffer);
Batch DOM reads and writes. Never interleave them.
// Bad: read-write-read-write (forces synchronous layout)
elements.forEach(el => {
const height = el.offsetHeight; // read
el.style.height = height + 10 + 'px'; // write
});
// Good: batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight); // all reads
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px'; // all writes
});
will-change SparinglyOnly apply will-change to elements that will animate, and remove it after animation completes.
/* Good: scoped and temporary */
.card:hover {
will-change: transform;
}
.card.animating {
will-change: transform, opacity;
}
/* Bad: blanket will-change */
/* * { will-change: transform; } */
After a user action, acknowledge the new state immediately. If work cannot finish within a brief moment, show progress, skeletons, optimistic UI, or aria-busy feedback instead of leaving the interface unchanged.
Always provide a reduced-motion alternative (SC 2.3.3, Level AAA).
/* Define animations normally */
.fade-in {
animation: fadeIn 300ms ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Remove or reduce for users who prefer it */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
// Check in JavaScript
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
Animate only transform and opacity for smooth 60fps animation. These run on the GPU compositor thread.
/* Good: compositor-only properties */
.slide-in {
transition: transform 200ms ease-out, opacity 200ms ease-out;
}
/* Bad: triggers layout/paint */
.slide-in-bad {
transition: left 200ms, width 200ms, height 200ms;
}
Never flash content more than 3 times per second (SC 2.3.1). This can trigger seizures.
Use transitions for hover, focus, open/close, and other state changes to provide continuity.
.dropdown {
opacity: 0;
transform: translateY(-4px);
transition: opacity 150ms ease-out, transform 150ms ease-out;
pointer-events: none;
}
.dropdown.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
Animation should communicate state, guide attention, or show spatial relationships. Never animate for decoration alone.
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0f17;
--text: #e4e4ef;
--surface: #1c1c2e;
--border: #2e2e44;
}
}
Define all theme values as custom properties. Toggle themes by changing property values.
:root {
color-scheme: light dark;
/* Light theme (default) */
--color-bg: #ffffff;
--color-surface: #f5f5f7;
--color-text-primary: #1a1a2e;
--color-text-secondary: #555770;
--color-border: #d1d1e0;
--color-primary: #2563eb;
--color-primary-text: #ffffff;
--color-error: #dc2626;
--color-success: #16a34a;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f0f17;
--color-surface: #1c1c2e;
--color-text-primary: #e4e4ef;
--color-text-secondary: #a0a0b8;
--color-border: #2e2e44;
--color-primary: #60a5fa;
--color-primary-text: #0f0f17;
--color-error: #f87171;
--color-success: #4ade80;
}
}
Tell the browser about supported color schemes for native UI elements (scrollbars, form controls).
<meta name="color-scheme" content="light dark">
Verify contrast ratios in both light and dark modes. Dark mode often suffers from low-contrast text on dark surfaces.
Provide appropriate images for light and dark contexts.
<picture>
<source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">
<img src="logo-light.svg" alt="Company logo">
</picture>
/* Or use CSS filter for simple cases */
@media (prefers-color-scheme: dark) {
.decorative-img {
filter: brightness(0.9) contrast(1.1);
}
}
Honor the user's contrast preference using @media (prefers-contrast: more) and @media (prefers-contrast: forced). prefers-contrast: more responds to macOS/iOS "Increase Contrast" in System Settings; prefers-contrast: forced responds to Windows High Contrast Mode — a distinct OS feature that overrides colors entirely.
/* Default theme */
:root {
--color-text: #555770;
--color-border: #d1d1e0;
--color-bg: #ffffff;
}
/* High contrast mode: stronger text and border colors */
@media (prefers-contrast: more) {
:root {
--color-text: #1a1a2e; /* Darker text for higher ratio */
--color-border: #1a1a2e; /* Stronger borders */
--color-bg: #ffffff;
}
/* Ensure interactive elements are clearly delineated */
button, input, select, textarea {
border: 2px solid currentColor;
}
}
/* Forced colors (Windows High Contrast mode) */
@media (prefers-contrast: forced) {
/* Use system color keywords to respect OS color palette */
:root {
--color-text: ButtonText;
--color-bg: ButtonFace;
--color-border: ButtonBorder;
}
}
Every meaningful view should have a unique URL. Users should be able to bookmark, share, and reload any state.
// Update URL without full page reload
function updateFilters(filters) {
const params = new URLSearchParams(filters);
history.pushState(null, '', `?${params}`);
renderResults(filters);
}
// Restore state from URL on load
const params = new URLSearchParams(location.search);
const initialFilters = Object.fromEntries(params);
Handle popstate to support browser navigation.
window.addEventListener('popstate', () => {
const params = new URLSearchParams(location.search);
renderResults(Object.fromEntries(params));
});
Indicate the current page or section in navigation. Use aria-current="page" for the active link.
<nav aria-label="Main">
<a href="/" aria-current="page">Home</a>
<a href="/products">Products</a>
<a href="/about">About</a>
</nav>
[aria-current="page"] {
font-weight: 700;
border-bottom: 2px solid var(--color-primary);
}
Provide breadcrumbs for sites with deep hierarchies.
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/products/widgets" aria-current="page">Widgets</a></li>
</ol>
</nav>
Manage scroll position for SPA navigation.
// Disable browser auto-restoration for manual control
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
// Save scroll position before navigation
function saveScrollPosition() {
sessionStorage.setItem(`scroll-${location.pathname}`, window.scrollY);
}
// Restore on back/forward
window.addEventListener('popstate', () => {
const saved = sessionStorage.getItem(`scroll-${location.pathname}`);
if (saved) {
requestAnimationFrame(() => window.scrollTo(0, parseInt(saved)));
}
});
Use touch-action to control gesture behavior on interactive elements.
/* Allow only vertical scrolling (disable horizontal pan and pinch-zoom) */
.vertical-scroll {
touch-action: pan-y;
}
/* Carousel: horizontal scroll only */
.carousel {
touch-action: pan-x;
}
/* Canvas/map: disable all browser gestures */
.canvas {
touch-action: none;
}
Control the tap highlight on mobile WebKit browsers.
button, a {
-webkit-tap-highlight-color: transparent;
}
Every hover interaction must also work with keyboard focus.
/* Always pair :hover with :focus-visible */
.card:hover,
.card:focus-visible {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
Never hide essential functionality behind hover. Touch devices have no hover state.
/* Bad: content only accessible on hover */
/* .tooltip { display: none; }
.trigger:hover .tooltip { display: block; } */
/* Good: works with focus and click too */
.trigger:hover .tooltip,
.trigger:focus-within .tooltip,
.tooltip:focus-within {
display: block;
}
Use CSS scroll snap for card carousels and horizontal lists.
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: 1rem;
scroll-padding: 1rem;
}
.carousel > .slide {
scroll-snap-align: start;
flex: 0 0 min(85%, 400px);
}
Set lang on the <html> element. Use dir="auto" for user-generated content.
<html lang="en" dir="ltr">
<!-- User-generated content: let browser detect direction -->
<p dir="auto">User-submitted text here</p>
<!-- Explicit override for known RTL content -->
<blockquote lang="ar" dir="rtl">...</blockquote>
Use the Intl API for locale-aware formatting. Never hard-code date or number formats.
// Dates
new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(date);
// "January 15, 2026"
// Numbers
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.56);
// "1.234,56 EUR"
// Relative time
new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(-1, 'day');
// "yesterday"
// Lists
new Intl.ListFormat('en', { style: 'long', type: 'conjunction' }).format(['a', 'b', 'c']);
// "a, b, and c"
// Plurals
const pr = new Intl.PluralRules('en');
const suffixes = { one: 'st', two: 'nd', few: 'rd', other: 'th' };
function ordinal(n) { return `${n}${suffixes[pr.select(n)]}`; }
Text in images cannot be translated, resized, or read by screen readers. Use HTML/CSS text with background images when a styled text overlay is needed.
Use logical properties instead of physical ones to support both LTR and RTL layouts.
/* Physical (breaks in RTL) */
/* margin-left: 1rem; padding-right: 2rem; border-left: 1px solid; */
/* Logical (works in LTR and RTL) */
.sidebar {
margin-inline-start: 1rem;
padding-inline-end: 2rem;
border-inline-start: 1px solid var(--color-border);
}
.stack > * + * {
margin-block-start: 1rem;
}
/* Logical shorthands */
.box {
margin-inline: auto; /* left + right */
padding-block: 2rem; /* top + bottom */
inset-inline-start: 0; /* left in LTR, right in RTL */
border-start-start-radius: 8px; /* top-left in LTR, top-right in RTL */
}
| Physical | Logical |
|---|---|
left / right | inline-start / inline-end |
top / bottom | block-start / block-end |
margin-left |
Test layouts in RTL mode. Flexbox and Grid handle RTL automatically with logical properties.
/* This layout works in both LTR and RTL without changes */
.layout {
display: flex;
gap: 1rem;
}
/* Icons that indicate direction need flipping */
[dir="rtl"] .arrow-icon {
transform: scaleX(-1);
}
PWAs allow web apps to be installed and run offline. When building an installable web app, the following rules ensure the experience is consistent and reliable.
Include a manifest.json linked from <head> with all required fields for installability. Missing fields silently prevent install prompts.
<link rel="manifest" href="/manifest.json">
{
"name": "My App",
"short_name": "App",
"start_url": "/",
"display": "standalone",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
Incorrect:
{
"name": "My App"
// Missing start_url, display, and icons — app is not installable
}
theme_color tints the browser chrome and the OS task switcher. background_color fills the splash screen before the app loads. Both must match your brand colors.
{
"theme_color": "#1a73e8",
"background_color": "#ffffff"
}
A service worker is required for installability and offline capability. Cache critical assets on install; respond from cache when offline.
// In your main entry point
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
// sw.js — cache on install, serve from cache when offline
const CACHE = 'v1';
const PRECACHE = ['/', '/index.html', '/app.js', '/app.css'];
self.addEventListener('install', e =>
e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)))
);
self.addEventListener('fetch', e =>
e.respondWith(
caches.match(e.request).then(hit => hit ?? fetch(e.request))
)
);
For the browser install prompt to appear: the app must be served over HTTPS, have a registered service worker with a fetch handler, and include a manifest with name, icons, start_url, and display: standalone (or fullscreen/minimal-ui).
| Value | Use When |
|---|---|
standalone | App replaces browser UI; most common choice |
fullscreen | Games or media apps needing the entire screen |
minimal-ui | Retain minimal browser controls (back, reload) |
browser | No installation behavior; opens in browser tab |
Use this checklist when building or reviewing web interfaces.
alt textloading="lazy"width and heightpreconnectprefers-reduced-motion is respectedtransform and opacitycolor-scheme meta tag is presentprefers-contrast: more increases text and border contrastprefers-contrast: forced uses system color keywordslang attribute on <html><head> with name, icons, start_url, and displaytheme_color and background_color match brand palettefetch handler for offline support| Anti-Pattern | Fix |
|---|---|
<div onclick="..."> | Use <button> |
outline: none without replacement | Use :focus-visible with custom outline |
placeholder as label | Add a <label> element |
tabindex="5" | Use tabindex="0" or natural order |
Weekly Installs
339
Repository
GitHub Stars
268
First Seen
Feb 1, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex284
opencode281
gemini-cli272
claude-code255
github-copilot252
cursor244
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
106,200 周安装
市场研究员技能:市场规模测算、消费者行为分析与竞争分析指南
256 周安装
React Three Fiber 光照系统教程 - 6种光源类型配置与阴影优化指南
256 周安装
MiniMax图像理解技能:AI图像分析、OCR文本提取、UI设计评审与代码识别工具
256 周安装
项目开发指南技能示例:Next.js + FastAPI + Claude AI 全栈架构与代码规范
256 周安装
Skill Auditor:AI技能安全审计工具,6步协议检测恶意代码与权限风险
256 周安装
extract 技能:自动化网页数据抓取工具,生成Playwright脚本和JSON/CSV数据
257 周安装
dialog| Modal |
<dialog> |
alert | Assertive live region | None |
status | Polite live region | <output> |
navigation | Nav landmark | <nav> |
main | Main landmark | <main> |
complementary | Aside landmark | <aside> |
search | Search landmark | <search> (HTML5) |
img | Image | <img> |
list / listitem | List | <ul>/<li> |
heading | Heading (with aria-level) | <h1>-<h6> |
menu / menuitem | Menu widget | None |
tree / treeitem | Tree view | None |
grid / row / gridcell | Data grid | <table> |
progressbar | Progress | <progress> |
slider | Range input | <input type="range"> |
switch | Toggle | <input type="checkbox"> |
datetime-local| Temporal values |
password | Passwords (triggers password manager) |
text with inputmode="numeric" | Numeric data without spinners (PINs, zip codes) |
margin-inline-start |
padding-right | padding-inline-end |
border-top-left-radius | border-start-start-radius |
width | inline-size |
height | block-size |
text-align: left | text-align: start |
user-scalable=no | Remove it |
font-size: 12px | Use font-size: 0.75rem |
Animating width/height/top/left | Animate transform and opacity |
| Disabling submit button | Validate on submit, show errors |
| Color alone for status | Add icon, text, or pattern |
margin-left / padding-right | Use margin-inline-start / padding-inline-end |
<img> without dimensions | Add width and height attributes |
| Hover-only disclosure | Add :focus-within and click handler |