贴纸统计卡与数字滚动
open-design.ai 首页 Selected Work 模块的数据墙:六张微微歪斜的"贴纸"卡, 滚进视口时荧光绿大数字从 0 甩尾滚到位。贴纸感是纯 CSS,数字滚动是官网 自己写的零依赖 CountUp(整页不带 React runtime,全靠这类小 enhancer)。
要点
- 贴纸感 = 每卡一个独立
--tilt微旋转 + 一坨"离得远、几乎全黑"的大阴影:官网倾角序列[-1.2, 1.4, -0.6, 0.9, -1, 1.1]deg,正负交替、都压在 ±1.5° 内 —— 歪得出随手贴的味道又不乱 - 阴影浓度给到
rgba(0,0,0,.72)但用-42px负扩散收窄成卡底一条深影(0 28px 60px -42px),纸片悬空感的关键就在"浓 + 窄" - hover 写成
rotate(var(--tilt)) translateY(-4px):rotate 必须在 hover 里重写一遍,否则整个 transform 被覆盖、卡片瞬间回正 - hover 只推深阴影(
.72→.86、60px→76px)+ 上浮 4px,260ms ease 一步到位,不加任何弹簧 - 大数字荧光绿
clamp(34px, 4vw, 58px)/ 800 / 行高 .96 / 字距 -.028em,标签黑墨块级分行;最后一张卡反转成荧光绿底当 CTA,一格颜色收束整个网格 - CountUp 的健壮性契约(官网注释原话 robustness contract):静态 HTML 里直接渲染终值,归零只发生在元素进视口那一刻 —— reduced-motion、没有 IntersectionObserver、一屏滚过头,任何情况下数字都不会卡在 0 或空白
- IO 参数
threshold: 0, rootMargin: '0px 0px -8% 0px':底边收 8%,元素真露头才开滚,避免贴边启动看不见前半程;触发即unobserve,一次性动画 - 缓动
1-(1-t)^4(前快后慢的甩尾),默认 2s;结束帧强制写一次精确终值,杜绝浮点尾巴 - 小数位从
data-countup-to的字符串推断('7.4'→ 1 位),Intl.NumberFormat负责格式化,data-countup-separator存在才开千分组;K+、+后缀纯文本拼接 - 官网的活数据卡(Stars / 贡献者)另走 GitHub API 覆写,硬编码值只是 SSR 兜底 —— 数据墙永远不说谎
源码(折叠)
stat-cards-demo.html(自包含单页,含内联 style + script)
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>贴纸统计卡与数字滚动 — open-design.ai Selected Work 模块拆解</title>
<style>
/* ================================================================
Atelier Zero 令牌(抄自 open-design 仓库 apps/landing-page/app/globals.css :root)
纸白底 + 黑墨字 + 荧光绿唯一强调色。
================================================================ */
:root {
--paper: #fafafa;
--paper-dark: #f0f0f0;
--ink: #262626;
--ink-soft: #434343;
--ink-mute: #595959;
--ink-faint: #8c8c8c;
--coral: #63fe13; /* 官网变量名叫 coral,值其实是荧光绿 */
--coral-soft: #83ff3b;
--bone: #ffffff;
--line: #d9d9d9;
--shadow: 0 30px 60px -30px rgba(38, 38, 38, 0.16);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { background: var(--paper); color: var(--ink); }
body {
font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, sans-serif;
padding: 34px clamp(18px, 4vw, 44px) 44px;
-webkit-font-smoothing: antialiased;
}
/* ---- 模块头:小字眉题 + 重播按钮 ---- */
.head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 26px;
}
.head .kicker {
font-size: 12px;
letter-spacing: .22em;
color: var(--ink-faint);
margin-bottom: 8px;
}
.head h1 {
font-size: clamp(22px, 3.4vw, 30px);
font-weight: 800;
letter-spacing: -.02em;
}
.replay {
flex: 0 0 auto;
font: inherit;
font-size: 13px;
font-weight: 600;
padding: 9px 18px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--bone);
color: var(--ink);
cursor: pointer;
transition: border-color .16s ease, background .16s ease;
}
.replay:hover { border-color: var(--ink); background: var(--paper-dark); }
/* ================================================================
贴纸统计卡(官网 .work-stat-card,globals.css)
贴纸感的全部来源:每张卡带一个自己的 --tilt 微旋转 +
一坨"离得很远、几乎全黑"的大阴影 —— 阴影浓(.72)但
负扩散(-42px)收得极窄,只剩卡底一条深影,像纸片悬空。
hover 只是在保留 rotate 的前提下加 translateY(-4px) 并把
阴影推深推远(.86 / 76px),260ms ease,一步到位。
================================================================ */
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 22px; /* 官网同值 */
}
.card {
min-height: 208px; /* 官网同值 */
padding: 28px;
border-radius: 18px;
background: var(--bone);
border: 1px solid var(--line);
color: var(--ink);
text-decoration: none;
display: flex;
align-items: flex-end; /* 数字坐在卡片底部,杂志排版感 */
position: relative;
transform: rotate(var(--tilt, 0deg));
box-shadow: 0 28px 60px -42px rgba(0, 0, 0, 0.72); /* 官网同值 */
transition: transform 260ms ease, box-shadow 260ms ease;
}
.card:hover {
transform: rotate(var(--tilt, 0deg)) translateY(-4px); /* rotate 必须重写一遍,否则被覆盖 */
box-shadow: 0 36px 76px -42px rgba(0, 0, 0, 0.86); /* 官网同值 */
}
/* 大数字(荧光绿)+ 标签(黑墨),官网 .work-stat-card h3 同款排版 */
.card h3 {
font-size: clamp(34px, 4vw, 58px); /* 官网同值 */
font-weight: 800;
line-height: .96;
letter-spacing: -.028em;
}
.card h3 .num { display: block; color: var(--coral); margin-bottom: 10px; font-style: normal; }
.card h3 em { display: block; color: var(--ink); font-style: normal; font-size: .42em; letter-spacing: -.01em; }
/* 右上角出走箭头:默认藏着,hover 才浮现 */
.card .arrow {
position: absolute;
top: 18px; right: 20px;
font-size: 18px;
color: var(--ink-faint);
opacity: 0;
transform: translate(-4px, 4px);
transition: opacity .2s ease, transform .2s ease;
}
.card:hover .arrow { opacity: 1; transform: translate(0, 0); }
/* 最后一张卡反转成荧光绿底当 CTA(官网 .work-stat-card-cta) */
.card.cta { background: var(--coral); border-color: transparent; }
.card.cta h3 .txt { display: block; color: var(--ink); font-size: .52em; line-height: 1.15; }
.card.cta .arrow { color: var(--ink); opacity: 1; transform: none; }
@media (max-width: 720px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.card { min-height: 168px; padding: 22px; }
}
/* reduced-motion:贴纸不再浮动(数字滚动在 JS 里一并跳过) */
@media (prefers-reduced-motion: reduce) {
.card, .card:hover { transition: none; transform: rotate(var(--tilt, 0deg)); box-shadow: 0 28px 60px -42px rgba(0,0,0,.72); }
}
</style>
</head>
<body>
<div class="head">
<div>
<p class="kicker">SELECTED WORK · 数据一览</p>
<h1>一组会自报家门的歪贴纸</h1>
</div>
<button class="replay" id="replay" type="button">↻ 重播数字</button>
</div>
<!-- ================================================================
六张卡的 --tilt 用官网原序列 [-1.2, 1.4, -0.6, 0.9, -1, 1.1]deg。
正负交替、幅度都在 ±1.5° 内 —— 歪得出"随手贴上去"的味道,
又不至于乱。数字卡的 data-countup 契约见页尾脚本注释。
================================================================ -->
<main class="grid">
<a class="card" href="#" style="--tilt: -1.2deg">
<span class="arrow">↗</span>
<h3><span class="num" data-countup data-countup-to="74" data-countup-suffix="K+">74K+</span><em>GitHub Stars</em></h3>
</a>
<a class="card" href="#" style="--tilt: 1.4deg">
<span class="arrow">↗</span>
<h3><span class="num" data-countup data-countup-to="340" data-countup-suffix="+">340+</span><em>贡献者</em></h3>
</a>
<a class="card" href="#" style="--tilt: -0.6deg">
<span class="arrow">↗</span>
<h3><span class="num" data-countup data-countup-to="217" data-countup-suffix="+">217+</span><em>插件</em></h3>
</a>
<a class="card" href="#" style="--tilt: 0.9deg">
<span class="arrow">↗</span>
<h3><span class="num" data-countup data-countup-to="128" data-countup-suffix="+">128+</span><em>设计系统</em></h3>
</a>
<a class="card" href="#" style="--tilt: -1deg">
<span class="arrow">↗</span>
<h3><span class="num" data-countup data-countup-to="21">21</span><em>Coding Agent</em></h3>
</a>
<a class="card cta" href="#" style="--tilt: 1.1deg">
<span class="arrow">↗</span>
<h3><span class="txt">喜欢?<br>去点亮一颗星</span></h3>
</a>
</main>
<script>
/* ================================================================
CountUp 数字滚动 —— 抄自官网 index.astro 的 enhanceCountups。
官网注释里管这套叫 robustness contract(健壮性契约):
① 静态 HTML 里【直接渲染终值】(上面每个 [data-countup] 的
文本就是 74K+ / 340+ …),滚动只是锦上添花的增强;
② 归零这个动作【只发生在元素进入视口的那一刻】(run() 内部),
观察器永不触发(reduced-motion / 老浏览器没 IO / 用户一屏
滚过头没相交)时终值原样留着 —— 数字永远不可能卡在 0 或空白。
这一条是"渐进增强"数字动画和劣化版最大的区别。
参数全部同官网:
- IO: threshold 0, rootMargin '0px 0px -8% 0px'(底边收 8%,
元素露出下沿一点点才算进场,避免贴边就开滚看不见前半程)
- 缓动 ease(t) = 1 - (1-t)^4,前快后慢的甩尾
- 时长 data-countup-duration 秒,默认 2s
- Intl.NumberFormat:小数位数从 data-countup-to 的字符串推断
('7.4' → 1 位小数),data-countup-separator 存在才开千分组
- data-countup-suffix(K+ / +)纯文本拼在格式化结果后
================================================================ */
(() => {
const nodes = document.querySelectorAll('[data-countup]');
if (nodes.length === 0) return;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* 官网此处直接 return:终值已经在 HTML 里,什么都不做就是正确结果 */
if (reduceMotion || !('IntersectionObserver' in window)) return;
const decimalsOf = (value) => {
const str = String(value);
return str.includes('.') ? str.split('.')[1].length : 0;
};
const makeFormatter = (el, to) => {
const suffix = el.dataset.countupSuffix || '';
const separator = el.dataset.countupSeparator || '';
const decimals = decimalsOf(to);
return (value) => {
const formatted = new Intl.NumberFormat('en-US', {
useGrouping: Boolean(separator),
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
return (separator ? formatted.replace(/,/g, separator) : formatted) + suffix;
};
};
const ease = (t) => 1 - Math.pow(1 - t, 4);
const done = new Set(); /* 官网用 WeakSet;demo 要支持重播,换成可清空的 Set */
const run = (el) => {
if (done.has(el)) return;
done.add(el);
const to = Number(el.dataset.countupTo);
if (!Number.isFinite(to)) return;
const from = Number(el.dataset.countupFrom || '0');
const duration = Math.max(0, Number(el.dataset.countupDuration || '2')) * 1000;
const format = makeFormatter(el, to);
let startTs = null;
const tick = (ts) => {
if (startTs === null) startTs = ts;
const progress = Math.min(1, (ts - startTs) / duration);
el.textContent = format(from + (to - from) * ease(progress));
if (progress < 1) requestAnimationFrame(tick);
else el.textContent = format(to); /* 收尾强制精确终值,杜绝浮点尾巴 */
};
requestAnimationFrame(tick);
};
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
run(entry.target);
io.unobserve(entry.target); /* 一次性动画,滚回来不重播 */
}
},
{ threshold: 0, rootMargin: '0px 0px -8% 0px' },
);
for (const el of nodes) io.observe(el);
/* demo 加的重播按钮:清掉 done 记录直接重跑(不走 IO) */
document.getElementById('replay').addEventListener('click', () => {
done.clear();
for (const el of nodes) run(el);
});
})();
</script>
</body>
</html>