滚动入场编排(data-reveal)
open-design.ai 全页共用的入场体系:每个模块只挂一个 data-reveal 属性,一个页面级
IntersectionObserver 统一翻牌,方向、幅度、错峰节奏全部下放给 CSS。整站几十处入场
动画,JS 只有二十行 —— 编排在样式层,不在脚本层。
要点
- 初始态
opacity:0; translate:0 28px,翻成data-revealed='true'后过渡到位;一条900ms cubic-bezier(0.22,1,0.36,1)的长曲线,入场是"飘"不是"弹" - 方向/形态即属性值:
data-reveal="left"→-36px 0、right→36px 0、scale→scale:.96、rise-lg→0 64px + scale:.985(大块面用),过渡曲线共用一条 - 错峰零 JS:兄弟元素按
nth-child递增--reveal-delay(官网分 70/80/90/110ms 四档间隔),同一帧翻牌、靠 delay 排成波浪 - IO 参数:
threshold 0.12+rootMargin '0px 0px -8% 0px'—— 元素要露出 12% 且越过视口底部 8% 缓冲带才触发,贴底边的不算 - 翻完立刻
unobserve,每个元素一生只入场一次;终态顺手把will-change交还auto - 鲁棒性契约:没有这段 JS 整页全是
opacity:0的白板,所以 reduced-motion 或无 IO 时直接把所有元素置 revealed —— 内容可见是底线,动画是增强 - 选择器只抓
[data-reveal]:not([data-revealed]),重复初始化天然幂等 - scroll 监听一律
{ passive: true },观察和翻牌都不碰布局读写
源码(折叠)
scroll-reveal-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>data-reveal 滚动入场编排 — open-design.ai 全页体系复刻</title>
<style>
/* Atelier Zero 令牌(open-design 官网 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);
--sans: 'PingFang SC', 'Microsoft YaHei', -apple-system, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--paper); color: var(--ink); font-family: var(--sans); }
/* ================================================================
data-reveal 体系 —— 照抄官网 globals.css 5349 行起的原始规则。
初始态:透明 + 位移;被 JS 翻成 data-revealed='true' 后过渡到位。
一条 900ms 的长过渡 + cubic-bezier(0.22,1,0.36,1)(快出缓收),
所以入场是"飘"进来而不是"弹"进来。
================================================================ */
[data-reveal] {
opacity: 0;
translate: 0 28px; /* 默认:从下方 28px 升入 */
transition:
opacity 900ms cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-delay, 0ms),
translate 900ms cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-delay, 0ms),
scale 900ms cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-delay, 0ms);
will-change: opacity, translate, scale;
}
/* 四个方向/形态变体(官网原值):属性值本身就是变体名 */
[data-reveal='left'] { translate: -36px 0; }
[data-reveal='right'] { translate: 36px 0; }
[data-reveal='scale'] { translate: 0 0; scale: 0.96; }
[data-reveal='rise-lg'] { translate: 0 64px; scale: 0.985; }
/* 终态:翻牌后归位,并把 will-change 交还给浏览器 */
[data-reveal][data-revealed='true'] {
opacity: 1;
translate: 0 0;
scale: 1;
will-change: auto;
}
/* stagger 原语:不写 JS 排队,直接给兄弟元素按 nth-child 递增
--reveal-delay(官网 cards 用 90ms 间隔、hero-copy 80ms、partners 70ms)。
同一批元素同时进场,靠 delay 错开成波浪。 */
.cards > .card[data-reveal]:nth-child(1) { --reveal-delay: 0ms; }
.cards > .card[data-reveal]:nth-child(2) { --reveal-delay: 90ms; }
.cards > .card[data-reveal]:nth-child(3) { --reveal-delay: 180ms; }
/* 无障碍兜底(官网原样):reduced-motion 下全部立即可见、零过渡 */
@media (prefers-reduced-motion: reduce) {
[data-reveal] {
opacity: 1 !important;
translate: 0 0 !important;
scale: 1 !important;
transition: none !important;
}
}
/* ---------------- 以下是 demo 的页面骨架,与效果本身无关 ---------------- */
.intro {
min-height: 92vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
text-align: center;
padding: 20px;
}
.intro h1 { font-size: clamp(26px, 5vw, 40px); font-weight: 800; letter-spacing: -0.02em; }
.intro h1 em { font-style: normal; background: var(--coral); padding: 0 8px; }
.intro p { color: var(--ink-mute); font-size: 15px; }
.intro .down { margin-top: 24px; font-size: 22px; color: var(--ink-faint); animation: bob 1.6s ease-in-out infinite; }
@keyframes bob { 50% { transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) { .intro .down { animation: none; } }
section { max-width: 860px; margin: 0 auto; padding: 12vh 24px; }
.kicker { font-size: 13px; letter-spacing: 0.14em; color: var(--ink-mute); margin-bottom: 10px; }
.display { font-size: clamp(24px, 4vw, 36px); font-weight: 800; letter-spacing: -0.02em; margin-bottom: 14px; }
.lead { color: var(--ink-soft); line-height: 1.8; max-width: 56ch; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; align-items: center; }
@media (max-width: 640px) { .split { grid-template-columns: 1fr; } }
.panel {
background: var(--bone);
border: 1px solid var(--line);
border-radius: 16px;
padding: 26px;
box-shadow: var(--shadow);
}
.panel .num { font-size: 40px; font-weight: 800; color: var(--coral); -webkit-text-stroke: 1px var(--ink); }
.panel h3 { font-size: 17px; margin: 8px 0 6px; }
.panel p { font-size: 14px; color: var(--ink-mute); line-height: 1.7; }
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
@media (max-width: 640px) { .cards { grid-template-columns: 1fr; } }
.card {
background: var(--bone);
border: 1px solid var(--line);
border-radius: 14px;
padding: 22px;
}
.card .dot { width: 10px; height: 10px; background: var(--coral); display: inline-block; margin-bottom: 12px; }
.card h4 { font-size: 15px; margin-bottom: 6px; }
.card p { font-size: 13px; color: var(--ink-mute); line-height: 1.7; }
.stage {
background: var(--ink);
color: var(--paper);
border-radius: 20px;
padding: 48px 34px;
text-align: center;
}
.stage h3 { font-size: clamp(20px, 3.4vw, 30px); font-weight: 800; }
.stage h3 em { font-style: normal; color: var(--coral); }
.stage p { color: #bfbfbf; margin-top: 10px; font-size: 14px; }
.reset {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 9;
font-family: var(--sans);
font-size: 13px;
padding: 9px 18px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--bone);
color: var(--ink);
cursor: pointer;
box-shadow: var(--shadow);
}
.reset:hover { border-color: var(--coral); }
.tail { text-align: center; color: var(--ink-faint); font-size: 13px; padding: 0 0 10vh; }
</style>
</head>
<body>
<div class="intro">
<h1>一个属性,<em>整页编排</em></h1>
<p>官网每个模块都只是挂了一个 data-reveal —— 往下滚,看它们逐一进场。</p>
<span class="down">↓</span>
</div>
<!-- 默认变体:从下方 28px 升入 -->
<section>
<p class="kicker" data-reveal>模块 01 · 默认升入</p>
<h2 class="display" data-reveal>从下方 28px 飘入</h2>
<p class="lead" data-reveal>不带变体值的 data-reveal 就是这一种:opacity 0 → 1,translate 0 28px → 0,一条 900ms 的 cubic-bezier(0.22,1,0.36,1) 过渡。官网 hero 文案、各段标题全部用它。</p>
</section>
<!-- right 变体:左文右卡,卡片从右侧 36px 滑入 -->
<section>
<div class="split">
<div>
<p class="kicker" data-reveal>模块 02 · 方向变体</p>
<h2 class="display" data-reveal>右栏从右侧滑入</h2>
<p class="lead" data-reveal>data-reveal="right" 初始位移换成 36px 0。官网"方法论"模块的右侧导语、地球模块的球体都是 right 入场,和左栏的默认升入形成对角线节奏。</p>
</div>
<div class="panel" data-reveal="right">
<div class="num">36px</div>
<h3>方向即属性值</h3>
<p>left / right / scale / rise-lg 四个变体只改初始 translate 或 scale,过渡曲线共用一条。</p>
</div>
</div>
</section>
<!-- stagger:三张卡片同批触发,靠 --reveal-delay 错峰 -->
<section>
<p class="kicker" data-reveal>模块 03 · 波浪错峰</p>
<h2 class="display" data-reveal>兄弟元素 90ms 一个</h2>
<div class="cards">
<div class="card" data-reveal><span class="dot"></span><h4>零 JS 排队</h4><p>delay 由 CSS nth-child 递增 --reveal-delay 给出,JS 只管翻牌。</p></div>
<div class="card" data-reveal><span class="dot"></span><h4>同批进场</h4><p>三张卡在同一帧被翻成 revealed,波浪感全靠 delay。</p></div>
<div class="card" data-reveal><span class="dot"></span><h4>节奏令牌</h4><p>官网不同区块用 70 / 80 / 90 / 110ms 四档间隔。</p></div>
</div>
</section>
<!-- scale + rise-lg 变体 -->
<section>
<p class="kicker" data-reveal>模块 04 · 形态变体</p>
<h2 class="display" data-reveal>大块面用 rise-lg</h2>
<div class="stage" data-reveal="rise-lg">
<h3>整块舞台从 <em>64px</em> 下方浮起</h3>
<p>rise-lg = translate 0 64px + scale 0.985,官网结尾 CTA 的桌面端窗口截图就是这么进场的;scale 变体则只做 0.96 → 1 的呼吸。</p>
</div>
</section>
<p class="tail">—— 到底了,点右下角可以重看一遍 ——</p>
<button class="reset" type="button" id="reset">↺ 重置入场</button>
<script>
(() => {
/* ================================================================
JS 侧照抄官网 home-enhancer.astro 的 IntersectionObserver 编排:
- 只选还没翻牌的 [data-reveal]:not([data-revealed])
- threshold: 0.12 —— 元素露出 12% 才算进场
- rootMargin: '0px 0px -8% 0px' —— 视口底部 8% 是缓冲带,
贴着底边的元素不算,要再滚一点才触发
- 翻完立刻 unobserve:每个元素一生只入场一次
鲁棒性契约(官网注释原话的意思):没有这段 JS 页面全是 opacity:0
的"白板",所以 reduced-motion 或没有 IO 时必须直接把所有元素
置为 revealed —— 动画是增强,内容可见是底线。
================================================================ */
const arm = () => {
const elements = document.querySelectorAll('[data-reveal]:not([data-revealed])');
if (elements.length === 0) return;
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduceMotion || !('IntersectionObserver' in window)) {
for (const el of elements) el.dataset.revealed = 'true';
return;
}
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.dataset.revealed = 'true';
observer.unobserve(entry.target);
}
}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
for (const el of elements) observer.observe(el);
};
arm();
/* demo 专属:摘掉全部 data-revealed、回到顶部、重新布防 */
document.getElementById('reset').addEventListener('click', () => {
for (const el of document.querySelectorAll('[data-revealed]')) delete el.dataset.revealed;
scrollTo({ top: 0, behavior: 'auto' });
requestAnimationFrame(arm);
});
})();
</script>
</body>
</html>