<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>纯 CSS 动效</title>
<style>
:root {
--paper: #fbf8f1;
--paper-2: #f3eee2;
--card: #fffdf8;
--ink: #1c2230;
--ink-2: #4a5163;
--ink-3: #636877;
--line: #e6dfd0;
--dot: #d8d0bf;
--blue: #2f5bd3;
--blue-soft: #e8eefc;
--blue-line: rgba(47, 91, 211, 0.35);
--seal: #c0391f;
--seal-soft: #fbe7e2;
--green: #277a4b;
--hi: #ffe066;
--on-blue: #fff;
--shadow: 0 1px 0 rgba(28, 34, 48, 0.04), 0 10px 28px -14px rgba(28, 34, 48, 0.28);
--ease: cubic-bezier(0.16, 1, 0.3, 1);
/* Two damped springs sampled into linear(): damping 0.5 overshoots about 16%, 0.62 about 8%. */
--spring: linear(
0,
0.062,
0.215,
0.411,
0.615,
0.799,
0.95,
1.059,
1.126,
1.158,
1.161,
1.145,
1.116,
1.083,
1.051,
1.023,
1.001,
0.986,
0.977,
0.974,
0.974,
0.978,
0.983,
0.988,
0.994,
0.998,
1.001,
1.003,
1.004,
1.004,
1
);
--spring-soft: linear(
0,
0.042,
0.145,
0.282,
0.432,
0.578,
0.711,
0.825,
0.916,
0.985,
1.033,
1.063,
1.079,
1.084,
1.08,
1.072,
1.06,
1.048,
1.035,
1.024,
1.015,
1.007,
1.001,
0.997,
0.995,
0.993,
0.993,
0.993,
0.994,
0.995,
1
);
--f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
--f-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--f-serif: "Songti SC", STSong, "Noto Serif CJK SC", "Source Han Serif SC", SimSun, Georgia, serif;
/* Lets height transition to and from `auto` (Chrome 129+). */
interpolate-size: allow-keywords;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #14161b;
--paper-2: #1c1f26;
--card: #1e2129;
--ink: #ece7dc;
--ink-2: #bab6ac;
--ink-3: #959aa8;
--line: #31353f;
--dot: #2c3039;
--blue: #8eaaff;
--blue-soft: #1f2a47;
--blue-line: rgba(142, 170, 255, 0.45);
--seal: #f0694e;
--seal-soft: #3b211b;
--green: #5cc189;
--hi: #7d6416;
--on-blue: #14161b;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.25), 0 10px 28px -14px rgba(0, 0, 0, 0.8);
color-scheme: dark;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
color: var(--ink);
font: 15px/1.7 var(--f-sans);
}
header {
max-width: 1220px;
margin: 0 auto;
padding: 22px 16px 6px;
}
h1 {
margin: 0;
font: 900 26px/1.3 var(--f-serif);
}
header p {
margin: 2px 0 0;
font-size: 14px;
color: var(--ink-2);
}
code {
font: 400 0.88em var(--f-mono);
padding: 1px 5px;
border-radius: 5px;
background: var(--paper-2);
border: 1px solid var(--line);
}
.grid {
max-width: 1220px;
margin: 0 auto;
padding: 12px 16px 40px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
gap: 16px;
align-items: start;
}
.card {
min-width: 0;
padding: 16px 18px 18px;
background: var(--card);
border: 2px dashed var(--blue-line);
border-radius: 14px;
box-shadow: var(--shadow);
}
.card h2 {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
align-items: baseline;
margin: 0 0 4px;
font: 700 17px/1.4 var(--f-serif);
}
.card h2 small {
font: 500 11px/1 var(--f-mono);
color: var(--seal);
letter-spacing: 0.08em;
}
.card > p {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.65;
color: var(--ink-2);
}
.support {
display: inline-block;
margin-left: auto;
padding: 2px 7px;
border-radius: 6px;
font: 500 11px/1.4 var(--f-mono);
color: var(--green);
background: color-mix(in srgb, var(--green) 14%, transparent);
}
.support.no {
color: var(--seal);
background: var(--seal-soft);
}
.btn {
font: 500 13px/1 var(--f-sans);
color: var(--ink-2);
padding: 8px 12px;
border-radius: 10px;
border: 1px dashed var(--ink-3);
background: transparent;
cursor: pointer;
transition:
scale 0.45s var(--spring),
color 0.15s,
background-color 0.15s;
}
.btn:hover {
border-style: solid;
color: var(--ink);
background: var(--paper-2);
}
.btn:active {
scale: 0.93;
}
.btn.solid {
border: 1px solid var(--blue);
background: var(--blue);
color: var(--on-blue);
}
/* ---------- 1. springs as easing curves ---------- */
.plot {
display: block;
width: 100%;
height: auto;
margin-bottom: 6px;
overflow: visible;
}
.plot .axis {
stroke: var(--line);
stroke-width: 1;
}
.plot .one {
stroke: var(--ink-3);
stroke-dasharray: 4 4;
stroke-width: 1;
}
.plot polyline {
fill: none;
stroke-width: 2.4;
stroke-linejoin: round;
stroke-linecap: round;
}
.plot text {
font: 400 10px var(--f-mono);
fill: var(--ink-3);
}
.c-ease {
--c: var(--ink-3);
}
.c-spring {
--c: var(--seal);
}
.c-soft {
--c: var(--blue);
}
.plot polyline.c-ease,
.plot polyline.c-spring,
.plot polyline.c-soft {
stroke: var(--c);
}
.race {
display: grid;
gap: 6px;
margin: 4px 0 12px;
}
.lane {
display: grid;
grid-template-columns: 92px 1fr;
align-items: center;
gap: 8px;
font: 400 11px/1.2 var(--f-mono);
color: var(--ink-2);
}
.track {
position: relative;
height: 18px;
border-bottom: 1px dashed var(--line);
}
.track i {
position: absolute;
left: 0;
top: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--c);
transition: left 0.9s var(--e);
}
.race.go .track i {
left: calc(100% - 14px);
}
.c-ease i {
--e: var(--ease);
}
.c-spring i {
--e: var(--spring);
}
.c-soft i {
--e: var(--spring-soft);
}
/* ---------- 2. view transition ---------- */
.stage {
view-transition-name: stage;
min-height: 178px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--paper);
}
.stage ul {
list-style: none;
margin: 0;
padding: 0;
}
.stage li + li {
border-top: 1px dashed var(--line);
}
.stage li button {
display: grid;
grid-template-columns: 40px 1fr;
gap: 6px;
width: 100%;
padding: 9px 2px;
border: 0;
background: none;
color: var(--ink);
text-align: left;
font: 600 14px/1.45 var(--f-serif);
cursor: pointer;
}
.stage li button:hover {
color: var(--blue);
}
.stage li .d {
font: 400 11px/2 var(--f-mono);
color: var(--ink-3);
}
.stage .title {
width: fit-content;
}
.detail h3 {
width: fit-content;
margin: 0 0 8px;
font: 900 21px/1.35 var(--f-serif);
}
.detail p {
margin: 0 0 10px;
font-size: 13px;
color: var(--ink-2);
}
/* The page itself does not move; the stage lifts away and settles in, the clicked title glides. */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
::view-transition-old(stage) {
animation: vt-out 0.2s ease-in both;
}
::view-transition-new(stage) {
animation: vt-in 0.5s var(--spring-soft) both;
}
::view-transition-group(vt-title) {
animation-duration: 0.55s;
animation-timing-function: var(--spring-soft);
}
::view-transition-old(vt-title),
::view-transition-new(vt-title) {
height: 100%;
object-fit: contain;
object-position: left top;
}
@keyframes vt-out {
to {
opacity: 0;
translate: 0 -12px;
}
}
@keyframes vt-in {
from {
opacity: 0;
translate: 0 20px;
}
}
/* ---------- 3. scroll-driven ---------- */
.scroller {
position: relative;
height: 200px;
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--paper);
overscroll-behavior: contain;
}
.progress {
position: sticky;
top: 0;
z-index: 2;
height: 4px;
background: var(--seal);
transform-origin: 0 50%;
scale: 0 1;
}
.scroller ol {
list-style: none;
margin: 0;
padding: 10px 12px 12px;
display: grid;
gap: 10px;
}
.scroller li {
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--card);
font-size: 13px;
color: var(--ink-2);
}
.scroller li b {
font: 600 11px/1 var(--f-mono);
color: var(--blue);
margin-right: 6px;
}
@keyframes grow {
from {
scale: 0 1;
}
to {
scale: 1 1;
}
}
@keyframes rise {
from {
opacity: 0;
translate: 0 36px;
scale: 0.97;
}
}
/* Longhands on purpose: some CSS minifiers fold `animation` + `animation-timeline` into one
shorthand that the browser rejects, and the effect silently disappears. */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.progress {
animation-name: grow;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll();
}
.scroller li {
animation-name: rise;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry 0% entry 75%;
}
}
}
/* ---------- 4. @starting-style ---------- */
.toasts {
display: grid;
gap: 8px;
min-height: 136px;
align-content: start;
margin-top: 12px;
}
.toast {
display: flex;
gap: 10px;
align-items: center;
padding: 9px 12px;
border: 1.5px solid var(--blue);
border-radius: 10px;
background: var(--blue-soft);
color: var(--ink);
font-size: 13px;
transition:
opacity 0.3s var(--ease),
translate 0.55s var(--spring),
scale 0.55s var(--spring);
}
.toast b {
font: 600 11px/1 var(--f-mono);
color: var(--blue);
}
/* The state an element starts from the first time it is rendered: no keyframes needed. */
@starting-style {
.toast {
opacity: 0;
translate: 0 14px;
scale: 0.94;
}
}
.toast.leaving {
opacity: 0;
scale: 0.96;
}
/* ---------- 5. height: auto ---------- */
.fold {
height: 0;
overflow: hidden;
transition: height 0.55s var(--spring-soft);
}
.fold.open {
height: auto;
}
.fold-in {
margin-top: 12px;
padding: 12px 14px;
border-left: 3px solid var(--seal);
background: var(--paper);
font-size: 13px;
color: var(--ink-2);
}
.fold-in p {
margin: 0 0 6px;
}
.fold-in p:last-child {
margin: 0;
}
/* ---------- 6. press and hover ---------- */
.feel {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
}
.lift {
flex: 1 1 180px;
padding: 14px 16px;
border: 2px dashed var(--blue-line);
border-radius: 12px;
background: var(--paper);
font: 700 15px/1.4 var(--f-serif);
transition:
transform 0.6s var(--spring),
border-color 0.2s;
}
.lift:hover {
transform: translateY(-3px) rotate(-0.4deg);
border-style: solid;
border-color: var(--blue);
}
.lift small {
display: block;
margin-top: 2px;
font: 400 11px/1.4 var(--f-mono);
color: var(--ink-3);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
</style>
</head>
<body>
<header>
<h1>纯 CSS 动效</h1>
<p>
不引动画库:弹簧缓动、页面过渡、滚动驱动、首帧入场、高度到
auto。每块右上角显示你的浏览器支不支持;系统开了「减少动态效果」时全部关掉。
</p>
</header>
<main class="grid">
<section class="card">
<h2><small>01</small>弹簧缓动 <code>linear()</code><span class="support" data-check="linear"></span></h2>
<p>
把阻尼弹簧采样成 31 个点写进 <code>linear()</code>:冲过头一点再回来。红线 = <code>--spring</code>(过冲约
16%),蓝线 = <code>--spring-soft</code>(约 8%),灰线是普通的 ease-out。
</p>
<svg class="plot" id="plot" viewBox="0 0 320 132" aria-label="三条缓动曲线对比图" role="img">
<line class="axis" x1="24" y1="118" x2="316" y2="118" />
<line class="axis" x1="24" y1="6" x2="24" y2="118" />
<line class="one" x1="24" y1="34" x2="316" y2="34" />
<text x="0" y="37">1.0</text>
<text x="0" y="121">0</text>
<text x="290" y="130">时间</text>
</svg>
<div class="race" id="race">
<div class="lane c-ease">
<span>ease-out</span><span class="track"><i></i></span>
</div>
<div class="lane c-spring">
<span>--spring</span><span class="track"><i></i></span>
</div>
<div class="lane c-soft">
<span>--spring-soft</span><span class="track"><i></i></span>
</div>
</div>
<button class="btn solid" type="button" id="race-btn">▶ 跑一次</button>
</section>
<section class="card">
<h2><small>02</small>页面过渡 View Transitions<span class="support" data-check="vt"></span></h2>
<p>点一篇:旧画面上移淡出、新画面弹着落下,被点的标题直接「飞」成下一页的大标题。</p>
<div class="stage" id="stage"></div>
</section>
<section class="card">
<h2><small>03</small>滚动驱动 <code>animation-timeline</code><span class="support" data-check="sda"></span></h2>
<p>
在框里滚动:顶上红条是阅读进度(<code>scroll()</code>),卡片进入视口时浮上来(<code>view()</code>)。全程没有一行
JS。
</p>
<div class="scroller" tabindex="0" aria-label="可滚动的示例列表">
<div class="progress"></div>
<ol>
<li><b>01</b>进度条跟着滚动条走,不监听 scroll 事件。</li>
<li><b>02</b>卡片从下方 36px 浮上来,同时淡入。</li>
<li><b>03</b><code>animation-range: entry 0% entry 75%</code> 决定什么时候播完。</li>
<li><b>04</b>动画跑在合成线程,滚得再快也不掉帧。</li>
<li><b>05</b>不支持的浏览器直接显示静止的内容。</li>
<li><b>06</b>减少动态效果时整段关掉。</li>
<li><b>07</b>再往下一点。</li>
<li><b>08</b>到底了,进度条也满了。</li>
</ol>
</div>
</section>
<section class="card">
<h2>
<small>04</small>首帧入场 <code>@starting-style</code><span class="support" data-check="starting"></span>
</h2>
<p>
新插入的元素第一次渲染时从 <code>@starting-style</code> 里的样子过渡过来,不用写
keyframes,也不用等一帧再加类。
</p>
<button class="btn solid" type="button" id="toast-btn">+ 来一条提示</button>
<div class="toasts" id="toasts" aria-live="polite">
<div class="toast"><b>01</b>已保存草稿</div>
<div class="toast"><b>02</b>复制好了</div>
</div>
</section>
<section class="card">
<h2>
<small>05</small>高度到 auto <code>interpolate-size</code><span class="support" data-check="isize"></span>
</h2>
<p>
以前 <code>height: 0 → auto</code> 不能过渡,只能量高度写死。现在根上一句
<code>interpolate-size: allow-keywords</code> 就行,还能配弹簧。
</p>
<button class="btn" type="button" id="fold-btn" aria-expanded="false" aria-controls="fold">展开说明 ▾</button>
<div class="fold" id="fold">
<div class="fold-in">
<p>展开的高度是内容自己撑出来的,换文字、换字号都不用改 CSS。</p>
<p>不支持的浏览器会直接跳到展开状态,没有动画但不坏。</p>
</div>
</div>
</section>
<section class="card">
<h2><small>06</small>按下回弹 · 悬停抬起<span class="support" data-check="linear"></span></h2>
<p>
按钮按下 <code>scale: .93</code>,松手用弹簧弹回;卡片悬停抬起 3px、轻轻歪一点。都是一行
<code>transition</code>。
</p>
<div class="feel">
<button class="btn" type="button">按住我</button>
<button class="btn solid" type="button">再按我</button>
<div class="lift">把鼠标放上来<small>translateY(-3px) rotate(-.4deg)</small></div>
</div>
</section>
</main>
<script>
// ---------- support badges ----------
const checks = {
linear: CSS.supports("transition-timing-function", "linear(0, 1)"),
vt: "startViewTransition" in document,
sda: CSS.supports("animation-timeline: view()"),
starting: "CSSStartingStyleRule" in window,
isize: CSS.supports("interpolate-size: allow-keywords"),
};
const fallback = {
linear: "退回线性",
vt: "直接切换",
sda: "静止显示",
starting: "直接出现",
isize: "直接展开",
};
for (const el of document.querySelectorAll("[data-check]")) {
const ok = checks[el.dataset.check];
el.textContent = ok ? "✓ 支持" : `✗ ${fallback[el.dataset.check]}`;
el.classList.toggle("no", !ok);
}
// ---------- 1. draw the curves straight from the CSS variables ----------
const css = getComputedStyle(document.documentElement);
const linearPoints = (name) =>
css
.getPropertyValue(name)
.replace(/^\s*linear\(|\)\s*$/g, "")
.split(",")
.map(Number);
// cubic-bezier(.16, 1, .3, 1) sampled by solving x(t) with a few bisection steps.
const bezier = (x1, y1, x2, y2) => (x) => {
const f = (t, a, b) => 3 * a * t * (1 - t) ** 2 + 3 * b * t * t * (1 - t) + t ** 3;
let lo = 0;
let hi = 1;
for (let i = 0; i < 24; i++) {
const mid = (lo + hi) / 2;
if (f(mid, x1, x2) < x) lo = mid;
else hi = mid;
}
return f((lo + hi) / 2, y1, y2);
};
const ease = bezier(0.16, 1, 0.3, 1);
const X = (t) => 24 + t * 292;
const Y = (v) => 118 - v * 84;
const plot = document.getElementById("plot");
const line = (cls, values) => {
const pl = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
pl.setAttribute("class", cls);
pl.setAttribute(
"points",
values.map((v, i) => `${X(i / (values.length - 1)).toFixed(1)},${Y(v).toFixed(1)}`).join(" "),
);
plot.append(pl);
};
line(
"c-ease",
Array.from({ length: 41 }, (_, i) => ease(i / 40)),
);
line("c-soft", linearPoints("--spring-soft"));
line("c-spring", linearPoints("--spring"));
const race = document.getElementById("race");
document.getElementById("race-btn").addEventListener("click", () => race.classList.toggle("go"));
// ---------- 2. same-document view transition ----------
const posts = [
{ d: "10-12", t: "给按钮加一点弹簧", p: "按下缩小 7%,松手用 linear() 弹回来。" },
{ d: "10-08", t: "纸感配色的深色模式", p: "白天是奶油纸,夜里是深色书桌。" },
{ d: "10-03", t: "滚动驱动的阅读进度条", p: "一个 animation-timeline 替掉整段 JS。" },
];
const stage = document.getElementById("stage");
function renderList() {
stage.innerHTML = `<ul>${posts
.map(
(p, i) =>
`<li><button type="button" data-i="${i}"><span class="d">${p.d}</span><span class="title">${p.t}</span></button></li>`,
)
.join("")}</ul>`;
}
function renderDetail(i) {
const p = posts[i];
stage.innerHTML = `<div class="detail"><h3 style="view-transition-name: vt-title">${p.t}</h3><p>${p.p} 这里是正文的第一段,真实站点里这是另一张页面。</p><button class="btn" type="button" data-back>← 返回列表</button></div>`;
}
// Run a DOM change as a view transition where supported, otherwise just make the change.
const swap = (change) => (document.startViewTransition ? document.startViewTransition(change) : change());
stage.addEventListener("click", (e) => {
const item = e.target.closest("[data-i]");
if (item) {
// Name only the clicked title, so exactly one element pairs with the next headline.
item.querySelector(".title").style.viewTransitionName = "vt-title";
swap(() => renderDetail(Number(item.dataset.i)));
} else if (e.target.closest("[data-back]")) {
swap(renderList);
}
});
renderList();
// ---------- 4. toasts that enter through @starting-style ----------
const toasts = document.getElementById("toasts");
const words = ["已保存草稿", "复制好了", "已加入稍后读", "收到你的留言"];
let n = toasts.children.length;
document.getElementById("toast-btn").addEventListener("click", () => {
const t = document.createElement("div");
t.className = "toast";
t.innerHTML = `<b>${String(++n).padStart(2, "0")}</b>${words[n % words.length]}`;
toasts.append(t);
while (toasts.children.length > 3) toasts.firstElementChild.remove();
setTimeout(() => {
t.classList.add("leaving");
setTimeout(() => t.remove(), 300);
}, 2600);
});
// ---------- 5. fold ----------
const fold = document.getElementById("fold");
const foldBtn = document.getElementById("fold-btn");
foldBtn.addEventListener("click", () => {
const open = fold.classList.toggle("open");
foldBtn.setAttribute("aria-expanded", String(open));
foldBtn.textContent = open ? "收起说明 ▴" : "展开说明 ▾";
});
</script>
</body>
</html>