链接悬停填充:细线长满、文字翻色,以及一些奇怪的 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` 过渡,与填充块的上升同时进行。块是链接自己的背景:
```cssa { 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` 让断点跟着填充块的上沿走:
```cssa { 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` 共用同一份声明,
脚本不碰任何样式属性:
```jsvar 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 |
退场时填充块始终贴在元素顶端往下缩,底部细线同时已经回来。 退化情况两条:快速划进划出时退场层可能还没补满,那时就是普通的“退回去”; 链接落在带底色的容器里时,退场层被容器背景盖住,表现是填充直接消失。 本站没有采用这一版。
怎么复算
静止态可以直接在控制台里看:
```jsgetComputedStyle(document.querySelector('main a:not(.tag, .no-fill)')).backgroundSize // → "100% 2px" 细线 getComputedStyle(document.querySelector('main a:not(.tag, .no-fill)')).textDecorationLine // → "none" 细线取代了原来的下划线
```
中间态要把它钉住再看(否则 260ms 一晃就过去了)。用 CDP 注入一段只改动目标值的样式, 等过渡跑完再截屏:
```csshtml.has-hover .lk--c { background-position: 0 50% !important; } html.has-hover .lk--c::after { transform: scaleY(0.5) !important; }
```
「快速划过」要发真实鼠标事件,不是 `dispatchEvent`:
```jsInput.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` |