你有没有在网页上看到那种随着鼠标移动而鼓起、凹陷的立体文字?那种感觉就像是用手在捏一团软软的橡皮泥,光标划到哪儿,哪儿就跟着变形。以前觉得这种效果肯定得用上什么复杂的库或者Canvas才能搞定,但看完这篇文章就会发现,其实用最基础的HTML、CSS,再加上一小段JavaScript,就能让这些文字活起来。
摘要
这篇文章会带着一步步实现一个跟随鼠标变化的3D立体文字特效。先从用JavaScript动态生成文字层开始,替代之前手工复制粘贴的笨办法。接着会加入悬停效果,让文字只在鼠标放上去时才弹出立体感。最后,利用鼠标位置数据,配合CSS里的三角函数和渐变背景,制作出文字表面跟着光标“鼓起”的视觉效果。整个过程注重实际操作,会解释每一步背后的逻辑和需要注意的细节,比如如何避免性能问题,怎样让不同屏幕尺寸下的效果保持一致,以及怎么处理鼠标位置与页面滚动的兼容性问题。
先把代码收拾利索
之前做立体字,HTML里得堆一大串重复的div,看着都头大。这次换个思路,用JavaScript来干这脏活累活。先把HTML精简到最干净的状态,只需要一个带class的容器,里面写上想要展示的文字就行。
<div class="layeredText">码农翻身</div>动态生成层
写一个叫generateLayers的函数,它的任务就是接收上面那个div,然后在里面自动搭建立体字需要的所有结构。这里有个小细节得注意,万一这个函数被调用了多次,同一个元素被反复处理,页面就会出现重复的层。为了避免这种情况,在动手之前先检查一下,如果这个div里面已经存在.layers这个类了,那就直接收工,啥也不干。
function generateLayers(element) {
// 防止重复生成,如果已经有层了就直接闪人
if (element.querySelector('.layers')) return;
// 后面就是真正的生成逻辑
}接下来要考虑层数的问题。CSS里虽然有个--layers-count变量,但这次想让每个文字都能有自己的层数。在JavaScript里先定个默认值,比如24层。同时允许在HTML标签上加个data-layers属性来覆盖这个默认值,比如想要更厚实的效果,就写成data-layers="32"。拿到这个数字后,用setProperty把它塞回CSS变量里,这样样式那边就能用上了。
const DEFAULT_LAYERS_COUNT = 24;
function generateLayers(element) {
if (element.querySelector('.layers')) return;
const layersCount = element.dataset.layers || DEFAULT_LAYERS_COUNT;
element.style.setProperty('--layers-count', layersCount);
}最后一步,也是最关键的一步,就是把原本的文字内容和所有层都塞回这个div里。先用textContent把原始文字存下来,然后重新设置innerHTML。结构跟以前一样,一个span放原文,后面跟一个.layers的容器。在这个容器里,根据刚才算出来的层数循环生成一个个.layer,每个层都带一个--i的自定义属性,方便后面CSS按索引来算位置。
function generateLayers(element) {
// ...之前的代码
const content = element.textContent;
element.innerHTML = `
<span>${content}</span>
<div class="layers" aria-hidden="true">
${Array.from({ length: layersCount}, (_, i) =>
`<div class="layer" style="--i: ${i + 1};">${content}</div>`
).join('')}
</div>
`;
}最后别忘了,在页面上找到所有带.layeredText的玩意儿,挨个调用这个函数,让它们都完成蜕变。
const layeredElements = document.querySelectorAll('.layeredText');
layeredElements.forEach(generateLayers);统一身高
现在每个立体字的层数可以不一样了,这带来一个新问题:层数多的字会比层数少的字更高。如果想让它们不管多少层,总高度都一样,那就得换个计算方式。之前是用--layer-offset(每层偏移量)乘上索引,现在改成定义一个总高度--text-height,然后用当前层的归一化值(var(--i) / var(--layers-count))去乘这个总高度。这样算出来的translateZ就能保证层数多的每层薄一点,层数少的每层厚一点,最终大家总高度保持一致。
.layeredText {
--text-height: 36px;
.layer {
--n: calc(var(--i) / var(--layers-count));
transform: translateZ(calc(var(--n) * var(--text-height)));
color: hsl(200 30% calc(var(--n) * 100%));
}
}让鼠标和文字互撩
有了动态生成的立体字,接下来就可以加点互动了。先得解决一个小麻烦:那些生成出来的层虽然好看,但它们会挡住鼠标点击下面的东西。想象一下,如果这个立体字是个链接,鼠标光标飘过去想点它,结果却被层层叠叠的div给挡住了,那就尴尬了。解决的办法很简单,给.layers加上pointer-events: none;,让这些层对鼠标事件完全透明,就像不存在一样。
.layers {
pointer-events: none;
}悬停时才弹出
现在想让某个链接或者按钮,平时是平平无奇的,鼠标一放上去就“砰”地弹出立体感。先写好HTML,比如一段文字里嵌入两个带.layeredText的链接。默认状态下,让里面的文字和所有层都是浅蓝色,透明度也调一下,显得比较低调。关键的一步是把所有跟3D相关的样式,比如颜色、阴影、translateZ的偏移,都放到:hover块里面去。
.layeredText {
display: inline-block;
span, .layer {
color: hsl(200 100% 75%);
transition: all 0.5s;
}
.layer {
opacity: 0;
}
&:hover {
span {
color: black;
text-shadow: 0 0 0.1em #003;
}
.layer {
color: hsl(200 30% calc(var(--n) * 100%));
transform: translateZ(calc(var(--i) * var(--layer-offset) + 0.5em));
opacity: 1;
}
}
}如果页面上有些元素想一直保持立体效果,比如大标题,而链接只想悬停时才有效果,可以用:is()选择器来搞定。
.layeredText {
&:is(h1, :hover) {
/* 公共的立体样式 */
}
}追踪光标位置
要想让文字跟着鼠标动,就得知道鼠标在哪儿,文字又在哪儿。先在全局监听mousemove事件,把鼠标相对于整个页面的横坐标和纵坐标存到body的CSS变量里。这里用pageX和pageY而不是clientX和clientY,是为了照顾滚动条,确保滚动后鼠标位置依然准确。
window.addEventListener('mousemove', e => {
document.body.style.setProperty('--mx', e.pageX);
document.body.style.setProperty('--my', e.pageY);
});接着,需要知道每个文字元素在页面上的具体位置。写个setRects函数,用getBoundingClientRect拿到元素相对于视口的位置,再加上滚动距离,就能得到相对于整个页面的坐标,同样存到每个元素自己的CSS变量里。
function setRects() {
layeredElements.forEach(element => {
const rect = element.getBoundingClientRect();
element.style.setProperty('--top', rect.top + window.scrollY);
element.style.setProperty('--left', rect.left + window.scrollX);
});
}这个函数在页面加载完跑一次,并且还要监听resize事件,因为窗口大小变了,元素位置也可能变。注意,读getBoundingClientRect这类的布局信息是重活儿,不能频繁调用,否则页面会卡。
setRects();
window.addEventListener('resize', setRects);凸起效果初现
现在手里有了鼠标位置和文字位置,就可以玩花活了。想象一下,在文字的每个层上,都用径向渐变画一个圆,圆的中心点正好是鼠标相对于当前文字的位置。用背景渐变来填充文字颜色,这样那个圆就会像一束光一样打在文字上。
.layer {
background-clip: text;
color: transparent;
background-image:
radial-gradient(
circle at calc((var(--mx) - var(--left)) * 1px)
calc((var(--my) - var(--top)) * 1px),
red 24px,
white 0
);
}这样,鼠标移动到文字上,一个红点就会跟着跑,感觉就像用激光笔在字上照来照去。但这还不够,想要的是“鼓起”的感觉,而不只是一个点。那就得把这个渐变的思路,应用到所有层上去,而且每层的颜色、圆的半径都得根据层数(归一化值--n)来变化,让这个圆从最底层到最表层,有一种向外凸起的视觉延伸。
打造真正的凸起感
为了让凸起的效果更柔和、更自然,得用上CSS新出的三角函数。计算每个层的归一化值--n,然后乘上90度,再取余弦值。这个余弦值的曲线不是直线的,而是从1平滑地降到0,正好可以用来模拟那种从中心向外慢慢衰减的凸起弧度。
.layer {
--cos: calc(cos(var(--n) * 90deg));
--color: hsl(200 30% calc(var(--n) * 100%));
color: transparent;
background-clip: text;
background-image:
radial-gradient(
circle at calc((var(--mx) - var(--left)) * 1px)
calc((var(--my) - var(--top)) * 1px),
var(--color) calc(var(--cos) * 36px + 24px),
transparent calc(var(--cos) * 72px)
);
}这里,圆心的半径也跟--cos挂钩,--cos大的层(靠外的层)圆就大,--cos小的层(靠里的层)圆就小。同时,为了让过渡更丝滑,把渐变从硬边(从颜色直接变透明)改成了软过渡,通过设置两个不同的半径值来控制渐变的范围。这样一来,鼠标滑过时,文字表面的光晕就会随着层的深度变化,产生一种从中心往外“鼓起”的视觉效果。
最后,为了让效果在只有鼠标(支持悬停)的设备上才生效,可以加个@media (hover: hover)的查询,避免在触摸屏上出现奇怪的交互。触摸屏用户,就让他看个静态的立体字也挺好。
