← 全部文章
ISSUE 002 · 2026/09/09

一条会发光的线:阅读进度的交互设计

阅读进度不该是一根孤零零的背景条。本文拆解墨栈的完整方案:两像素的顶栏、跟着标题拐弯的右侧路径、移动端的圆环与抽屉,以及背后的 zustand 状态哲学。

⏱ 6 分钟2089 字2026/09/09UI 设计Zustand

「进度」这个词在互联网里被用滥了:下载进度、安装进度、加载进度。但阅读进度不一样——它不是机器在干活,是人在读字。把它做成一根进度条,就像给一首诗配了一张进度表。

所以墨栈的阅读体系不止一条线。它有三层:顶栏的细线负责「此刻在哪」,右侧的路径负责「这段旅程怎么拐」,移动端的圆环负责「随时可以回来」。

状态层:阅读是可恢复的旅程§

在讨论任何视觉之前,先定义数据。阅读状态只有三样东西:

  • fraction——0 到 1 的滚动位置,易失,更新频繁;
  • history[slug]——按文章保存的上次滚动位置,持久化到 localStorage;
  • activeHeading——滚动监视器报告的当前标题 id。

history 不是「读过的最大进度」。往上翻,存下来的数字会跟着变小。首页的「继续阅读」和文内的恢复芯片,用的都是这个离开时的位置。

zustand 的两个存储策略§

fraction 必须易失:它跟着滚动一直变,写进存储就是灾难。history 必须持久:它是「继续阅读」的全部根基。zustand 的 partialize 恰好把这两类状态切开,而且只把 history 交给 localStorage:

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";

export const useReading = create<ReadingState>()(
  persist(
    (set) => ({
      history: {},
      fraction: 0,
      activeHeading: null,
      setFraction: (f) => {
        const rounded = Math.round(f * 1000) / 1000;
        set((s) => (Math.abs(s.fraction - rounded) < 0.0005 ? s : { fraction: rounded }));
      },
      setActiveHeading: (id) => set((s) => (s.activeHeading === id ? s : { activeHeading: id })),
      setProgress: (slug, fraction) =>
        set((s) => {
          const rounded = Math.round(fraction * 1000) / 1000;
          if (Math.abs((s.history[slug] ?? 0) - rounded) < 0.002) return s;
          return { history: { ...s.history, [slug]: rounded } };
        }),
    }),
    {
      name: "inkstack-reading-v1",
      storage: createJSONStorage(() => localStorage),
      partialize: (s) => ({ history: s.history }),
    }
  )
);

存储键是 inkstack-reading-v1。形状一旦要改,换键比做迁移更干净。

阈值守卫:别让状态抖动§

每个 setter 在变化不够大时返回原来的 state,而不是一个新对象。fraction 先收成三位小数,变化不到 0.0005 不广播;历史进度变化不到 0.002 不落笔;标题 id 相同则直接跳过。顶栏自己还更克制:动画值挪动超过 0.0024 才推进 store。这不是优化,是礼貌——高频更新静悄悄地流过,百分比读数才不会跟着每一帧重渲染。

文章页另有一条慢路径:每 900ms,以及离开页面前,把当前滚动写进 history。顶栏负责「此刻」,这条路径负责「下次还能找回来」。

顶栏:两像素的戏§

最细的线,最克制。它用 rAF 做非线性缓动,让「追」比「贴」更有质感。进入文章时先把 fraction 归零,避免上一篇的进度闪在这一篇的顶上:

useEffect(() => {
  useReading.getState().setFraction(0);

  let raf = 0;
  let current = 0;
  let lastPushed = -1;

  const step = () => {
    const doc = document.documentElement;
    const max = doc.scrollHeight - window.innerHeight;
    const target = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
    current += (target - current) * 0.14;
    if (Math.abs(target - current) < 0.0009) current = target;
    if (fillRef.current) fillRef.current.style.transform = "scaleX(" + current + ")";
    if (Math.abs(current - lastPushed) > 0.0024) {
      lastPushed = current;
      useReading.getState().setFraction(current);
    }
    raf = current === target ? 0 : requestAnimationFrame(step);
  };

  const start = () => {
    if (!raf) raf = requestAnimationFrame(step);
  };
  start();
  window.addEventListener("scroll", start, { passive: true });
  window.addEventListener("resize", start);
  return () => {
    if (raf) cancelAnimationFrame(raf);
    window.removeEventListener("scroll", start);
    window.removeEventListener("resize", start);
  };
}, []);

注意两点:scaleX 直接改 DOM,不经过 setState;误差收进 0.0009 之后停掉 rAF。一个闲置页面不应该还在燃烧帧。滚动比例用 scrollY / (scrollHeight - innerHeight),和后面路径、历史进度用的是同一把尺子。

右侧:一条跟着标题拐弯的线§

桌面端的主体。目录不再是一排圆点和一条竖直填充条,而是一条贴在标题左侧的 SVG 路径:二级标题走最内侧的车道,每深一级向右 12px。

function laneX(depth: number): number {
  return 8 + depth * 12;
}

车道变了的时候,折线不硬拐。垂直段占着间隙的两头,中间 55% 用一段三次贝塞尔接上,两端仍然是竖直的。同一级标题之间则是一根直线。

进度画在路径上§

当前标题的纵坐标投到这条路径上,二分大约 18 次,找到对应的弧长,再写成 stroke-dasharray。圆点用 getPointAtLength 贴在同一个位置。路径形状没变、读者也没有打开「减少动效」时,300ms 内用 cubic-bezier(0.4, 0, 0.2, 1) 从旧弧长走到新弧长。换文章或路径重算时直接跳到终点,避免把整条目录播一遍。

当前只有两种状态§

列表项不再区分「已读 / 未读」。data-state 只有 active 和 rest:当前标题字色提到最亮的纸色、字重 500;其余保持辅文色,200ms 的颜色过渡。二级标题前面带「1、」「2、」这样的序号,三级、四级只靠 12px / 24px 的缩进,字色再淡一档。

滚动快到页底(fraction ≥ 0.995,且和真实滚动位置相差不超过 0.08)时,路径直接点亮最后一节,避免末节标题已经滚出观察区、高亮却停在倒数第二节。

换页时把指针按住§

客户端切到另一篇文章时,页面还在回顶。选择器如果跟着标题走,侧栏会从底闪到顶。navPin 在目录 id 变化后把索引冻在 0,等滚动停稳——或者最多 4 秒——再放开。这个标记活在模块内存里:客户端导航能记住「上一篇是谁」,整页刷新会把它清掉,所以刷新后仍尊重浏览器留下的真实滚动位置。

侧栏自己溢出时,当前行会被滚进可视区,上下各留 24px,避免激活项贴着裁切边缘。

移动端:圆环与抽屉§

屏幕窄到放不下侧栏时,体系收缩成一个右下角的浮标:SVG 圆环画着当前进度,点开是一张从底部升起的抽屉。抽屉里的路径和桌面是同一份列表。

圆环的 dash 数学§

圆环周长 2π × 14 ≈ 87.96,剩余量写成 CSS 变量 --ring,由 stroke-dashoffset 吃掉。写在 DOM 上,不进 React 渲染;只有抽屉开着的时候,百分比才会推进组件状态,供「已读 n%」那一行使用。

const RING = 2 * Math.PI * 14;

function paintRing(el: SVGCircleElement, fraction: number) {
  el.style.setProperty("--ring", (RING * (1 - fraction)).toFixed(2) + "px");
}

抽屉:别挡着人看正文§

抽屉的进入曲线是 cubic-bezier(0.22, 1, 0.36, 1) 的「快出慢停」,400ms 收尾。点任意节点,先关抽屉、等 80ms 再滚动——给关闭让路,滚动就不会和抽屉打架。标题副文写着阅读时间、字数和已读百分比:约 分钟 · 字 · 已读 %。

跳转本身看系统偏好:开了减少动效就用 auto,否则 smooth。

动效预算表§

动效是最容易超支的东西。这张表是墨栈阅读体系的「预算单」——路径描边控制在 300ms,没有两秒一轮的呼吸节点:

元素时长缓动作用
顶栏填充追到误差 < 0.0009 即停0.14 线性阻尼平滑追踪
路径描边300ms0.4, 0, 0.2, 1沿折线走到当前标题
标题字色200mscolor点明当前节
抽屉进场400ms0.22, 1, 0.36, 1空间层级
圆环进度400ms同上即时反馈

减少动效打开时,路径描边直接落点,圆环和标题的过渡被关掉,跳转也不再平滑。不尊重这个开关的动效,都只是噪音。

三步,把「进度」还给读者§

  1. 顶栏只答「此刻在哪」,用 2px 和阻尼表达尊敬;
  2. 路径只答「读到哪一节」,线跟着标题的深浅拐弯,可点、可跟随;
  3. 圆环只答「回来吧」,把最深的状态藏进最小的形状。

进度体系的终点不是数字,是安全感:读者随时可以离开,也随时被欢迎回来。