Vue3实现鼠标hover:类似Nitro首页卡片发光效果

989 阅读3分钟

image.png

偶然间点开nitro文档时,发现其页面鼠标移动至卡片上效果很有趣,故尝试实现其效果。

其主要使用CSSbackground: radial-gradient(xxx)和JS监听指定元素的鼠标移动mousemove事件来实现的

CSS radial-gradient

radial-gradient其作用是创建一个图像,该图像由从原点辐射的两种或多种颜色之间的渐进过渡组成,其形状可以是圆形或椭圆形。我们所需是创建一个渐变圆形,其效果如同一个手电筒效果。

image.png

   .card::before {
      background: radial-gradient(
        500px circle at var(--x) var(--y),
        var(--fl-color),
        transparent 40%
      );
      will-change: background;
    }

如上图所示效果,也可进行扩展实现其他有趣的效果

JS监听获取在Cards容器内的位置

  • 页面元素结构

image.png

  • JS监听Cards容器鼠标移动事件

    JS监听Cards容器内容鼠标的移动,并设置每个子Card的x,y轴方向上的位置,用于设置radial-gradient属性。

    // cardListRef 为外部Cards容器,cardListRef为子card元素集合
    function handleCardElMouseProperty(e){
     if(!cardListRef.value.length) return;
     for (const card of cardListRef.value) {
       const rect = card.getBoundingClientRect(),
         x = e.clientX - rect.left,
         y = e.clientY - rect.top;
       card!.style.setProperty("--x", `${x}px`);
       card!.style.setProperty("--y", `${y}px`);
       // 设置主题色
       card!.style.setProperty("--fl-color", props.color);
     }
   }

以上两步为该效果主要代码,其效果能实现圆形区域跟随鼠标移动,但是还缺少两点:

1、无边框效果 2、文字内容会被遮盖

完善细节

目前只需要解决以上两点即可达成最终效果:

1、无边框效果高亮 上述的圆形手电筒效果也是利用伪元素::before实现的,将每个Card的伪元素进行调整

.card::before{
  content: "";
  height: calc(100% + 4px);
  position: absolute;
  width: calc(100% + 4px);
  display: block;
  inset: -2px;
  z-index:-1;
  background: radial-gradient(
      500px circle at var(--x) var(--y),
      var(--fl-color),
  transparent 40%
  );
  will-change: background;
}

image.png

经由如上调整,则能够展示完整边框效果,但是由于设置的z-index:-1会导致每个card里面的亮部区域丢失

2、文字内容会被遮盖

如上一步骤调整后,card里面的亮部区域丢失了文字内容就不会被覆盖,但是还是不完善,如果将card的内容区域设置为一个单独的div元素,将其背景颜色与外部设置一致,并且设置起hover上增加背景色的透明度即可达成nitro一致的交互效果。

// card-content 为子card的内容元素
    .card-content{
      background-color: var(--card-bg-color);
      ...
    }
    .card-content:hover{
      opacity: 0.9;
    }

组件

<!-- CursorShineCards.vue -->
<template>
  <div id="cards-container" ref="cardsRef">
    <div class="card" v-for="(item, index) in data" :key="index" :ref="e => setCardListRef(e)">
      <div class="card-content">
        <slot :item="item" :index="index">
          <div class="card-info">
            <h3 v-if="item.title">{{item.title}}</h3>
              <h3 v-if="item.content">{{item.content}}</h3>
          </div>
        </slot>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, onUnmounted, PropType, ref } from "vue";
defineOptions({
  name: "CursorShineCards"
});
const props = defineProps({
  data: {
    type:Array as PropType<Record<string, any>[]>,
    default: [] 
  },
  color: {
    type: String,
    default: 'rgba(0,193,106, 1)'
  }
})
const cardsRef = ref()
const cardListRef = ref<Array<HTMLElement>>([])
const setCardListRef = (e: HTMLElement) => {
  if(e){
    cardListRef.value.push(e)
  }
}
function initCardMouseEvt(){
  if(cardsRef.value){
    cardsRef.value.addEventListener('mousemove', handleCardElMouseProperty);
  }
}
onMounted(() => {
  initCardMouseEvt()
})
onUnmounted(() => {
  if(cardsRef.value){
    cardsRef.value.removeEventListener('mousemove', handleCardElMouseProperty);
  }
})
function handleCardElMouseProperty(e){
  if(!cardListRef.value.length) return;
  for (const card of cardListRef.value) {
    const rect = card.getBoundingClientRect(),
      x = e.clientX - rect.left,
      y = e.clientY - rect.top;
    card!.style.setProperty("--x", `${x}px`);
    card!.style.setProperty("--y", `${y}px`);
    card!.style.setProperty("--fl-color", props.color);
  }
}
</script>
<style>
:root{
  --fl-color: rgba(239,68, 68, 1);
  --card-bg-color: rgb(20, 20, 20);
}
</style>
<style lang="scss" scoped>

#cards-container {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  max-width: 916px;
  width: calc(100% - 20px);
  background-color: var(--card-bg-color);
}

#cards-container:hover > .card::after {
  opacity: 1;
}

.card {
  border-radius: 10px;
  cursor: pointer;
  display: flex;
  min-height: 160px;
  height: auto;
  flex-direction: column;
  position: relative;
  width: 300px;
  box-sizing: border-box;
  isolation: isolate;
  box-shadow: 0 0 0 0px rgb(38,38,38),0 0 0 1px rgb(38,38,38), 0 0 #0000;
}

.card:hover::before {
  opacity: 1;
}

.card::before{
  border-radius: inherit;
  content: "";
  height: calc(100% + 4px);
  position: absolute;
  width: calc(100% + 4px);
  display: block;
  inset: -2px;
  z-index:-1;
  background: radial-gradient(
    500px circle at var(--x) var(--y),
    var(--fl-color),
    transparent 40%
  );
  will-change: background;
  
}
.card-content{
  flex: 1 1 0%;
  overflow: hidden;
  transition-duration: .15s;
  background-color: var(--card-bg-color);
  opacity: 1;
  transition-property: background-opacity;
  transition-timing-function: cubic-bezier(.4,0,.2,1);
  border-radius: 10px;
  align-items: center;
  display: flex;
  flex-grow: 1;
  justify-content: flex-start;
  padding: 0px 20px;
}
.card-content:hover{
  opacity: 0.9;
}
.card-content.view-border{
  position: relative;
  z-index: -1;
}
i {
  color: rgb(240, 240, 240);
}
</style>