动效与可访问性
动效只有三档时长和一条缓动曲线,且几乎全部发生在颜色与描边上:界面从不位移。 唯一的例外是按下时的 scale(0.96) 和主题切换时那道斜切裂缝。
时长与缓动
| token | 值 | 用于 | 说明 |
|---|---|---|---|
--zz-dur-fast | 100ms | 浮层进出、瞬时状态反馈 | 下拉与浮层的自带进出场,视觉上"立刻"发生 |
--zz-dur | 150ms | 默认:悬停 / 焦点变色、描边变化、背景切换 | 绝大多数过渡都用这一档,也是 :focus-visible 与 :hover 的时长 |
--zz-dur-slow | 250ms | 进入动画:元素上浮、淡入 | 只用于"新东西出现",不用于已存在元素的属性变化 |
--zz-ease | cubic-bezier(0.2, 0, 0, 1) | 全站唯一缓动曲线 | 快起慢收,开头干脆、结尾稳定,不要改 ease-in-out |
主题切换是单独一档:200ms + cubic-bezier(0.7, 0, 1, 1), 定义在 theme-transition.css 里而不占用上面三个 token——它是"裂缝"专用曲线, 不是通用动效。过渡属性必须逐个列出,禁止 transition: all。
.zz-btn {
transition-property: background-color, border-color, color, transform;
transition-duration: var(--zz-dur);
transition-timing-function: var(--zz-ease);
}交互反馈
两条规则足以覆盖所有可交互元素:悬停只改颜色(背景、描边、文字色),按下只做 scale(0.96)。二者都不改变布局,因此不会有重排抖动, 也不会让相邻元素跟着跳。
.zz-hoverable:hover { background: var(--zz-surface-2); } /* 只改色,不位移 */
.zz-pressable:active { transform: scale(0.96); } /* 只缩放,不改布局 */
.zz-pressable:disabled { opacity: 0.5; pointer-events: none; }禁用态统一为 opacity: .5 + pointer-events: none; 渲染成 <a> 时用 aria-disabled="true" 代替 disabled,并保留可聚焦性。 不用 translateY(-1px) 之类的"抬起"反馈——那是阴影语言的做法,直角 + 描边的语言里没有这一手。
主题切换:斜切裂缝
切换主题时会盖上一层新快照,旧快照沿一条 100deg 的斜缝从中间向两侧裂开,露出新页面。 实现靠 View Transitions API 加一个可补间的自定义属性:
- 用
@property --zz-wipe把裂缝宽度声明为可补间的<length-percentage>。 - 把
::view-transition-old(root)的mask-image写成一条100deg的线性渐变:中心±var(--zz-wipe)之外才显示旧页面。 - 关键帧把
--zz-wipe从0px推到80%,时长200ms、曲线cubic-bezier(0.7, 0, 1, 1)。 - 切换瞬间给根节点加
.zz-no-transitions,关掉全局过渡,否则每个颜色都会各自补间。
想直接看效果:点顶栏右侧的太阳/月亮开关,注意裂缝的倾角与收尾速度。
@property --zz-wipe {
syntax: '<length-percentage>';
inherits: false;
initial-value: 0px;
}
::view-transition-old(root) {
z-index: 1;
animation: zz-split-wipe 200ms cubic-bezier(0.7, 0, 1, 1) both;
mask-image: linear-gradient(
100deg,
#000 0,
#000 calc(50% - var(--zz-wipe) + 1px),
#0000 calc(50% - var(--zz-wipe) + 1px),
#0000 calc(50% + var(--zz-wipe) - 1px),
#000 calc(50% + var(--zz-wipe) - 1px),
#000 100%
);
}
@keyframes zz-split-wipe {
from { --zz-wipe: 0px; }
to { --zz-wipe: 80%; }
}可访问性
焦点可见
全局只有一条规则:outline: 2px solid var(--zz-brand) + outline-offset: 2px。 焦点环用品牌色是为了满足 3:1 的指示器对比度要求,所以换主色时要重新确认它仍然看得见。
滚动容器内的焦点环改内缩,避免被裁切:
- 不要在组件里写
outline: none;要自定义就把焦点环换成等价的 2px 描边,并保留 3:1 对比度。 - 滚动容器加
.zz-scroll-inset,其内部:focus-visible的偏移自动变成-2px。 - 卡片这类"整块可点"的容器,焦点环画在外层,内部链接不要各画一圈,避免双环。
键盘与结构
- 焦点顺序就是 DOM 顺序:不写正的
tabindex,需要提前就把元素在 DOM 里提前。 - 长页面提供跳转链接,排在 DOM 最前,聚焦后才从视口外滑入;装饰层(网格底、图标、遮罩热区)一律
aria-hidden="true",不进入焦点序。 - 状态用真实属性表达:
aria-current、aria-expanded、aria-pressed、aria-disabled,不要只靠视觉类。
减弱动效
每个动画都必须自带 prefers-reduced-motion: reduce 分支。当前的降级覆盖:
| 动画 | 降级后 | 实现位置 |
|---|---|---|
| 平滑滚动 | scroll-behavior: auto | base.css |
| 打字机光标 | .zz-caret 停用 animation(不再闪烁) | base.css |
| 主题擦除 | view-transition 伪元素 animation: none、mask-image: none,JS 侧直接同步切换 | theme-transition.css + 顶栏脚本 |
| spinner / 加载反馈 | 停用旋转,改为静态文案或静态指示 | 组件层 |
| 进入动画 | animation: none,直接落在终态 | 组件层 |
降级的目标是"没有运动",不是"更快":animation: none 而不是把时长调短。 组件层的 spinner 与进入动画属于约定项——新增动画时必须同时提交 reduce 分支。
原生控件与对比度
- 每个主题块都声明
color-scheme: dark或color-scheme: light,让滚动条、表单控件、日期选择器等原生 UI 跟随主题明暗;局部换主题的子树也因此表现正确。 - 文字层级靠三档实色而非透明度,正文用
--zz-text-secondary,标题用--zz-text,元信息用--zz-text-muted;弱化信息优先降字号、再换档,不要叠 alpha。 - 亮色主题下
--zz-text-muted比--zz-text-secondary更深,暗色主题下则更浅——每档是对背景独立挑的值,不是同一色的变淡。 - 可交互元素有
touch-action: manipulation,去掉触屏 300ms 双击缩放等待;锚点用scroll-margin-top: calc(var(--zz-nav-h) + 1rem)避开吸顶栏。 - 颜色从不单独承担语义:状态同时有文案或图标,选中同时有
aria-current/aria-pressed。