前言

Hexo 能够自动生成分类页,但许多主题默认只把分类渲染成“名称 + 数量”的文字链接。它当然足够清晰,却很难承担网站内容入口的视觉职责。

这次我为博客的分类页制作了一套全息收藏卡风格的磁贴:分类名称、文章数量、图标和说明被组织到同一张卡片中;桌面端移动鼠标时,卡片会轻微倾斜,反光也会跟随指针位置变化;移动端则自动关闭倾斜,保持稳定的单列阅读。

设计氛围参考了 TonyCrane 笔记站首页的收藏卡效果。参考组件公开于 note-homepage-cards,并使用 GPL-3.0 许可证。本文没有复制该项目的 Svelte 或 Pokémon 卡片源码,而是根据 Hexo Butterfly 的分类 DOM,使用原生 CSS 与 JavaScript 重新实现了一套更轻量的版本。

这套磁贴并不是 Hexo 专属。核心仍然是 HTML、CSS 和 JavaScript;Hexo 只负责提供分类名称、文章数量与链接。


一、先看看实际效果

下面是文章内的真实交互预览。桌面端可以把鼠标移到磁贴上,观察倾斜角度和高光位置;切换网站的明暗主题,也能看到对应配色。演示磁贴不会跳离本文。

预览和真实分类页共用同一套样式与倾斜算法,因此这里看到的不是截图,而是组件本身。


二、组件具备哪些功能

  • 把默认分类文字列表转换为响应式磁贴网格
  • 显示分类名称、文章数量、图标、类型和简介
  • 为指定分类配置独立图标、说明与色相
  • 未配置的新分类自动获得稳定主题色和文件夹图标
  • 桌面端根据指针位置计算 3D 倾斜角度
  • 全息高光跟随鼠标移动
  • 支持亮色与暗色主题
  • 手机端自动单列并关闭倾斜
  • 尊重系统的“减少动态效果”设置
  • 支持 Butterfly 的 PJAX 页面切换
  • JavaScript 加载失败时保留原始分类链接

三、它是怎样工作的

1. 不修改 Butterfly 模板

Butterfly 生成的分类页通常包含以下结构:

1
2
3
4
5
6
7
8
9
10
<div class="category-lists">
<ul class="category-list">
<li class="category-list-item">
<a class="category-list-link" href="/categories/Web-开发/">
Web 开发
</a>
<span class="category-list-count">4</span>
</li>
</ul>
</div>

脚本读取现有链接与数量,然后在浏览器中增强为磁贴。这样不需要直接修改 themes/butterfly,也不会破坏 Hexo 自动生成的地址。

2. 用 CSS 变量传递指针位置

每张卡片维护四个变量:

1
2
3
4
--tile-x: 50%;
--tile-y: 50%;
--tile-rx: 0deg;
--tile-ry: 0deg;

JavaScript 根据指针相对卡片的位置修改它们。旋转变量控制 3D 倾斜,坐标变量控制径向渐变的中心,于是高光会像反射一样跟随鼠标。

3. 未知分类自动配色

分类名称会经过一个简单哈希函数,得到稳定色相。同一个名称每次构建和访问都会得到相同颜色,又不必为每个新分类手动写 CSS。

4. 渐进增强

原始链接没有被服务器端删除。CSS 或 JavaScript 失效时,访客仍然能够看到 Butterfly 默认分类列表并正常进入分类,这比把分类页完全交给脚本生成更稳妥。


四、源码下载

压缩包内容:

1
2
3
4
5
unmyic-category-tiles/
├── category-tiles.css
├── category-tiles.js
├── butterfly-inject.yml
└── README.txt

下面也会直接展示完整 CSS 和 JavaScript,方便复制。长代码块可以点击底部按钮展开,也可以使用右上角按钮复制。


五、完整 CSS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
/*
* 分类页全息磁贴。
* 视觉语言参考收藏卡的厚边框、主题渐变与随指针移动的反光,
* 结构和实现均为适配 Butterfly 分类数据的独立版本。
*/
.category-lists.category-tiles-ready {
padding: 8px 0 18px;
}

.category-tile-preview {
margin: 24px 0 34px;
}

.category-tile-preview .category-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 22px;
margin: 0 !important;
padding: 0 !important;
}

.category-tile-preview .category-list-item {
margin: 0 !important;
padding: 0 !important;
list-style: none !important;
perspective: 900px;
}

.category-tile-preview .category-list-item::before {
display: none !important;
}

.category-tile-preview .category-tile-link {
box-sizing: border-box;
text-decoration: none !important;
}

.category-lists.category-tiles-ready > .category-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
margin: 0;
padding: 0;
}

.category-lists.category-tiles-ready .category-list-item {
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
perspective: 900px;
}

.category-lists.category-tiles-ready .category-list-item::before {
display: none;
}

.category-tile-link {
--tile-hue: 210;
--tile-x: 50%;
--tile-y: 50%;
--tile-rx: 0deg;
--tile-ry: 0deg;
position: relative;
display: flex;
overflow: hidden;
min-height: 278px;
padding: 25px;
border: 2px solid hsl(var(--tile-hue) 72% 72% / 0.62);
border-radius: 22px;
background:
radial-gradient(
circle at 80% 12%,
hsl(var(--tile-hue) 92% 74% / 0.34),
transparent 34%
),
linear-gradient(
145deg,
hsl(var(--tile-hue) 85% 98% / 0.96),
hsl(calc(var(--tile-hue) + 26) 72% 93% / 0.94)
);
box-shadow:
0 16px 38px rgba(22, 51, 88, 0.16),
inset 0 0 0 5px rgba(255, 255, 255, 0.42);
color: #1d334d !important;
isolation: isolate;
transform:
rotateX(var(--tile-rx))
rotateY(var(--tile-ry))
translateZ(0);
transform-style: preserve-3d;
transition:
transform 0.2s ease,
border-color 0.3s ease,
box-shadow 0.3s ease;
will-change: transform;
}

.category-tile-link::before {
position: absolute;
inset: 0;
z-index: -1;
background:
radial-gradient(
circle at var(--tile-x) var(--tile-y),
rgba(255, 255, 255, 0.72),
transparent 26%
),
repeating-linear-gradient(
115deg,
transparent 0 16px,
hsl(calc(var(--tile-hue) + 65) 92% 72% / 0.08) 17px 20px,
transparent 21px 34px
);
content: '';
opacity: 0.72;
mix-blend-mode: screen;
pointer-events: none;
transition: opacity 0.3s ease;
}

.category-tile-link::after {
position: absolute;
inset: 9px;
z-index: -1;
border: 1px solid rgba(255, 255, 255, 0.62);
border-radius: 15px;
content: '';
pointer-events: none;
}

.category-tile-link:hover {
border-color: hsl(var(--tile-hue) 88% 68% / 0.92);
box-shadow:
0 22px 48px hsl(var(--tile-hue) 48% 28% / 0.24),
0 0 24px hsl(var(--tile-hue) 88% 66% / 0.22),
inset 0 0 0 5px rgba(255, 255, 255, 0.5);
color: #122a43 !important;
}

.category-tile-link:hover::before {
opacity: 1;
}

.category-tile-content {
display: flex;
width: 100%;
flex-direction: column;
transform: translateZ(24px);
}

.category-tile-topline {
display: flex;
justify-content: space-between;
align-items: center;
}

.category-tile-type,
.category-tile-count {
padding: 4px 9px;
border: 1px solid hsl(var(--tile-hue) 60% 50% / 0.24);
border-radius: 999px;
background: rgba(255, 255, 255, 0.42);
color: hsl(var(--tile-hue) 45% 32%);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.1em;
line-height: 1.5;
}

.category-tile-count {
letter-spacing: 0.02em;
}

.category-tile-icon {
display: grid;
width: 68px;
height: 68px;
place-items: center;
margin: 28px 0 17px;
border: 1px solid hsl(var(--tile-hue) 76% 66% / 0.45);
border-radius: 20px;
background:
linear-gradient(
145deg,
rgba(255, 255, 255, 0.88),
hsl(var(--tile-hue) 84% 82% / 0.56)
);
box-shadow:
0 9px 24px hsl(var(--tile-hue) 60% 38% / 0.18),
inset 0 1px 0 #fff;
color: hsl(var(--tile-hue) 68% 46%);
font-size: 29px;
}

.category-tile-title {
margin: 0 0 8px;
color: #172f49;
font-size: clamp(23px, 3vw, 32px);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.25;
}

.category-tile-description {
margin: 0;
color: rgba(29, 51, 77, 0.72);
font-size: 13px;
line-height: 1.75;
}

.category-tile-action {
display: flex;
gap: 8px;
align-items: center;
margin-top: auto;
padding-top: 18px;
color: hsl(var(--tile-hue) 64% 39%);
font-size: 12px;
font-weight: 750;
}

.category-tile-action i {
transition: transform 0.25s ease;
}

.category-tile-link:hover .category-tile-action i {
transform: translateX(5px);
}

[data-theme='dark'] .category-tile-link {
border-color: hsl(var(--tile-hue) 82% 68% / 0.5);
background:
radial-gradient(
circle at 80% 12%,
hsl(var(--tile-hue) 92% 62% / 0.24),
transparent 36%
),
linear-gradient(
145deg,
hsl(var(--tile-hue) 42% 18% / 0.97),
hsl(calc(var(--tile-hue) + 28) 38% 11% / 0.97)
);
box-shadow:
0 18px 42px rgba(0, 0, 0, 0.34),
inset 0 0 0 5px rgba(164, 224, 255, 0.07);
color: #e5f6ff !important;
}

[data-theme='dark'] .category-tile-link:hover {
border-color: hsl(var(--tile-hue) 94% 74% / 0.9);
box-shadow:
0 22px 50px rgba(0, 0, 0, 0.42),
0 0 30px hsl(var(--tile-hue) 90% 64% / 0.28),
inset 0 0 0 5px rgba(164, 224, 255, 0.1);
color: #fff !important;
}

[data-theme='dark'] .category-tile-link::after {
border-color: hsl(var(--tile-hue) 86% 78% / 0.22);
}

[data-theme='dark'] .category-tile-type,
[data-theme='dark'] .category-tile-count {
border-color: hsl(var(--tile-hue) 76% 70% / 0.25);
background: rgba(5, 18, 34, 0.38);
color: hsl(var(--tile-hue) 88% 84%);
}

[data-theme='dark'] .category-tile-icon {
border-color: hsl(var(--tile-hue) 88% 70% / 0.42);
background:
linear-gradient(
145deg,
hsl(var(--tile-hue) 55% 28% / 0.92),
hsl(calc(var(--tile-hue) + 25) 48% 16% / 0.92)
);
box-shadow:
0 0 24px hsl(var(--tile-hue) 88% 60% / 0.2),
inset 0 1px 0 hsl(var(--tile-hue) 90% 86% / 0.3);
color: hsl(var(--tile-hue) 94% 82%);
}

[data-theme='dark'] .category-tile-title {
color: #eefaff;
text-shadow: 0 0 12px hsl(var(--tile-hue) 84% 68% / 0.28);
}

[data-theme='dark'] .category-tile-description {
color: rgba(222, 242, 255, 0.72);
}

[data-theme='dark'] .category-tile-action {
color: hsl(var(--tile-hue) 90% 80%);
}

@media (max-width: 760px) {
.category-tile-preview .category-list,
.category-lists.category-tiles-ready > .category-list {
grid-template-columns: 1fr;
gap: 18px;
}

.category-tile-link {
min-height: 248px;
padding: 22px;
transform: none !important;
}

.category-tile-icon {
width: 59px;
height: 59px;
margin: 22px 0 14px;
font-size: 25px;
}
}

@media (prefers-reduced-motion: reduce) {
.category-tile-link {
transform: none !important;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
}

六、完整 JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
/**
* 将 Butterfly 的原生分类文字列表增强为可访问的分类磁贴。
* 原始链接仍保留,因此禁用 JavaScript 时会自然退化为主题默认列表。
*/
(function () {
var CATEGORY_META = {
'Web 开发': {
icon: 'fa-code',
type: 'WEB / CREATE',
description: '记录前端、后端、Hexo 与博客组件的设计和实现过程。',
hue: 203
},
'学习资料': {
icon: 'fa-graduation-cap',
type: 'STUDY / NOTES',
description: '课程复习、有限元方法,以及持续整理的结构化学习笔记。',
hue: 258
}
};

function hashHue(value) {
var hash = 0;
for (var index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return 190 + (hash % 105);
}

function createElement(tag, className, text) {
var element = document.createElement(tag);
element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}

function bindTilt(link) {
if (
link.dataset.categoryTiltReady === 'true' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) return;

link.addEventListener('pointermove', function (event) {
if (window.innerWidth <= 760) return;
var rect = link.getBoundingClientRect();
var x = (event.clientX - rect.left) / rect.width;
var y = (event.clientY - rect.top) / rect.height;
link.style.setProperty('--tile-x', (x * 100).toFixed(1) + '%');
link.style.setProperty('--tile-y', (y * 100).toFixed(1) + '%');
link.style.setProperty('--tile-rx', ((0.5 - y) * 7).toFixed(2) + 'deg');
link.style.setProperty('--tile-ry', ((x - 0.5) * 9).toFixed(2) + 'deg');
});

link.addEventListener('pointerleave', function () {
link.style.setProperty('--tile-x', '50%');
link.style.setProperty('--tile-y', '50%');
link.style.setProperty('--tile-rx', '0deg');
link.style.setProperty('--tile-ry', '0deg');
});

link.dataset.categoryTiltReady = 'true';
}

function enhanceTile(item) {
if (item.dataset.categoryTileReady === 'true') return;

var link = item.querySelector(':scope > .category-list-link');
var countElement = item.querySelector(':scope > .category-list-count');
if (!link) return;

var name = link.textContent.trim();
var count = countElement ? countElement.textContent.trim() : '0';
var meta = CATEGORY_META[name] || {
icon: 'fa-folder-open',
type: 'COLLECTION',
description: '浏览这个分类下收录的文章、笔记与专题内容。',
hue: hashHue(name)
};

if (countElement) countElement.remove();
link.textContent = '';
link.className = 'category-list-link category-tile-link';
link.style.setProperty('--tile-hue', meta.hue);
link.setAttribute(
'aria-label',
'打开分类“' + name + '”,共 ' + count + ' 篇文章'
);

var content = createElement('span', 'category-tile-content');
var topLine = createElement('span', 'category-tile-topline');
topLine.appendChild(
createElement('span', 'category-tile-type', meta.type)
);
topLine.appendChild(
createElement('span', 'category-tile-count', count + ' 篇文章')
);

var icon = createElement('span', 'category-tile-icon');
icon.innerHTML =
'<i class="fas ' + meta.icon + '" aria-hidden="true"></i>';

var title = createElement('strong', 'category-tile-title', name);
var description = createElement(
'span',
'category-tile-description',
meta.description
);
var action = createElement(
'span',
'category-tile-action',
'进入分类'
);
action.innerHTML +=
'<i class="fas fa-arrow-right" aria-hidden="true"></i>';

content.appendChild(topLine);
content.appendChild(icon);
content.appendChild(title);
content.appendChild(description);
content.appendChild(action);
link.appendChild(content);

bindTilt(link);

item.dataset.categoryTileReady = 'true';
}

function enhanceCategoryPage() {
var lists = document.querySelectorAll(
'#page > .category-lists > .category-list'
);

lists.forEach(function (list) {
list.parentElement.classList.add('category-tiles-ready');
list.querySelectorAll(':scope > .category-list-item').forEach(enhanceTile);
});

document
.querySelectorAll('.category-tile-demo-card')
.forEach(bindTilt);
}

document.addEventListener('pjax:complete', enhanceCategoryPage);

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', enhanceCategoryPage);
} else {
enhanceCategoryPage();
}
})();

七、在 Hexo Butterfly 中部署

将文件放到:

1
2
3
4
5
source/
├── css/
│ └── category-tiles.css
└── js/
└── category-tiles.js

然后打开 _config.butterfly.yml,在 inject 中加入:

1
2
3
4
5
inject:
head:
- <link rel="stylesheet" href="/css/category-tiles.css?v=1">
bottom:
- <script defer src="/js/category-tiles.js?v=1"></script>

执行构建:

1
2
npm run clean
npm run build

本地预览:

1
npm run server

访问 /categories/ 即可查看结果。确认无误后再运行 npm run deploy。


八、自定义分类信息

在 category-tiles.js 中修改 CATEGORY_META:

1
2
3
4
5
6
7
8
var CATEGORY_META = {
'摄影': {
icon: 'fa-camera',
type: 'PHOTO / LIGHT',
description: '记录光线、构图与旅途中的片刻。',
hue: 328
}
};
  • icon 使用 Font Awesome 类名
  • type 是卡片顶部的小标签
  • description 是分类简介
  • hue 是 0–360 的 HSL 色相

如果不配置某个分类,组件会自动使用 fa-folder-open 和根据名称计算的色相。


九、用于普通 HTML 或其他框架

它并不依赖 Hexo,但脚本默认寻找 Butterfly 的分类类名。普通网页可以保留相同结构,再引入 CSS 和 JavaScript;Vue、React 或 MkDocs 则可以:

  1. 直接输出同样的 category-list DOM;
  2. 把 enhanceTile 和 bindTilt 改写为组件生命周期;
  3. 使用路由完成后对应的回调代替 pjax:complete。

真正依赖 Butterfly 的只有 DOM 选择器:

1
#page > .category-lists > .category-list

换成自己网站的分类容器即可复用其余逻辑。


十、性能与可访问性

组件没有请求第三方接口,也不加载额外图片。高光、边框和暗色效果都由 CSS 渐变生成。

每张卡片只在 pointermove 时更新少量 CSS 变量;手机端与减少动态模式下不会绑定倾斜交互。链接保留 aria-label,键盘用户仍可以通过 Tab 聚焦并进入分类。

如果分类非常多,可以减少阴影层数,或仅在 hover 时启用 will-change,降低低端设备的合成压力。


十一、常见问题

1. 页面仍然是文字列表

检查 CSS、JS 是否生成到 public 目录,确认 inject 缩进正确,并强制刷新浏览器缓存。

2. PJAX 返回分类页后没有磁贴

保留脚本中的 pjax:complete 监听,并确保脚本只注入一次。

3. 新分类没有专属图标

这是正常降级行为。把分类名称加入 CATEGORY_META 即可。名称必须与 Hexo 输出完全一致。

4. 手机端没有倾斜

这是有意设计。触屏没有稳定 hover,倾斜还可能影响滚动,所以 760px 以下保持静态单列卡片。

5. 暗色主题没有切换

当前样式使用 data-theme=“dark” 判断暗色模式。如果其他主题使用 class=“dark” 等标记,需要修改相应选择器。


十二、参考与实现说明

视觉方向参考了 TonyCrane/note-homepage-cards 的收藏卡设计。该参考项目基于 Svelte,并采用 GPL-3.0 许可证。

本文分享的代码是针对 Hexo Butterfly 分类 DOM 独立编写的实现,没有复制参考项目的源码、素材或 Pokémon 卡面。若你准备直接使用或修改参考仓库本身,请遵守其 GPL-3.0 许可证;若使用本文代码,也建议在二次分享时保留设计来源和实现说明。


结语

分类页不只是文章目录,也可以成为读者理解网站内容结构的入口。磁贴的价值并不只在于“更花哨”,而是让分类名称、内容方向、文章数量和视觉识别同时出现。

实现时最重要的原则仍然是渐进增强:数据由 Hexo 管理,链接由主题生成,CSS 和 JavaScript 只负责改善呈现。这样即使以后升级 Butterfly、关闭动画或迁移框架,也能保留清晰的退化路径。


AI 内容声明

本文包含 AI 辅助生成内容。文章结构、部分文字表述、交互预览以及分类磁贴代码由 AI 协助完成,并经过作者审阅、修改和本地构建验证。