2026 起 upio.ai 全站新增页面参照的统一视觉与动效标准,从今天 akke / cloud-pc-progress 一系列页面提炼。每段都是 live demo + 可复制代码片段,全 self-contained、零外部依赖。
所有色彩、字体、间距以 CSS variables 承载。新页面把 :root 块整段复制即可。
:root {
--bg: #0a0d14; --bg-elevated: #131720; --bg-deep: #1a1f2b;
--border: #232936; --border-strong: #2f3646;
--text: #e8eaef; --text-dim: #9ba3b4; --text-muted: #6b7384;
--accent: #8b5cf6; --accent-soft: rgba(139, 92, 246, 0.15);
--accent-2: #60a5fa; --accent-2-soft: rgba(96, 165, 250, 0.13);
--accent-3: #34d399; --accent-3-soft: rgba(52, 211, 153, 0.14);
--warning: #fbbf24; --warning-soft: rgba(251, 191, 36, 0.14);
--danger: #f87171; --danger-soft: rgba(248, 113, 113, 0.14);
--font-display: 'Instrument Serif', 'Source Han Serif SC', Georgia, serif;
--font-ui: 'Inter Tight', 'Noto Sans SC', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
}
三种字体明确分工:Instrument Serif italic 大标题制造情绪、Inter Tight 正文密度、JetBrains Mono 所有 label/number/code 的"工程语调"。
所有正文页面用 .container { max-width: 1080px; margin: 0 auto; padding: 56px 24px 120px; },避免阅读行长过宽。
每个页面顶部固定 .sticky-toc,带 backdrop-filter 模糊背景 + reading-progress 滚动进度条。TOC 链接配章节锚点 + IntersectionObserver 滚动高亮。
<div class="section-head" id="案例">
<span class="num">01</span>
<div class="meta">
<h2>SECTION ONE</h2>
<div class="lede">主题副标题用 italic display</div>
</div>
</div>
顶部 3px 状态色条 + 大数字 + label + sub + micro。入场 blur-up 错峰 80ms · 数字 count-up · 收尾 text-shadow burst。
<div class="kpi-grid">
<div class="kpi k-green" data-counter-target="2" data-counter-suffix="/2">
<div class="k-label">已完成</div>
<div class="k-val" data-counter="2">2/2</div>
<div class="k-sub">9 分钟连发 · 全 PASS</div>
<div class="k-micro">CROSS-CHECK BY RECEIVER</div>
</div>
<!-- 其他 .k-amber / .k-purple / .k-red 同结构 -->
</div>
<div class="callout info"><strong>info ·</strong> 中性提示</div> <div class="callout warn"><strong>warn ·</strong> 需要注意</div> <div class="callout success"><strong>success ·</strong> 已完成</div> <div class="callout danger"><strong>danger ·</strong> 阻塞 / 严重</div>
--dial-offset 控制 stroke-dashoffset,0 = 满载 + drop-shadow glow。data-dial-count 触发中心数字与圆环同步 count-up。
<div class="dial-grid">
<div class="dial-card green" style="--dial-offset: 0;"
data-dial-count="2" data-dial-suffix=" / 2">
<div><svg viewBox="0 0 100 100">
<circle class="dial-track" cx="50" cy="50" r="42"/>
<circle class="dial-fill" cx="50" cy="50" r="42"/>
</svg></div>
<div class="dial-meta">
<div class="dial-name">指标名</div>
<div class="dial-val">2 / 2</div>
<div class="dial-design">说明 / 设计上限</div>
</div>
</div>
</div>
<!-- --dial-offset 对应 stroke-dasharray=264 的偏移:
0=100%满 · 33≈88% · 92≈65% · 264=0% -->
3 层 SVG:area-fill 渐变(path 下方)+ polyline(数据线)+ data points(dots)。入场 stroke-draw + 7 个点 stagger fade。Hover 显示日期 + 值,竖向 marker 同步。末点是特殊的 +1 预期点,无限脉冲。
试一下:把鼠标移到上面的 sparkline,会出现 tooltip 显示 hovered 数据点的标签 + 值。
4 状态色(done/mitigated/watching/todo)做列头小圆点 + 3 重要性级别(critical/important/minor)做左侧 vertical bar accent。空格 opacity 0.25,填充泡是带 ring 的 42×42 圆。唯一阻塞的填充泡加 .urgent 类持续 pulse。
动效目的是引导注意 + 给静态信息加节奏,不是装饰。每个 pattern 都基于 IntersectionObserver + .in-view class,确保只在用户实际滚到时才触发,并且都带 reduce-motion 兜底。
卡片 opacity: 0 + filter: blur(8px) + translateY(10px) 起步,.in-view 时收回。同组按 :nth-child 错峰 80ms。用于:hero KPI 入场。
.kpi {
opacity: 0; filter: blur(8px); transform: translateY(10px);
transition: opacity .55s ease-out, filter .55s ease-out,
transform .55s ease-out, border-color .2s;
}
.kpi.in-view { opacity: 1; filter: blur(0); transform: translateY(0); }
.kpi-grid .kpi:nth-child(2).in-view { transition-delay: 80ms, 80ms, 80ms, 0s; }
.kpi-grid .kpi:nth-child(3).in-view { transition-delay: 160ms, 160ms, 160ms, 0s; }
.kpi-grid .kpi:nth-child(4).in-view { transition-delay: 240ms, 240ms, 240ms, 0s; }
所有数字 0 → target,ease-out cubic 900ms。animateNumber 接受 prefix/suffix/decimals/dur/onDone 选项,整站只有这一个 helper。
const animateNumber = (el, target, opts) => {
opts = opts || {};
const suffix = opts.suffix || '';
const prefix = opts.prefix || '';
const decimals = opts.decimals;
const onDone = opts.onDone;
const dur = opts.dur || 900;
const start = performance.now();
const isInt = decimals == null ? Number.isInteger(target) : false;
const fmt = (v) => {
if (decimals != null) return Number(v).toFixed(decimals);
return isInt ? Math.round(v).toString() : Number(v).toFixed(1);
};
const step = (t) => {
const p = Math.min((t - start) / dur, 1);
const eased = 1 - Math.pow(1 - p, 3);
el.textContent = prefix + fmt(target * eased) + suffix;
if (p < 1) requestAnimationFrame(step);
else if (onDone) onDone();
};
requestAnimationFrame(step);
};
count-up 完成后给数字加 0.7s text-shadow 闪一下,再消失。用于:KPI .k-val 数字收尾。
.kpi .k-val.burst { animation: numBurst .7s ease-out; }
@keyframes numBurst {
0% { text-shadow: 0 0 0 currentColor; }
35% { text-shadow: 0 0 18px currentColor, 0 0 6px currentColor; }
100% { text-shadow: 0 0 0 currentColor; }
}
// JS: animateNumber(el, target, { onDone: () => {
// el.classList.add('burst');
// setTimeout(() => el.classList.remove('burst'), 800);
// }});
stroke-dasharray = path 长度,stroke-dashoffset 从 length → 0,配 cubic-bezier(.65,0,.35,1) 1.4s。用于:原理图 flow-line、sparkline polyline。
.ps-path {
stroke-dasharray: 760; /* ≈ polyline 实际长度 */
stroke-dashoffset: 760;
}
.primary-sparkline.in-view .ps-path {
animation: drawSpark 1.4s cubic-bezier(.65,0,.35,1) forwards;
}
@keyframes drawSpark { to { stroke-dashoffset: 0; } }
沿 path 跑的发光小圆点,呼应"数据在动"的语义。filter: url(#particleGlow) 给柔光,begin 错开各路径的入场时机。用于:原理图核心通路。
<defs>
<filter id="particleGlow" x="-200%" y="-200%" width="500%" height="500%">
<feGaussianBlur stdDeviation="2"/>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- 沿 line/path 跑 -->
<circle r="3" fill="#34d399" class="particle" filter="url(#particleGlow)">
<animateMotion path="M 230,354 Q 230,310 230,285"
dur="1.8s" repeatCount="indefinite" begin="2.8s"/>
</circle>
<!-- reduce-motion 兜底:CSS 隐藏 -->
给重要元素加一层同形状外圈,animate 同步驱动 x/y/width/height/stroke-opacity,制造"雷达外扩"。用于:架构图核心节点。
<rect x="100" y="170" width="840" height="110" rx="14"
fill="none" stroke="#8b5cf6" stroke-width="1.5">
<animate attributeName="x" values="100;88" dur="2.8s" repeatCount="indefinite"/>
<animate attributeName="y" values="170;158" dur="2.8s" repeatCount="indefinite"/>
<animate attributeName="width" values="840;864" dur="2.8s" repeatCount="indefinite"/>
<animate attributeName="height" values="110;134" dur="2.8s" repeatCount="indefinite"/>
<animate attributeName="stroke-opacity" values="0.6;0" dur="2.8s" repeatCount="indefinite"/>
</rect>
当 --dial-offset: 0(100% 满)时,stroke 加 drop-shadow 状态色散光。颜色随 currentColor 自动跟随 .green/.red/.amber/.purple。
// JS: dial-card with --dial-offset:0 gets .dial-full class
.dial-card.in-view.dial-full .dial-fill {
filter: drop-shadow(0 0 5px currentColor);
}
.dial-card.green.in-view.dial-full .dial-fill { filter: drop-shadow(0 0 6px var(--accent-3)); }
.dial-card.red.in-view.dial-full .dial-fill { filter: drop-shadow(0 0 6px var(--danger)); }
给"阻塞" / "P0 待修" 的单一元素加无限循环 box-shadow + filter pulse。规则:每页最多 1 个 urgent,多了视觉就降级了。
.bc-bubble.todo.urgent {
animation: bcUrgent 2.2s ease-in-out infinite;
animation-delay: 1.2s;
}
@keyframes bcUrgent {
0%, 100% {
box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.45);
filter: drop-shadow(0 0 0 transparent);
}
50% {
box-shadow: 0 0 0 10px rgba(248, 113, 113, 0);
filter: drop-shadow(0 0 8px var(--danger));
}
}
const target = document.querySelector('.your-component');
if (target && 'IntersectionObserver' in window) {
const obs = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (!e.isIntersecting) return;
e.target.classList.add('in-view');
// 触发 count-up / draw / 任何动效
obs.unobserve(e.target);
});
}, { threshold: 0.3 });
obs.observe(target);
}
系统级别"减少动画"用户偏好下,所有 transition + animation 一律关闭,动态元素直接显示终态。模板:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation: none !important;
transition: none !important;
}
/* 元素直显终态 */
.kpi { opacity: 1 !important; filter: none !important; transform: none !important; }
.dial-card .dial-fill { stroke-dashoffset: var(--dial-offset, 0) !important; }
.primary-sparkline .ps-path { stroke-dashoffset: 0 !important; }
.primary-sparkline .pt { opacity: 1 !important; }
.bc-matrix .bc-cell, .bc-matrix .bc-row-label {
opacity: 1 !important; transform: none !important;
}
/* SVG 动画隐藏 */
.diagram .particle, .diagram .board-radar { display: none !important; }
/* CSS 无限循环 pulse 关 */
.bc-bubble.todo.urgent { animation: none !important; }
}
白底黑字降级,Georgia serif,干掉 sticky-toc / footer / code-ref。
@media print {
body { background: white; color: black; font-family: Georgia, serif; }
.sticky-toc, footer, .breadcrumb, details.code-ref { display: none; }
.container { max-width: 100%; padding: 0; }
.kpi, .dial-card, .primary-sparkline, .bc-matrix, .role, .callout {
background: white; border-color: #ccc; color: black;
}
h1 {
color: black;
-webkit-text-fill-color: black;
background: none;
}
}
role="img" + aria-label 描述图意,让屏幕阅读器有上下文。display: none 不能用来藏 reduce-motion 下的关键内容。粒子是装饰可以 display:none;KPI 数字必须直显终态。