ZhuoQi Devzhuoqidev.comRSSllms.txt

← All entries~/design/sidebar-active-dot

NavigationRecreatedRecreated from recent.design · Sidebar Active State(Gustavo)

A Sidebar Whose Active Dot Slides

One dot for the whole list slides to the picked item, bumps its text aside, then the colour sweeps in; speeds measured on the reference.

html · css · jsMITAdded #navigation#sidebar#active state#micro-interaction#scroll fades

Live demoOpen in a new tab ↗

A recreation of a recording of Gustavo’s design-guidelines app on recent.design (video only, no public code). With an agent I pulled a frame every 70 ms and broke the active state into four steps. The list items are my own take on the same kind of design topics.

What makes it work

  • There is only one dot: not a dot per item shown and hidden, but one absolutely positioned dot for the whole list. Pick another item and it slides there from the previous one (across groups too), switching to the new group’s colour halfway. The eye can follow it: it was there, now it is here.
  • Five steps, in order, none at a constant speed (measured frame by frame on the 60 fps reference and written as CSS linear() curves):
    1. on click the previous item goes grey and slides home at once;
    2. the dot backs out of the text to the left and arcs over through the empty gutter: it moves sideways faster than it moves down (60% of the way back while only 30% of the way down), overshoots 2px further left than its resting place and creeps back against the text’s edge; vertically it is slow off the mark, fast in the middle, with a long settle, and longer trips take longer (one row ≈ 0.2 s, five rows ≈ 0.25 s). Each axis runs its own frame-by-frame curve (Web Animations keyframes), which together trace a curve bowing to the left;
    3. on landing it squashes for a frame (1.25 × 0.8), rests 2–3 frames, then nudges 10px right (4 frames);
    4. a frame later the text gives way and slides 22px right: 80% in the first tenth of a second, then it coasts;
    5. one frame later the colour sweeps across left to right: a rush, a stall around three quarters, a slow finish (0.2 s).
  • The sweep is pure CSS: the text’s background is a gradient, left half group colour and right half the current colour, 200% wide, clipped to the glyphs with background-clip: text; activating slides background-position from 100% to 0%. The right half is currentColor, so it is dark on hover and grey at rest without a second rule.
  • A colour per group: typography blue, colour pink, layout orange, motion purple; the group icon, the dot, the active text and the heading on the right all read one variable, --c.
  • Scroll fades: both ends of the list fade out with mask: linear-gradient(transparent, #000 36px, … ), which says “there is more” without a divider.

How to use it

  1. Copy the two curves in :root (--shove, --sweep), the three keyframe sets in the script (Y_LONG, Y_SHORT, X_BACK), the CSS for .item, .item .t and .dot, and activate() from the script; give each group a --c.
  2. Put the dot inside the list container (position: relative) and place it from the item’s offsetTop: that is layout-relative, so scrolling the list never moves it off, and it is only read on click, so nothing touches layout while the page loads.
  3. Items are real links: Tab reaches them and the up / down arrow keys move between them; the current one carries aria-current, which the styles hang on too.

Pitfalls

  • background-clip: text needs -webkit-text-fill-color: transparent; color: transparent alone fails in Safari.
  • The text’s shift and sweep must wait for the bump: aria-current (meaning) is set on click, but .lit (shoved and coloured) only about 0.1 s after the dot has landed and nudged; otherwise everything happens at once and the dot no longer reads as pushing the text aside.
  • Don’t approximate the speed with ease: measure positions frame by frame on the recording and write them as linear(0, 0.065 9%, 0.13 18%, …), closer than any cubic Bézier. To check it, note that Playwright’s fake clock can’t drive CSS transitions: pause them via document.getAnimations() and set currentTime by hand before reading values.
  • Rapid clicks: clear the previous round’s pending timers, then commitStyles() the dot’s animations into its inline style and cancel() them, so the next move starts where it is. Commit the finished ones held by fill: forwards too: committing only running animations makes the dot jump back to an old position. But drop the landing squash, never commit it: a click during those few dozen milliseconds would freeze the dot flat for good (it happened on the live page).
  • Don’t place the dot with getBoundingClientRect(): it changes as the list scrolls and the dot drifts off; offsetTop doesn’t.
  • Under prefers-reduced-motion every transition is off and the dot and colour jump straight into place.
$For agents: index.md

Source MIT licensed. Take it.

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

Say hi on WeChat

Liu ZhuoQi's WeChat QR code

Scan it in WeChat, or long-press it on your phone

Mention why you're reaching out, or I might miss it.