链接悬停填充:细线长满、文字翻色,以及一些奇怪的 BUG

文章正文里的链接,悬停时底部细线向上长满、文字翻成背景色。两条实现路线的实测差别、两处会坏掉的环境,以及"快速划过也要播完"为什么只能用一段脚本。

文章正文里的链接,悬停时底部那条细线向上长满整个链接,文字同时从链接色翻成背景色。 下面是这套效果的实现、实测数据,以及它在什么环境下不成立。

数字都在本机 Chromium 151(Chrome for Testing)上用 CDP 量的, 量法写在「怎么复算」一节。 最后,这是一篇记录向,可能对你没有帮助。

目标与参数

项 取值
静止态 一条 2px 细线,撑满链接宽度,位置与原来的下划线重合
悬停态 细线向上长满(`background-size: 100% 2px` → `100% 100%`)
文字 从 `--rose-ink` 翻成 `--paper`
时长 / 缓动 `--dur-base`(260ms)/ `--ease-out`
触屏 阉割
键盘 `:focus-visible` 与 hover 走同一套

填充色与翻转色是既有链接色的反用:`--rose-ink` 压在 `--paper` 上是 5.12:1, 反过来的对比度相同,所以没有引入新的配色对,`check:contrast` 的阈值不用改。

作用范围

`main` 里的链接都有这个效果,几处例外:

位置 效果
首页的「最近写的」、文章列表页的标题、项目页、关于页、标签页、404 ✅
文章正文 ✅
文章页的目录、上一篇 / 下一篇、返回列表 ❌(显式标记 `.no-fill`)
标签胶囊 ❌(`.tag`,胶囊本身有底色)
页头导航、页脚与备案 ❌(它们在 `main` 之外)

排除导航与页脚除了观感,还有一个具体原因:那些链接的静止色不是链接色, 它们是 `--ink-muted` / `--ink-dim`。而 `Base.astro` 的 scoped 样式里写着 `.site-nav a:hover { color: var(--rose-ink) }`、`.site-footer__filing a:hover { color: var(--rose-ink) }`, 这几条比新加的样式表优先级高(Astro 的 scoped 样式会带上属性选择器)。 结果是悬停时文字颜色被设成填充色本身:

```plaintext
导航链接悬停 400ms 后:
  color = rgb(186, 56, 86)     ← --rose-ink,与填充同色
  background-size = 100% 100%  ← 填充已铺满
  → 玫瑰色字压在玫瑰色块上,那块看起来是空的
```

正文链接没有这条冲突(`a:hover` 那条优先级更低),悬停终态是 `rgb(247, 246, 241)`,也就是 `--paper`。

这类冲突不会报错、也不影响其它属性:只有颜色被顶掉, 表现是“填充块里没有字”。改 hover 颜色时先查一遍有没有更高优先级的同名规则。

实现路线

路线一:整字翻色

文字颜色用一次普通的 `color` 过渡,与填充块的上升同时进行。块是链接自己的背景:

```css
a {
  background-image: linear-gradient(var(--fill), var(--fill));
  background-repeat: no-repeat;
  background-size: 100% 2px;
  background-position: 0 100%;
  transition: background-size 260ms, color 260ms;
}
a:hover {
  background-size: 100% 100%;
  color: var(--flip);
}
```

中间那 130ms 里,被盖住的下半个字是“玫瑰色字压在玫瑰色块上”。 把填充阈值设为 65%、文字色取过渡中点后截屏,逐像素统计覆盖区域: 纸色像素 0.0%,字与底色同色。

路线二:精确裁切

字色不是 `color`,而是一张裁到字形上的渐变(下半是背景色、上半是链接色, 硬断点在中间),用 `background-position` 让断点跟着填充块的上沿走:

```css
a {
  color: transparent;
  background-image: linear-gradient(to top, var(--paper) 50%, var(--rose-ink) 50%);
  background-size: 100% 200%;
  background-position: 0 0%;
  background-clip: text;
  transition: background-position 260ms var(--ease-out);
}
a:hover {
  background-position: 0 100%;
}

a::after {
  content: ''; position: absolute; inset: 0; z-index: -1;
  background: var(--rose-ink);
  transform: scaleY(0); transform-origin: bottom;
  transition: transform 260ms var(--ease-out);
}
a:hover::after {
  transform: scaleY(1);
}
```

两个量都是“进度 p 的线性函数”:块的上沿在 `(1−p)·H`,渐变里那条硬断点在窗口里的 位置也在 `(1−p)·H`。同一条缓动、同一个时长,于是每一帧都重合。 实测把填充阈值设为 50%:填充上沿落在图 y82,理论值 y84,差 0.5 CSS px; 同一段覆盖区域里纸色像素 17.8%,字完整可见。

取舍

路线一 · 整字翻色 路线二 · 精确裁切
中间态 有,覆盖区域里字与底色同色 无,逐帧重合
折行的链接 正常,逐行分别填充 坏:字形消失
带底色的容器 正常 坏:链接整个看不见
依赖 只有 `background-size` 过渡 `background-clip: text` + `z-index: -1`

本站用路线一。中间态只存在 260ms,而路线二要付出两种环境下失效的代价, 而且失效是静默的:页面不会报错,只是看起来“这里没样式”。

精确裁切不成立的 BUG

注意,局限于笔者水平,您可能在实际编码过程中可以完美解决这些问题

折行的链接

`background-clip: text` 的渐变按每一行各自计算(每行都当成“上半链接色、下半背景色”), 悬停终态下整个链接的字都会变成背景色,在纸面上就是字消失; 同时 `inset: 0` 的伪元素在折行的行内元素上退化了,填充块也看不见。 用 22ch 的窄容器把一条长链接逼成五行,实测:

路线 悬停终态
整字翻色 逐行分别长满,能读
精确裁切 字形消失,填充块退化

带底色的容器

路线二的填充块靠 `z-index: -1` 躲在字形下面,而“负 z-index 的子孙”画在 最近的层叠上下文里。链接与那个上下文之间只要有一块带背景的元素 (表格单元格、提示块、`--paper-sunken` 面板),填充就会被那块背景盖住:

层 悬停时
容器背景 `--paper-sunken`
填充块 被盖住,不可见
文字 翻成 `--paper-sunken`
结果 整条链接消失

绕法只有一条:给链接套一层自己开层叠上下文(`isolation: isolate`)的容器, 把填充挂在那一层上。折行那条仍然绕不开。

快速划过 `a` 时不中断 hover 动画

现象

鼠标快速划过一个链接(悬停 50ms 就走)时,动画只播了个开头。 实测(正常速度 260ms,每 80ms 采一次 `background-size`):

```plaintext
悬停 60ms 后移开:
  + 40ms   填充 31.4%
  +120ms   填充  1.5%
  +200ms   填充  0.0%   → 回到细线
```

指针离开 `:hover` 之后过渡改向,CSS 还有一条“反向”规则会按比例缩短时长, 于是只剩一闪。

为什么不用 CSS 解决

动画与过渡都挂在 `:hover` 上,选择器一失效就被撤掉,而 CSS 里没有任何东西 能记住“刚才 hover 过”。三条看着可行的绕法都不成立:

绕法 实际结果
换成 `@keyframes` 动画 同样挂在 `:hover` 上,指针一走照样被撤掉
退场加 `transition-delay` 延迟期间属性停在旧值,填充卡在半路不动
`animation-play-state: paused` 离开时是“暂停”不是“继续”,填充冻在半路

使用只监听类名的脚本

指针离开时,如果进场还没跑完,就把“悬停态”这个类名留到那一刻再摘; 摘掉类名才开始退场,于是一进一出都完整。`.fx-hover` 与 `:hover` 共用同一份声明, 脚本不碰任何样式属性:

```js
var until = new WeakMap(), timer = new WeakMap();

// ⚠️ CSS 时长读出来可能是 "260ms",也可能是 "2.08s"
function ms(v) { var n = parseFloat(v) || 0; return String(v).indexOf('ms') >= 0 ? n : n * 1000; }
function dur(el) {
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) return 0;  // 没有过渡就不必等
  return ms(getComputedStyle(el).getPropertyValue('--dur-base'));
}

document.querySelectorAll(SEL).forEach(function (el) {
  el.addEventListener('pointerenter', function () {
    clearTimeout(timer.get(el));
    el.classList.add('fx-hover');
    until.set(el, performance.now() + dur(el));
  });
  el.addEventListener('pointerleave', function () {
    var off = function () { el.classList.remove('fx-hover'); };
    var rest = (until.get(el) || 0) - performance.now();
    if (rest > 0) timer.set(el, setTimeout(off, rest));   // 进场没跑完 → 等它跑完
    else off();                                           // 已经跑完 → 立刻退场
  });
});
```

注意:读 CSS 时长进 JS 时的单位:同一个变量读出来可能是 `"260ms"`, 也可能是 `"2.08s"`,浏览器会把 1 秒以上的时长换成秒表示。 `parseFloat("2.08s")` 等于 `2.08`,于是“等 2.08 毫秒”,类名当场被摘掉、 补完功能静默失效;而普通速度下读到的是 `"260ms"`,看不出问题。 把时长放大 8 倍观察才会暴露。所有把 CSS 时长读进 JS 的地方都要先判单位。

实测同一划法(悬停 50–60ms 后移开):

```plaintext
有这段脚本:  90.7% → 99.5% → 100% → 停住 → 9.3% → 0.1% → 2px
没有:        31.4% →  1.5% →  0.0%(回到细线)
```

退场方向:继续向上

进场自下而上灌满,退场不原路退回、而是继续向上排空走掉 —— 这需要两个方向 用不同的插值起点,而一个属性的过渡只有一个起点。做法是把填充拆成两层,两层同色:

```css
/* ① 进场层:链接自己的背景,细线 → 长满 */
a {
  background-image: linear-gradient(var(--fill), var(--fill));
  background-repeat: no-repeat;
  background-size: 100% 2px;
  background-position: 0 100%;
  /* 静止态的 transition 写成 0s:离开 hover 时它瞬间回细线,把退场让给下面那层 */
  transition: background-size 0s, color 260ms var(--ease-out);
}
a:hover {
  background-size: 100% 100%;
  transition: background-size 260ms var(--ease-out), color 260ms var(--ease-out);
}

/* ② 退场层:同色的伪元素,缩向顶端 = 向上排空 */
a::after {
  content: ''; position: absolute; inset: 0; z-index: -1;
  background: var(--fill);
  transform: scaleY(0);
  transform-origin: top;
  transition: transform 260ms var(--ease-out);
}
a:hover::after {
  transform: scaleY(1);
  /* ③ 延迟一个进场时长才补满,否则它一上来就盖满,进场的长上去就看不见了 */
  transition: transform 0s 260ms;
}
```

偏差在于 `transition` 的第四个位置(延迟):进场期间退场层保持零高度, 进场结束的那一刻它补满,离开时它缩向顶端。

实测(慢放 8 倍逐帧):

时刻 进场层 退场层 scaleY
首屏 160ms `100% 2px` 0
进场中 96% 0
悬停终态 100% 1
退场 +220ms `100% 2px` 0.559
退场 +300ms `100% 2px` 0.192
退场 +420ms `100% 2px` 0.037

退场时填充块始终贴在元素顶端往下缩,底部细线同时已经回来。 退化情况两条:快速划进划出时退场层可能还没补满,那时就是普通的“退回去”; 链接落在带底色的容器里时,退场层被容器背景盖住,表现是填充直接消失。 本站没有采用这一版。

怎么复算

静止态可以直接在控制台里看:

```js
getComputedStyle(document.querySelector('main a:not(.tag, .no-fill)')).backgroundSize
// → "100% 2px"     细线
getComputedStyle(document.querySelector('main a:not(.tag, .no-fill)')).textDecorationLine
// → "none"         细线取代了原来的下划线
```

中间态要把它钉住再看(否则 260ms 一晃就过去了)。用 CDP 注入一段只改动目标值的样式, 等过渡跑完再截屏:

```css
html.has-hover .lk--c { background-position: 0 50% !important; }
html.has-hover .lk--c::after { transform: scaleY(0.5) !important; }
```

「快速划过」要发真实鼠标事件,不是 `dispatchEvent`:

```js
Input.dispatchMouseEvent({ type: 'mouseMoved', x, y })   // 移到链接上
// 等 50ms
Input.dispatchMouseEvent({ type: 'mouseMoved', x: 5, y: 5 })
```

通用性

项 结论
量测环境 Chromium 151.0.7922.34(Chrome for Testing),Linux
用到的特性 `background-size` 过渡、`:is()`、`@media (hover: hover) and (pointer: fine)`、`WeakMap`
浏览器验证范围 ⚠️ 只在 Chromium 里量过;Firefox 与 Safari 没验
触屏 按设计整段不生效(媒体查询),未在真机验收
`prefers-reduced-motion: reduce` 过渡时长归零(`base.css` 有全局兜底),未单独验收

排错表

现象 可能原因 下一步
填充块里没有字,整块看起来是空的 有更高优先级的 `:hover` 规则把 `color` 设成了填充色 查该链接所在的容器有没有自己的 hover 颜色规则(`--rose-ink` 这类),用 `getComputedStyle(el).color` 在悬停时对比
快速划过只闪一下 补完脚本没跑(JS 关了)、或它的选择器没覆盖到这条链接 看元素上有没有 `.fx-hover`;确认选择器与样式表范围一致
悬停时长变了但类名立刻就摘 CSS 时长读进 JS 没判单位(`2.08s` → `2.08`) 打印 `getComputedStyle(el).getPropertyValue('--dur-base')` 看实际字符串
折行的链接悬停后字消失 用了精确裁切那一路 换整字翻色;这条路对折行不成立
带底色的块里链接悬停后看不见 同上,填充被容器背景盖住 换整字翻色,或给链接套一层 `isolation: isolate`
静止态多出一条下划线 `text-decoration` 没去掉 细线本身就是下划线,`text-decoration: none`