zz design system
mono & squared
基础 / 动效

动效与可访问性

动效只有三档时长和一条缓动曲线,且几乎全部发生在颜色与描边上:界面从不位移。 唯一的例外是按下时的 scale(0.96) 和主题切换时那道斜切裂缝。

时长与缓动

token值用于说明
--zz-dur-fast100ms浮层进出、瞬时状态反馈下拉与浮层的自带进出场,视觉上"立刻"发生
--zz-dur150ms默认:悬停 / 焦点变色、描边变化、背景切换绝大多数过渡都用这一档,也是 :focus-visible 与 :hover 的时长
--zz-dur-slow250ms进入动画:元素上浮、淡入只用于"新东西出现",不用于已存在元素的属性变化
--zz-easecubic-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 加一个可补间的自定义属性:

  1. 用 @property --zz-wipe 把裂缝宽度声明为可补间的 <length-percentage>。
  2. 把 ::view-transition-old(root) 的 mask-image 写成一条 100deg 的线性渐变:中心 ±var(--zz-wipe) 之外才显示旧页面。
  3. 关键帧把 --zz-wipe 从 0px 推到 80%,时长 200ms、曲线 cubic-bezier(0.7, 0, 1, 1)。
  4. 切换瞬间给根节点加 .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 的指示器对比度要求,所以换主色时要重新确认它仍然看得见。

链接 · 按 Tab

滚动容器内的焦点环改内缩,避免被裁切:

  • 不要在组件里写 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: autobase.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。