使用 Vue.js 和 GSAP 的简约导航菜单 - WotW
欢迎来到“每周小部件”系列的第二季,在这里我拍摄了精彩的 UI/UX 组件的 gif 或视频,并用代码将它们变为现实。
距离上次发帖已经过去好久了。之前忙着一些项目(包括生女儿),不过我带着更多知识和小工具回来了。
今天我们要创建一个极简的导航菜单,鼠标悬停在某个选项上时会呈现动画效果。
灵感来自Zhenya Rynzhuk的这个作品,它看起来就像这样:

这是给谁的?
本教程面向希望提升技能的前端开发者。建议您具备一定的 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>
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;
}
让我解释一下最重要的规则……
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
}
})
}
}
})
我将元素添加为常量的计算属性,而不是组件内的数据,因为我需要将它们绑定到模板,但它们不会随着时间的推移而改变。
现在我们已经将这些项目作为列表,并且可以将它们用作计算属性,我们可以简化模板以使用以下内容呈现所有菜单项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 -->
现在我们应该有相同的元素,只是绑定到我们的 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>
现在,对于selectItem()
方法,我们需要跟踪悬停的项目,因此我们将在组件数据中添加一个selectedItem
变量。此属性将从未选择任何项目时开始-1
,并在悬停时将其值更改为所选按钮的索引。
// JS
new Vue({
el: '#app',
data: {
selectedItem: -1
},
methods: {
selectItem(id) {
this.selectedItem = id;
}
},
// ... the rest of our component
要查看selectedItem
更改,您可以在模板中添加下一行:
<!-- HTML after </nav> -->
<p>
selectedItem: {{ selectedItem }}
</p>
知道当前选中的是哪个按钮,我们就可以添加一个类来“移动”按钮。为此,我们可以像这样selected
在计算中添加一个属性:menuItems
// JS inside computed
menuItems () {
return itemsList.map((item, index) => {
const isSelected = this.selectedItem === index;
return {
label: item,
selected: isSelected
}
})
}
并在 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>
注意,有两个类属性,它们是串联的,而不是相互覆盖。当“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;
}
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>
添加箭头并暗化其他项目
在进入角色动画之前,还有一些细节需要注意,选定的项目应该出现一个箭头,而未选定的项目应该变暗或半透明。
让我们快速向菜单项添加一个箭头字符。
<!-- 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>
箭头看起来与参考略有不同,因为它是一个常规的➔字符,而不是与设计完全匹配的矢量,但对于我们的目的来说已经足够了。
我们希望隐藏所有箭头,除非它们是选定项目的子项,我们可以使用类似于之前对选定项目所执行的 CSS 规则来执行此操作:
/* CSS */
.menu-item > .arrow {
opacity: 0;
transition: opacity 0.7s ease-out;
}
.selected > .arrow {
opacity: 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
}
})
}
... 将.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>
...最后但同样重要的是,创建.dimmed
CSS 规则:
/* CSS */
.dimmed {
opacity: 0.3;
}
我们正在接近最终产品。
角色翻转动画
翻转每个菜单项字符可能是这个小部件中最有趣、最复杂的部分。我们不能直接翻转整个菜单,而是应该单独水平翻转每个字符(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('')
}
})
}
...通过该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>
注意“ref”属性,它将帮助我们使用菜单项的“索引”和“charIndex”来“引用”所有这些字符。
此时,视觉上应该没有任何变化,但我们应该将菜单分成字符。
我们将添加一些常量,以帮助我们更好地阅读代码的下一部分
// JS below const itemList declaration
const LEFT = -1
const RIGHT = 1
之后,每当角色selectedItem
发生变化时,我们都希望所有角色都能正确动画。我们将循环遍历每个角色menuItem
,并根据它们是否被选中进行翻转LEFT
或RIGHT
:
// 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);
})
},
该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});
}
}
在这种方法中,我们基本上获得了每个角色的参考,杀死当前拥有的动画(如果有的话),然后将角色翻转到我们之前计算的方向。
看起来一切都应该和参考的一样,但事实并非如此😰。
如果我们查看控制台,我们会看到角色正在被正确转换,TweenMax 正在改变它们的变换矩阵,正如我预期的那样:
我绞尽脑汁想了好一会儿,终于找到了问题所在,那就是<span>
我们动画的节点display
默认的属性是inline
。而 Transform 似乎不支持这种类型的显示属性,所以让我们利用.char
添加到这些元素的 class 来解决这个问题:
.char {
display: inline-block;
min-width: 0.3em;
}
添加了“min-width”属性以使文本看起来正确,当添加“inline-block”显示属性时,空格等字符会失去其宽度。
现在来看看最终结果!
我知道还有改进的空间,有时候动画会因为各种元素的渲染而运行不流畅。如果你有什么改进建议,欢迎在评论区留言。
这就是本周小部件的全部内容。
如果您还想了解更多,可以查看其他 WotW:
最初发表于ederdiaz.dev
鏂囩珷鏉ユ簮锛�https://dev.to/ederchrono/minimalistic-nav-menu-with-vue-js-and-gsap-wotw-1m3k