返回博客

SVG 圆形进度环从 3 点开始?用 SVG transform 属性而非 CSS 旋转

不依赖图表库,手写一个环形进度条(Ring / Gauge / Donut)是前端常见需求。stroke-dasharray + stroke-dashoffset 这套算法并不复杂,但有两个坑会让你调半天:弧线死活从 3 点钟位置出发,以及暗色主题下颜色莫名失效。

现象

stroke-dasharray / stroke-dashoffset 画好进度弧后,想让它从 12 点(顶部)顺时针增长,于是给 <circle> 加上 Tailwind 的 -rotate-90 origin-center

<circle
  cx={cx} cy={cy} r={r}
  strokeDasharray={circ}
  strokeDashoffset={offset}
  className="-rotate-90 origin-center stroke-blue-500"
/>

结果:弧线依然从 3 点出发,或者旋转中心跑偏,弧段错位成奇怪的形状。

第二个问题:想让颜色跟随深色主题,写了 stroke-${tone},结果进度环颜色完全没有,一片透明或黑色。

根因

问题一:CSS transforms 在 SVG 几何元素上 transform-box 默认是 view-box

CSS 规范规定,SVG 内的几何元素(<circle><path><rect> 等)的 transform-box 默认值是 view-box,而非 fill-box。这意味着 transform-origin: center(即 Tailwind 的 origin-center)解析的是整个 SVG 视口的中心,并非这个 <circle> 自身的圆心——而且不同浏览器对内层 SVG 元素的 CSS transform 行为还存在不一致。

相比之下,外层 <svg> 元素是一个普通的替换/块级盒,transform-origin: 50% 50% 行为正常,CSS rotate 可以直接用。

问题二:Tailwind JIT 只扫描字面量类名

Tailwind 的 JIT 编译器在扫描源码时,只识别完整的字面量字符串。动态拼接的 `stroke-${tone}`"stroke-" + tone 根本不会出现在生成的 CSS 里,导致运行时这个 class 不存在,颜色为空。

坑:对 SVG <circle> 用 CSS rotate,旋转中心不是圆心

transform-box: view-boxorigin-center 指向 SVG 视口中心而非圆心。当圆心不在视口正中时,旋转后弧段就会错位。就算圆心恰好在视口中心,不同浏览器的实现也可能存在偏差,不可依赖。

正解

旋转:用 SVG transform 属性,显式传入圆心坐标

SVG transform 属性支持 rotate(angle cx cy) 三参数形式,第二、三个参数就是旋转中心的坐标。这完全绕开了 CSS transform-box 的问题:

const r = (size - thickness) / 2;   // 内缩半个线宽,防止被视口裁切
const circ = 2 * Math.PI * r;
const offset = circ * (1 - pct / 100);  // 0% → offset=circ(全隐),100% → offset=0
 
<svg width={size} height={size}>
  {/* 轨道圆 */}
  <circle
    cx={size / 2} cy={size / 2} r={r}
    fill="none" strokeWidth={thickness}
    className="stroke-[var(--color-surface-hover)]"
  />
  {/* 进度弧 */}
  <circle
    cx={size / 2} cy={size / 2} r={r}
    fill="none" strokeWidth={thickness}
    strokeLinecap="round"
    strokeDasharray={circ}
    strokeDashoffset={offset}
    transform={`rotate(-90 ${size / 2} ${size / 2})`}  {/* ← SVG 属性,显式圆心 */}
    className="stroke-[var(--color-primary)]"           {/* ← 字面量 var 类 */}
    style={{ transitionProperty: "stroke-dashoffset", transitionDuration: "200ms" }}
  />
</svg>

rotate(-90 cx cy) 将 3 点钟起点逆时针转到 12 点,随后 dashoffset 减小,弧段顺时针增长。

主题色:用字面量任意类引用 CSS 变量

stroke-[var(--color-primary)] 这样的字面量任意类,CSS 变量会在 [data-theme=dark] 下重新解析,零 JS 实现主题切换。多种颜色变体用映射表而非字符串拼接:

const strokeByTone = {
  primary: "stroke-[var(--color-primary)]",
  danger:  "stroke-[var(--color-danger)]",
  success: "stroke-[var(--color-success)]",
} as const;
 
// ✅ 正确
className={strokeByTone[tone]}
 
// ❌ 错误:JIT 扫不到,生成的 CSS 里没有这个 class
className={`stroke-${tone}`}
正解:rotate 三参数指定圆心 + 字面量 var 类

SVG transform="rotate(-90 cx cy)" 绕自身圆心旋转,与 CSS transform-box 无关;颜色用 stroke-[var(--color-x)] 字面量类,Tailwind JIT 能静态分析到,暗色主题下 CSS 变量自动重解析。

附:不确定进度(Spinner)变体

旋转整个 <svg> 元素(不是 <circle>),CSS transform 在这里是安全的:

<motion.svg
  animate={{ rotate: 360 }}
  transition={{ repeat: Infinity, duration: 1.4, ease: "linear" }}
  ...
>

注意必须是 ease: "linear",缓动曲线会让每圈末尾出现肉眼可见的卡顿。或者直接用 Tailwind 内置的 animate-spin motion-reduce:animate-none,简洁且自带无障碍保护。

一句话外卖

CSS transform-origin: center 在 SVG <circle> 上解析的是视口中心而非圆心——凡是要旋转 SVG 几何元素,用 SVG transform="rotate(a cx cy)" 属性,把圆心坐标写死。