使用 Vue.js 和 GSAP 的简约导航菜单 - WotW

2025-06-08

使用 Vue.js 和 GSAP 的简约导航菜单 - WotW

欢迎来到“每周小部件”系列的第二季,在这里我拍摄了精彩的 UI/UX 组件的 gif 或视频,并用代码将它们变为现实。

距离上次发帖已经过去好久了。之前忙着一些项目(包括生女儿),不过我带着更多知识和小工具回来了。

今天我们要创建一个极简的导航菜单,鼠标悬停在某个选项上时会呈现动画效果。
灵感来自Zhenya Rynzhuk的这个作品,它看起来就像这样:

wotw通行证

这是给谁的?

本教程面向希望提升技能的前端开发者。建议您具备一定的 HTML、CSS 和 JS 基础。
我将使用Vue.js来制作小部件,如果您不熟悉这个框架,以下精彩文章可以帮助您快速上手:

准备工作

对于今天的小部件,我们将使用Vue.js,对于某些动画,我们将使用TweenMax。如果您想继续学习,可以 fork 这个已经包含依赖项的Codepen 模板。

搭配外观

我想要用这个组件做的第一件事就是匹配设计。这可能是最简单的部分,因为不需要复杂的图形SVG或图标。

在我们的应用节点中,我将首先创建菜单的标记。由于它将是一个导航菜单,因此我们应该使用语义正确的 HTML 节点:

<!-- HTML -->
<div id="app">
  <nav>
    <ul>
      <li class="menu-item">About</li>
      <li class="menu-item">Works</li>
      <li class="menu-item">News/Blog</li>
      <li class="menu-item">Contact us</li>
    </ul>
  </nav>
</div>
Enter fullscreen mode Exit fullscreen mode

class="menu-item"下一步,我将添加容器和菜单项的样式。id="app"稍后还会用它来添加功能和动画Vue.js

菜单现在看起来真的很丑,让我们改变它:

/* CSS */
body {
  background-color: #f1f0e9;
}

.menu-item {
  font-size: 5em;
  list-style: none;
  text-transform: uppercase;
  font-family: sans-serif;
  text-align: center;
  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode

现在我们应该有这样的内容:
风格

让我解释一下最重要的规则……

  • list-style: none;正在帮助从列表中删除项目符号。
  • text-transform: uppercase;当我们不想对使用屏幕阅读器的用户明确地“大喊大叫”而只想出于设计目的显示大写字符时很有用。
  • cursor: pointer;使鼠标的行为就像每个元素都是一个链接。

设置 Vue.js

在了解更多细节之前,让我们将 Vue.js 添加到组件中,以便能够动态呈现菜单项。

// JS
const itemsList = ['About', 'Works', 'News/Blog', 'Contact us']

new Vue({
  el: '#app',
  computed: {
    menuItems() {
      return itemsList.map((item, index) => {
        return {
          label: item
        }
      })
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

我将元素添加为常量的计算属性,而不是组件内的数据,因为我需要将它们绑定到模板,但它们不会随着时间的推移而改变。

现在我们已经将这些项目作为列表,并且可以将它们用作计算属性,我们可以简化模板以使用以下内容呈现所有菜单项v-for

<!-- HTML - inside div > nav > ul -->
<li v-for="(item, index) in menuItems" :key="`item-${index}`" class="menu-item">
  {{item.label}}
</li>
<!-- remove all other "li" elements -->
Enter fullscreen mode Exit fullscreen mode

现在我们应该有相同的元素,只是绑定到我们的 Vue.js 实例。

悬停和动画

参考中的菜单动画可以分为两部分,第一部分是将菜单项向左移动,第二部分是字符翻转。

让我们从第一个开始,将鼠标光标下方的菜单向左移动。为此,我们将向@mouseover菜单项添加一个事件,该事件将触发一个selectedItem()我们尚未声明的函数:

<!-- HTML inside nav > ul -->
<li
  v-for="(item, index) in menuItems"
  :key="`item-${index}`"
  @mouseover="selectItem(index)"
  class="menu-item"
>
  {{item.label}}
</li>
Enter fullscreen mode Exit fullscreen mode

现在,对于selectItem()方法,我们需要跟踪悬停的项目,因此我们将在组件数据中添加一个selectedItem变量。此属性将从未选择任何项目时开始-1,并在悬停时将其值更改为所选按钮的索引。

// JS
new Vue({
  el: '#app',
  data: {
    selectedItem: -1
  },
  methods: {
    selectItem(id) {
      this.selectedItem = id;
    }
  },
  // ... the rest of our component
Enter fullscreen mode Exit fullscreen mode

要查看selectedItem更改,您可以在模板中添加下一行:

<!-- HTML after </nav> -->
<p>
  selectedItem: {{ selectedItem }}
</p>
Enter fullscreen mode Exit fullscreen mode

知道当前选中的是哪个按钮,我们就可以添加一个类来“移动”按钮。为此,我们可以像这样selected在计算中添加一个属性:menuItems

// JS inside computed
menuItems () {
  return itemsList.map((item, index) => {
    const isSelected = this.selectedItem === index;
    return {
      label: item,
      selected: isSelected
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

并在 HTML 中使用该新属性:

<!-- HTML inside nav > ul -->
<li
  v-for="(item, index) in menuItems"
  :key="`item-${index}`"
  @mouseover="selectItem(index)"
  :class="{'selected': item.selected}"
  class="menu-item"
>
  {{item.label}}
</li>
Enter fullscreen mode Exit fullscreen mode

注意,有两个类属性,它们是串联的,而不是相互覆盖。当“item.selected”为“true”时,该菜单项将同时具有“menu-item”和“selected”类。

让我们添加 CSS 类来处理移动:

/* CSS */
.menu-item {
  /* ... previous styles */
  transition: margin-left 0.5s ease-out, opacity 0.5s ease-out;
}

.selected {
  margin-left: -90px;
}
Enter fullscreen mode Exit fullscreen mode

transition 属性表示该值的任何变化都应该以动画形式呈现。

这部分我们快完成了,不过还有点问题。鼠标移到所有元素之外后,最后一个元素仍然保持选中状态,这可不是我们想要的。为了解决这个问题,我们可以使用以下@mouseleave事件:

<!-- HTML inside nav > ul -->
<li
  v-for="(item, index) in menuItems"
  :key="`item-${index}`"
  @mouseover="selectItem(index)"
  @mouseleave="selectItem(-1)"
  :class="{'selected': item.selected}"
  class="menu-item"
>
  {{item.label}}
</li>
Enter fullscreen mode Exit fullscreen mode

添加箭头并暗化其他项目

在进入角色动画之前,还有一些细节需要注意,选定的项目应该出现一个箭头,而未选定的项目应该变暗或半透明。

让我们快速向菜单项添加一个箭头字符。

<!-- HTML inside nav > ul -->
<li
  v-for="(item, index) in menuItems"
  :key="`item-${index}`"
  @mouseover="selectItem(index)"
  @mouseleave="selectItem(-1)"
  :class="{'selected': item.selected}"
  class="menu-item"
>
  {{item.label}}
  <span class="arrow"></span>
</li>
Enter fullscreen mode Exit fullscreen mode

箭头看起来与参考略有不同,因为它是一个常规的➔字符,而不是与设计完全匹配的矢量,但对于我们的目的来说已经足够了。

我们希望隐藏所有箭头,除非它们是选定项目的子项,我们可以使用类似于之前对选定项目所执行的 CSS 规则来执行此操作:

/* CSS */
.menu-item > .arrow {
  opacity: 0;
  transition: opacity 0.7s ease-out;
}

.selected > .arrow {
  opacity: 1;
}
Enter fullscreen mode Exit fullscreen mode

现在箭头已经出现和消失,让我们将未选中的项目变暗。我们可以像计算选中项目一样计算变暗的项目:

// JS inside computed
menuItems () {
  return itemsList.map((item, index) => {
    const isSelected = this.selectedItem === index;
    const otherButtonIsSelected = this.selectedItem !== -1
    return {
      label: item,
      selected: isSelected,
      dimmed: !isSelected && otherButtonIsSelected
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

... 将.dimmed类添加到我们的 HTML 中的项目:

<!-- HTML inside nav > ul -->
<li
  v-for="(item, index) in menuItems"
  :key="`item-${index}`"
  @mouseover="selectItem(index)"
  @mouseleave="selectItem(-1)"
  :class="{
      'selected': item.selected,
      'dimmed': item.dimmed
    }"
  class="menu-item"
>
  {{item.label}}
  <span class="arrow"></span>
</li>
Enter fullscreen mode Exit fullscreen mode

...最后但同样重要的是,创建.dimmedCSS 规则:

/* CSS */
.dimmed {
  opacity: 0.3;
}
Enter fullscreen mode Exit fullscreen mode

我们正在接近最终产品。

基本动画

角色翻转动画

翻转每个菜单项字符可能是这个小部件中最有趣、最复杂的部分。我们不能直接翻转整个菜单,而是应该单独水平翻转每个字符(scaleX: -1)。

为了能够“控制”每个字符,我们需要拆分菜单字符:

// JS inside computed
menuItems () {
  return itemsList.map((item, index) => {
    const isSelected = this.selectedItem === index;
    const otherButtonIsSelected = this.selectedItem !== -1
    return {
      label: item,
      selected: isSelected,
      dimmed: !isSelected && otherButtonIsSelected,
      chars: item.split('')
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

...通过该chars属性,我们现在可以在<span>节点内渲染每个字符:

<!-- HTML inside nav > ul -->
<li
  v-for="(item, index) in menuItems"
  :key="`item-${index}`"
  @mouseover="selectItem(index)"
  @mouseleave="selectItem(-1)"
  :class="{
      'selected': item.selected,
      'dimmed': item.dimmed
    }"
  class="menu-item"
>
  <span
    class="char"
    v-for="(char, charIndex) in item.chars"
    :key="`char-${charIndex}`"
    :ref="`char-${index}-${charIndex}`"
    >{{char}}</span
  >
  <span class="arrow"></span>
</li>
Enter fullscreen mode Exit fullscreen mode

注意“ref”属性,它将帮助我们使用菜单项的“索引”和“charIndex”来“引用”所有这些字符。

此时,视觉上应该没有任何变化,但我们应该将菜单分成字符。

我们将添加一些常量,以帮助我们更好地阅读代码的下一部分

// JS below const itemList declaration
const LEFT = -1
const RIGHT = 1
Enter fullscreen mode Exit fullscreen mode

之后,每当角色selectedItem发生变化时,我们都希望所有角色都能正确动画。我们将循环遍历每个角色menuItem,并根据它们是否被选中进行翻转LEFTRIGHT

// JS inside methods
selectItem(id) {
  this.selectedItem = id;

  this.menuItems.forEach((item, index) => {
    const direction = item.selected ? LEFT : RIGHT;
    this.animateChars(index, item.label.length, direction);
  })
},
Enter fullscreen mode Exit fullscreen mode

animateChars()方法尚未声明,但现在创建起来应该不难,因为我们有项目的索引、项目所具有的字符数以及翻转字母的方向:

// JS inside methods
animateChars (id, charLength, direction) {
  for(let c=0;c < charLength; c++){
    const refId = `char-${id}-${c}`;
    const char = this.$refs[refId];
    TweenMax.killTweensOf(char);
    TweenMax.to(char, 0.5, {scaleX: direction});
  }
}
Enter fullscreen mode Exit fullscreen mode

在这种方法中,我们基本上获得了每个角色的参考,杀死当前拥有的动画(如果有的话),然后将角色翻转到我们之前计算的方向。

看起来一切都应该和参考的一样,但事实并非如此😰。

如果我们查看控制台,我们会看到角色正在被正确转换,TweenMax 正在改变它们的变换矩阵,正如我预期的那样:

哦不

我绞尽脑汁想了好一会儿,终于找到了问题所在,那就是<span>我们动画的节点display默认的属性是inline。而 Transform 似乎不支持这种类型的显示属性,所以让我们利用.char添加到这些元素的 class 来解决这个问题:

.char {
  display: inline-block;
  min-width: 0.3em;
}
Enter fullscreen mode Exit fullscreen mode

添加了“min-width”属性以使文本看起来正确,当添加“inline-block”显示属性时,空格等字符会失去其宽度。

现在来看看最终结果!

我知道还有改进的空间,有时候动画会因为各种元素的渲染而运行不流畅。如果你有什么改进建议,欢迎在评论区留言。

这就是本周小部件的全部内容。

如果您还想了解更多,可以查看其他 WotW:

最初发表于ederdiaz.dev

鏂囩珷鏉ユ簮锛�https://dev.to/ederchrono/minimalistic-nav-menu-with-vue-js-and-gsap-wotw-1m3k
PREV
从 Web 应用程序上传文件到 IPFS
NEXT
制作动画滑块 - WotW