<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>会滑动的激活圆点侧边栏</title>
<style>
:root {
--paper: #ecebe7;
--panel: #fafaf9;
--ink: #1b1d22; /* hovered */
--ink-2: #8a8d94; /* resting items */
--ink-3: #b4b6bb;
--line: #ecebe8;
--new-bg: #e3f6e6;
--new-ink: #2e8a46;
--typo: #3b6fe0;
--color: #e0466b;
--layout: #ea8a1c;
--motion: #8b5cf6;
--f-sans: system-ui, -apple-system, "Inter", "PingFang SC", "Microsoft YaHei", sans-serif;
--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);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #101115;
--panel: #18191e;
--ink: #f1f0ec;
--ink-2: #8d9099;
--ink-3: #5d6069;
--line: #26282f;
--new-bg: #183424;
--new-ink: #6fd28f;
--typo: #7ea2ff;
--color: #ff7d9b;
--layout: #ffad4d;
--motion: #b69bff;
color-scheme: dark;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 28px 16px;
background: var(--paper);
color: var(--ink);
font: 15px/1.5 var(--f-sans);
}
.app {
display: grid;
grid-template-columns: 64px 280px minmax(0, 340px);
height: min(560px, calc(100vh - 56px));
border-radius: 18px;
overflow: hidden;
background: var(--panel);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 24px 60px -24px rgba(0, 0, 0, 0.35);
}
/* the painted strip on the left: a sky over a field, all gradients */
.art {
background:
radial-gradient(60% 18% at 40% 52%, rgba(255, 239, 214, 0.9), transparent 70%),
radial-gradient(80% 22% at 70% 40%, rgba(255, 226, 190, 0.7), transparent 70%),
linear-gradient(#7d93ad 0%, #b8a99a 38%, #e6c49a 52%, #9aa860 64%, #5f7a3a 82%, #4d6430 100%);
filter: saturate(0.9) blur(0.4px);
}
/* ---------- the list ---------- */
.side {
position: relative;
overflow-y: auto;
border-left: 1px solid var(--line);
border-right: 1px solid var(--line);
/* Scroll fades: the list dissolves into the panel at both ends instead of being cut off. */
-webkit-mask: linear-gradient(transparent, #000 36px, #000 calc(100% - 36px), transparent);
mask: linear-gradient(transparent, #000 36px, #000 calc(100% - 36px), transparent);
scrollbar-width: none;
}
.side::-webkit-scrollbar { display: none; }
.groups { position: relative; margin: 0; padding: 30px 26px 40px 30px; list-style: none; }
.group + .group { margin-top: 26px; }
.group h2 {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 8px;
font-size: 15px;
font-weight: 550;
color: var(--ink);
}
.group h2 svg { width: 15px; height: 15px; color: var(--c); flex: none; }
.group ul { margin: 0; padding: 0; list-style: none; }
.item {
--c: inherit;
position: relative;
display: flex;
align-items: center;
gap: 8px;
height: 31px;
color: var(--ink-2);
text-decoration: none;
outline: none;
cursor: pointer;
}
/* Every curve below is sampled frame by frame (60 fps) from the reference recording and written as
linear(): none of these moves at a constant speed. */
:root {
/* the text pushed aside: 80% of the way in a tenth of a second, then it coasts */
--shove: linear(0, 0.18 11%, 0.45 22%, 0.64 33%, 0.73 44%, 0.82 56%, 0.91 67%, 0.95 83%, 1);
/* the colour sweep: a rush, a stall around three quarters, then a slow finish */
--sweep: linear(0, 0.07 8%, 0.21 17%, 0.33 25%, 0.39 33%, 0.53 42%, 0.67 50%, 0.75 58%, 0.77 67%, 0.85 75%, 0.91 83%, 0.97 92%, 1);
--push: 22px; /* how far the text is shoved */
--nudge: 10px; /* how far the dot itself moves right when it hits */
}
.item .t {
/* Text is painted with a two-colour gradient clipped to the glyphs: its right half is the plain
colour, its left half the group's colour. Sliding the background from 100% to 0% sweeps the
colour across the word from left to right. */
background: linear-gradient(90deg, var(--c) 49%, currentColor 51%) 100% 0 / 200% 100% no-repeat;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
/* leaving: back to grey at once, slide home quickly */
transition: translate 0.14s cubic-bezier(0.2, 0.8, 0.3, 1), background-position 0s;
white-space: nowrap;
}
.item:hover,
.item:focus-visible { color: var(--ink); }
.item:focus-visible .t { text-decoration: underline 1px var(--ink-3); text-underline-offset: 4px; }
/* .lit is added by the script the moment the dot bumps into the item (not on click): the text is
shoved right and the colour sweeps across it. aria-current is set on click, for meaning only. */
.item.lit .t {
translate: var(--push) 0;
background-position: 0 0;
transition: translate 0.15s var(--shove), background-position 0.2s var(--sweep) 16ms;
}
.badge {
padding: 1px 7px;
border-radius: 99px;
background: var(--new-bg);
color: var(--new-ink);
font-size: 11px;
font-weight: 600;
transition: translate 0.14s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.item.lit .badge { translate: var(--push) 0; transition: translate 0.15s var(--shove); }
/* ---------- the one dot ----------
A single element for the whole list, moved by the script (Web Animations) on keyframes measured
from the reference: the outer box carries it up or down, the inner one sideways. The two run on
different curves, so the path bows out to the left, into the empty gutter, and back. */
.dot {
position: absolute;
left: 22px;
top: 0;
width: 6px;
height: 6px;
margin-top: -3px;
pointer-events: none;
}
.dot i {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background: var(--dot, transparent);
transition: background-color 0.1s;
}
/* ---------- the page beside it ---------- */
.page { padding: 34px 32px; overflow: hidden; }
.page .crumb { font-size: 12px; font-weight: 600; letter-spacing: 0.06em; color: var(--c, var(--ink-2)); transition: color 0.3s; }
.page h1 { margin: 10px 0 12px; font-size: 26px; line-height: 1.25; letter-spacing: -0.01em; }
.page p { margin: 0; color: var(--ink-2); }
.page .swap { transition: opacity 0.18s, translate 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); }
.page .swap.out { opacity: 0; translate: 0 6px; }
@media (max-width: 720px) {
.app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; width: min(380px, 100%); height: min(620px, calc(100vh - 56px)); }
.art { display: none; }
.side { border: 0; border-bottom: 1px solid var(--line); }
.page { padding: 20px 26px 24px; }
.page h1 { font-size: 21px; }
}
@media (prefers-reduced-motion: reduce) {
.dot i, .item .t, .item.lit .t, .badge, .item.lit .badge, .page .swap { transition: none !important; }
}
</style>
</head>
<body>
<div class="app">
<div class="art" aria-hidden="true"></div>
<nav class="side" aria-label="设计规范目录">
<div class="groups" id="groups">
<span class="dot" id="dot" aria-hidden="true"><i></i></span>
</div>
</nav>
<main class="page" id="page" aria-live="polite">
<div class="swap" id="swap"><div class="crumb" id="crumb"></div><h1 id="title"></h1><p id="desc"></p></div>
</main>
</div>
<script>
const ICONS = {
typo: '<path d="M2 12 5.5 3h1L10 12M3.3 9h5.4M11 12V8.5a1.8 1.8 0 0 1 3.4-.8M14.5 7.7V12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>',
color: '<circle cx="8" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="5" cy="10.5" r="2.6" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="11" cy="10.5" r="2.6" fill="none" stroke="currentColor" stroke-width="1.4"/>',
layout: '<rect x="2" y="3" width="12" height="10" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M2 6.5h12M6.5 6.5V13" stroke="currentColor" stroke-width="1.4"/>',
motion: '<path d="M2 12c1.5-6 4-8 6-8s4.5 2 6 8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><circle cx="2.5" cy="12" r="1.4" fill="currentColor"/><circle cx="13.5" cy="12" r="1.4" fill="currentColor"/>',
};
// [group key, title, [[item, isNew, one-line note], …]]
const DATA = [
["typo", "排版", [
["字距", 0, "大号标题收紧一点字距,小字放宽一点,读起来才均匀。"],
["换行与断词", 0, "标题用 text-wrap: balance,正文用 pretty,别留一个孤零零的尾字。"],
["等宽数字", 1, "会跳动的数字用 tabular-nums,每一位一样宽,不会左右抖。"],
["视觉对齐", 1, "播放图标往右挪一点、圆形图标放大一点,眼睛看着才居中。"],
["图标尺寸", 0, "图标跟着字号走,用 1em 或者和行高对齐。"],
]],
["color", "颜色", [
["OKLCH", 0, "在 OKLCH 里调明暗,换色相时亮度看起来不变。"],
["噪点纹理", 1, "大面积渐变加一层细噪点,消掉色带。"],
["阴影代替描边", 0, "卡片用一圈很淡的阴影收边,比 1px 描边更柔和。"],
["图片描边", 0, "图片外面加一圈半透明内描边,浅色图片才不会糊进背景。"],
]],
["layout", "布局", [
["嵌套圆角", 1, "内层圆角 = 外层圆角 − 内边距,套在一起才同心。"],
["点击热区", 0, "小图标的可点区域至少 40×40,用伪元素扩出去。"],
["HTML 背景色", 1, "给 html 也设背景色,回弹滚动时露出来的不是白边。"],
["裁剪路径", 0, "clip-path 做形状过渡,比 overflow 加圆角更灵活。"],
["滚动渐隐", 0, "可滚动区域两端用 mask 渐隐,告诉人后面还有。"],
]],
["motion", "动效", [
["图标变形", 0, "图标之间切换用形变,别直接替换。"],
["按下反馈", 0, "按下缩到 0.97,松手用弹簧回来。"],
["缓动曲线", 0, "进场用 ease-out,离场用 ease-in,持续时间别超过 300ms。"],
["依次出场", 0, "列表项错开 30–50ms 出场,一眼就知道是一组。"],
["可被打断", 1, "动画进行中再次操作要能立刻转向,而不是等它播完。"],
]],
];
const groups = document.getElementById("groups");
const dot = document.getElementById("dot");
const items = [];
for (const [key, title, list] of DATA) {
const g = document.createElement("section");
g.className = "group";
g.style.setProperty("--c", `var(--${key})`);
g.innerHTML = `<h2><svg viewBox="0 0 16 16" aria-hidden="true">${ICONS[key]}</svg>${title}</h2><ul></ul>`;
for (const [name, isNew, note] of list) {
const li = document.createElement("li");
const a = document.createElement("a");
a.className = "item";
a.href = `#${encodeURIComponent(name)}`;
a.innerHTML = `<span class="t"></span>${isNew ? '<span class="badge">New</span>' : ""}`;
a.querySelector(".t").textContent = name;
a.dataset.group = key;
a.dataset.groupTitle = title;
a.dataset.note = note;
li.append(a);
g.querySelector("ul").append(li);
items.push(a);
}
groups.append(g);
}
const swap = document.getElementById("swap");
const reduced = matchMedia("(prefers-reduced-motion: reduce)");
const pip = dot.firstElementChild;
const NUDGE = 10; // px the dot moves right when it bumps the text (CSS --nudge pushes the text 22px)
// Keyframes measured frame by frame (60 fps) on the reference, as progress from start (0) to end (1).
// Up / down, a long trip: slow off the mark, fast in the middle, a long settle.
const Y_LONG = [0, 0.059, 0.132, 0.303, 0.48, 0.633, 0.747, 0.832, 0.891, 0.934, 0.959, 0.977, 0.987, 0.992, 0.997, 1];
// A one-row hop starts quick instead.
const Y_SHORT = [0, 0.16, 0.33, 0.51, 0.65, 0.77, 0.85, 0.91, 0.95, 0.97, 0.985, 0.995, 1];
// Sideways, as a share of the nudge: it backs out of the text fast (well ahead of the vertical move),
// overshoots 2px further left into the gutter, and creeps back to rest against the text's edge.
const X_BACK = [1, 0.78, 0.62, 0.38, 0.16, -0.03, -0.19, -0.2, -0.2, -0.19, -0.18, -0.16, -0.11, -0.08, -0.02, -0.01, 0];
// Before a new move, every movement (running, paused or finished and held by fill) is written back
// into the inline style and dropped, so the dot stays exactly where it is and the next move starts there.
// The landing squash is only ever dropped: committed mid-squash it would leave the dot flat for good.
const stop = (el) => {
el.getAnimations().forEach((a) => {
if (a.id !== "squash" && a.playState !== "idle") a.commitStyles();
a.cancel();
});
el.style.scale = "";
};
const at = (el) => {
const [x = "0", y = "0"] = (getComputedStyle(el).translate === "none" ? "0 0" : getComputedStyle(el).translate).split(" ");
return [parseFloat(x), parseFloat(y)];
};
const frames = (curve, f) => curve.map((v, i) => ({ translate: f(v), offset: i / (curve.length - 1) }));
let current = null;
let timers = [];
const later = (ms, fn) => timers.push(setTimeout(fn, ms));
const activate = (a, { instant = false } = {}) => {
if (a === current) return;
timers.forEach(clearTimeout);
timers = [];
// The old item lets go at once: grey again, sliding home.
if (current) {
current.removeAttribute("aria-current");
current.classList.remove("lit");
}
current = a;
a.setAttribute("aria-current", "true");
// The dot lives in .groups, the items' offsetParent: offsetTop is layout-relative, so scrolling the
// list never skews it, and nothing is read until the reader clicks (no layout work on load).
const y1 = a.offsetTop + a.offsetHeight / 2;
const colour = `var(--${a.dataset.group})`;
stop(dot);
stop(pip);
const [, y0] = at(dot);
const [x0] = at(pip);
const quick = instant || reduced.matches;
const swapPage = () => {
document.getElementById("crumb").textContent = a.dataset.groupTitle;
document.getElementById("crumb").style.setProperty("--c", colour);
document.getElementById("title").textContent = a.querySelector(".t").textContent;
document.getElementById("desc").textContent = a.dataset.note;
swap.classList.remove("out");
};
if (quick) {
dot.style.translate = `0 ${y1}px`;
pip.style.translate = `${NUDGE}px 0`;
dot.style.setProperty("--dot", colour);
a.classList.add("lit");
swapPage();
return;
}
// Up or down: time grows gently with distance; the measured curves are one sample per frame, so the
// reference's own trips last (samples − 1) frames: a one-row hop 0.2 s, five rows 0.25 s.
const dist = Math.abs(y1 - y0);
const d = Math.min(380, 250 * Math.max(0.8, (dist / 110) ** 0.2));
const yCurve = dist < 50 ? Y_SHORT : Y_LONG;
dot.animate(frames(yCurve, (v) => `0 ${y0 + (y1 - y0) * v}px`), { duration: d, fill: "forwards" });
// Sideways: back out of the text into the gutter and creep to rest, a little longer than the trip.
// Starting from rest (a quick second click), it still dips the 2px into the gutter.
pip.animate(frames(X_BACK, (v) => `${v > 0 ? x0 * v : v * NUDGE}px 0`), { duration: d * 1.1, fill: "forwards" });
later(d * 0.45, () => dot.style.setProperty("--dot", colour));
// Landing: squashed for a frame or two (8×8 → 10×8 on the reference).
later(d * 0.92, () =>
pip.animate([{ scale: "1 1" }, { scale: "1.25 0.8", offset: 0.4 }, { scale: "1 1" }], { id: "squash", duration: 60, easing: "ease-out" }),
);
// A beat at rest, then the bump: the dot moves right and, a frame later, the text gives way.
const bump = d * 1.1 + 50;
later(bump, () => {
stop(pip);
pip.animate(
[{ translate: `${at(pip)[0]}px 0` }, { translate: `${NUDGE * 0.55}px 0`, offset: 0.25 }, { translate: `${NUDGE * 0.85}px 0`, offset: 0.55 }, { translate: `${NUDGE}px 0` }],
{ duration: 90, fill: "forwards" },
);
});
later(bump + 16, () => a.classList.add("lit"));
// the page on the right
swap.classList.add("out");
later(140, swapPage);
};
groups.addEventListener("click", (e) => {
const a = e.target.closest(".item");
if (!a) return;
e.preventDefault();
activate(a);
history.replaceState(null, "", a.getAttribute("href"));
});
// Arrow keys move between items like a real menu.
groups.addEventListener("keydown", (e) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
const i = items.indexOf(document.activeElement);
if (i < 0) return;
e.preventDefault();
items[Math.max(0, Math.min(items.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))].focus();
});
const fromHash = items.find((a) => a.getAttribute("href") === location.hash);
activate(fromHash ?? items[8], { instant: true });
</script>
</body>
</html>