---
title: "会滑动的激活圆点侧边栏"
summary: "整个目录只有一个圆点：点哪项它就竖着滑过去、换成分组色，落地把文字顶开，颜色再从左往右刷过去；每段速度都照原片逐帧量。"
category: "navigation (导航)"
origin: "recreated"
inspired_by: "recent.design · Sidebar Active State（Gustavo） https://recent.design/i/9e89xf0-design-guidelines-app-ui"
stack: ["html","css","js"]
tags: ["导航","侧边栏","激活状态","微交互","滚动渐隐"]
demo: "https://zhuoqidev.com/design/demos/sidebar-active-dot/"
url: "https://zhuoqidev.com/design/sidebar-active-dot/"
license: "MIT"
added: "2026-10-20"
translation: "https://zhuoqidev.com/en/design/sidebar-active-dot/index.md"
---

# 会滑动的激活圆点侧边栏

> 整个目录只有一个圆点：点哪项它就竖着滑过去、换成分组色，落地把文字顶开，颜色再从左往右刷过去；每段速度都照原片逐帧量。

在 recent.design 看到 Gustavo 一个设计规范 App 的录屏（只有视频、没有公开代码），我和 Agent 每 70ms 抽一帧，把激活状态拆成了四步再复现。目录条目是我自己写的同类设计话题。

## 看点

- **只有一个圆点**：不是每一项各带一个点、显示或隐藏，而是整个目录共用一个绝对定位的点。点了新的一项，它从上一项竖着滑过去（跨分组也一样），半路换成新分组的颜色。眼睛能跟着它走，知道「刚才在那、现在在这」。
- **五步有先后，而且没有一段是匀速的**（原片 60 帧逐帧量出来、写成 CSS `linear()` 曲线）：
  1. 点下去，上一项立刻退回灰色、滑回原位；
  2. 圆点**先往左退出文字区、在左边空白里弧线跳过去**：横向比竖直跑得快（竖直走到 30% 时横向已经退回 60%），还会比停靠位置再往左多绕 2px，然后慢慢回到贴着文字左边的位置；竖直方向慢起、中间快、落地前长长地减速，时长随距离变（一行约 0.2 秒，五行约 0.25 秒）。两个方向各走各的逐帧曲线（Web Animations 关键帧），叠出来就是一条往左鼓的弧线；
  3. 落地时压扁一帧（1.25 × 0.8），停 2–3 帧，再往右「顶」10px（4 帧）；
  4. 晚一帧，文字被挤开，往右滑 22px：0.1 秒走完八成，然后慢慢停住；
  5. 晚一帧，颜色从左往右刷过去：先冲、到四分之三处顿一下、再慢慢刷完（0.2 秒）。
- **刷色只用 CSS**：文字的背景是一条「左半分组色、右半当前色」的渐变，宽 200%，用 `background-clip: text` 裁到字形上；激活时把 `background-position` 从 100% 滑到 0%，颜色就从左往右扫过去。右半用 `currentColor`，所以悬停时是深色、平时是灰色，不用写两套。
- **分组各有颜色**：排版蓝、颜色粉、布局橙、动效紫，分组标题的小图标、圆点、激活文字、右侧内容的小标题都用同一个变量 `--c`。
- **滚动渐隐**：列表两端用 `mask: linear-gradient(transparent, #000 36px, … )` 渐隐，告诉人上下还有，不用画分隔线。

## 怎么用

1. 拷 `:root` 里的两条曲线（`--shove`、`--sweep`）、脚本里的三组关键帧（`Y_LONG`、`Y_SHORT`、`X_BACK`）、`.item`、`.item .t`、`.dot` 这几段 CSS 和脚本里的 `activate()`；每个分组设一个 `--c`。
2. 圆点放在列表容器里（容器 `position: relative`），位置用条目的 `offsetTop` 算：它是相对布局的，列表滚动也不会错位；而且只在点击时才读，页面加载时不碰布局。
3. 条目是真的链接，键盘能 Tab 过去，上下方向键在条目间移动；当前项用 `aria-current` 标出来，样式也挂在它上面。

## 坑

- `background-clip: text` 要配 `-webkit-text-fill-color: transparent`，只写 `color: transparent` 在 Safari 上不行。
- 文字的平移和刷色要**等圆点顶到**：语义上的 `aria-current` 点击时就设，视觉上的 `.lit`（挤开 + 上色）等圆点落地、顶过之后约 0.1 秒才加；否则几件事同时发生，就看不出是圆点「挤开」了文字。
- 速度曲线别用 `ease` 凑：按录屏逐帧量位置，直接写成 `linear(0, 0.065 9%, 0.13 18%, …)`，比任何三次贝塞尔都像。验证时 Playwright 的虚拟时钟管不了 CSS 过渡，要用 `document.getAnimations()` 暂停后手动拨 `currentTime` 再读。
- 连续快速点：每次先清掉上一轮还没执行的定时器，再把圆点身上的动画 `commitStyles()` 写回内联样式后 `cancel()`，新的动画从当前位置开始。**播完、靠 `fill: forwards` 停着的动画也要先 commit**，只 commit「播放中」的会让圆点跳回旧位置。但**落地压扁那段只能取消、不能 commit**：正好在压扁的几十毫秒里再点一下，会把扁的形状永久写进样式（线上真出现过）。
- 别用 `getBoundingClientRect()` 算圆点位置：列表滚动后它会变，点就跑偏；`offsetTop` 不受滚动影响。
- `prefers-reduced-motion` 下所有过渡都关掉，圆点和颜色直接跳到位。

## 参考实现（完整源码）

在线演示：https://zhuoqidev.com/design/demos/sidebar-active-dot/。单文件，存成 .html 就能打开。

### index.html

```html
<!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>
```
