网页上的立体字怎么动起来?,手把手教你用鼠标玩转3D文字特效

4,813字
20–31 分钟
in

你有没有在网页上看到那种随着鼠标移动而鼓起、凹陷的立体文字?那种感觉就像是用手在捏一团软软的橡皮泥,光标划到哪儿,哪儿就跟着变形。以前觉得这种效果肯定得用上什么复杂的库或者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变量里。这里用pageXpageY而不是clientXclientY,是为了照顾滚动条,确保滚动后鼠标位置依然准确。

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)的查询,避免在触摸屏上出现奇怪的交互。触摸屏用户,就让他看个静态的立体字也挺好。