今天我想和大家分享一下这个网站中的一些动画细节,以及它们背后的实现思路。
作为一个个人博客,我没有选择市面上常见的博客系统,尽管那些方案能让我更快地搭建起网站。相反,我选择了完全按照自己的想法去实现功能和样式。最终,我选用了 Next.js 作为前端框架,配合 Framer Motion 来实现各种动画效果。
当你打开这个网站时,你会立即注意到几乎每个页面都有精心设计的动画效果。有些看似微不足道的小动画,其实凝聚了不少心思。就像打磨一件精美的产品一样,我仔细考虑了每个元素应该以什么样的方式呈现,细节之处很多。
如果你翻阅 的文档,你会发现有许多参数可以调整。我建议从一些基础的动画效果开始练习,逐步掌握更复杂的技巧。 Framer for Developers
接下来,让我们一起探讨网站中的一些动画细节吧。
内容的入场动画
每个页面都有一个统一的内容入场动画,内容从上往下逐渐显示。这种动画灵感来源于现实世界。闭上眼睛想象一下:当一阵风吹过地上的落叶,是不是有一种逐渐推开落叶的过程?又或者在一间宽敞的厂房里,当你打开总电源开关,灯光从一端到另一端依次亮起的场景。
理论上,我们还可以为入场动画添加模糊渐入的效果。但考虑到大量使用模糊效果可能导致页面加载时出现轻微卡顿,我最终没有采用这个方案。在内容从上往下出现的过程中,为了避免给用户拖沓的感觉,我将内容出现的间隔时间设置为固定的 0.5 秒。经过反复测试,我发现只要将这个时间延长到 1 秒,就会让人产生明显的拖沓感,体验非常不佳。
这个简单而优雅的入场动画是整个网站动效设计的基础,它不仅适用于页面加载,还可以应用到许多其他场景。
虽然完整的代码较为复杂,但核心逻辑可以简化为以下几行:
const animationConfig = useCallback(
() => ({
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 20 },
transition: { ease: "easeOut", duration: 0.3, delay },
}),
[delay],
);
const animationConfig = useCallback(
() => ({
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 20 },
transition: { ease: "easeOut", duration: 0.3, delay },
}),
[delay],
);
这段代码定义了元素的初始状态、动画状态和退出状态,以及过渡效果的参数。
头像动画
头像区域最初只是一张静态图片。随着网站其他部分应用了大量动画后,我突发奇想:为什么不让头像里的眼睛也动起来呢?于是,我在 Figma 中精心绘制了这个可爱的头像。
接下来,我将头像导出为 SVG 代码,这样就可以方便地实现 SVG 动画了。
导出的 SVG 代码相当冗长。为了优化,我借助 GPT 的力量,将每个数值保留到小数点后两位。这种精简并不会对外观产生明显影响,但代码量仍然不小。在将 SVG 图标替换到网页上后,我发现蓝色眼袋并不明显,于是干脆将其去掉了。
然后,我实现了让左右眼可以跟随鼠标轻微移动的效果,看起来就像这个小家伙在 监视 关注着你一样。当然,我的初衷只是想让这个卡比形象更加生动活泼。
但这还不够。为了更真实地模拟眼睛的效果,我还添加了眨眼的动画。人类平均每 5 到 10 秒眨一次眼,经过权衡,我将这个眨眼的时间间隔设置为了 8 秒。这个频率既不会太频繁让人感到奇怪,也不会太慢而显得不自然。
眨眼的动画使用了 CSS 的 clip-path
属性,代码如下:
@keyframes blink {
0%,
100% {
clip-path: inset(0% 0% 0% 0%);
}
50% {
clip-path: inset(50% 0% 50% 0%);
}
}
.blink {
animation: blink 0.2s ease-in-out;
}
@keyframes blink {
0%,
100% {
clip-path: inset(0% 0% 0% 0%);
}
50% {
clip-path: inset(50% 0% 50% 0%);
}
}
.blink {
animation: blink 0.2s ease-in-out;
}
播放音乐动画
说实话,这可能是我在整个网站中花费时间最多的一个动画区域。经过无数次的修改和调整,最终的效果其实融合了 5 种不同的动画。你可能会问:真的有必要这么复杂吗?确实,我完全可以简化掉一两种动画,但每一个都凝聚了我的心血,实在舍不得删除。
当你打开首页时,首先映入眼帘的是默认的唱片封面图片,旁边是一个 loading 状态的 skeleton。最初,这里只是简单地显示"暂无播放的歌曲"这样的文字,看起来平淡无奇。后来,我将其改进为 loading skeleton,这不仅能让用户感知到有内容正在加载,也确实反映了实际情况 —— 系统正在从 Supabase 数据库中获取最近播放的歌曲信息。
一旦获取到歌曲信息,状态就会发生变化。默认的唱片封面会向左移动并淡出消失,而获取到的歌曲信息则从右向左滑入并逐渐显现。在默认唱片封面消失的过程中,我添加了一个微妙的细节:它会先向右轻微移动一小段距离,然后才开始向左移动并消失。这里用到了 Framer Motion 的 ease: "anticipate"
属性,给人一种蓄力后突然发力的感觉。这种效果在游戏中很常见,比如英雄联盟中韦鲁斯的技能,会有一个向后蓄力的过程,然后突然爆发。相比之下,如果使用 ease: easeOut
属性,就会直接开始移动,少了这种微妙的预备动作。我认为,这种细节体现了对动画的品味,只有在仔细观察和反复尝试不同参数后,才能真正感受到哪种效果最合适。
当播放下一首歌曲时,同样的转场效果会再次触发。这里还有一个 1 秒延迟的设定,我就不详细展开了。整个动画过程中还涉及透明度变化、模糊效果、缩放等多个维度,每个细节都经过精心设计。
跳动的音符部分的灵感来自 Arc 浏览器播放音乐时的动画效果。当没有播放音乐时,音符显示为灰色;一旦开始播放,音符会立即变为绿色,页面上会显示"正在播放",唱片也会开始旋转,右侧还会出现富有节奏感的跳动音频。说实话,右侧的跳动音频可能有点画蛇添足,但如果去掉,又感觉右侧空间太空旷,所以最终还是保留了下来。
向上漂浮的音符是我最喜欢的创意之一。它不仅在大小和透明度上有变化,还加入了模糊效果和旋转角度的变化。实现这个效果的代码相当复杂:
type IconType = 'icon1' | 'icon2';
interface SpotifyPlayAnimationProps {
isPlaying: boolean;
}
const SpotifyPlayAnimation: React.FC<SpotifyPlayAnimationProps> = ({ isPlaying }) => {
const IconVariants = useCallback((i: number) => ({
initial: { opacity: 0, scale: 0, y: 0, x: 0, rotate: 0, rotateY: 0, filter: 'blur(0px)' },
animate: {
opacity: [0, 1, 0],
scale: [0, 0.8, 1.2, 0.4],
y: [-5, -50],
x: [0, i % 2 === 0 ? 12 + i * 5 : -(12 + i * 5)],
rotate: [0, i % 2 === 0 ? 5 + i * 2 : -(5 + i * 2)],
rotateY: [0, 20, 45],
filter: ['blur(0px)', 'blur(0px)', 'blur(2px)'],
transition: {
duration: 3,
repeat: Infinity,
ease: "easeInOut",
times: [0, 0.4, 1],
delay: i * 0.5,
}
},
}), []);
const musicIcons: { type: IconType; position: string }[] = useMemo(() => {
return Array(5).fill(null).map((_, i) => ({
type: Math.random() < 0.5 ? 'Icon1' : 'Icon2',
position: i % 2 === 0 ? `left-${1 + i * 2}/8` : `right-${1 + i * 2}/8`
}));
}, []);
type IconType = 'icon1' | 'icon2';
interface SpotifyPlayAnimationProps {
isPlaying: boolean;
}
const SpotifyPlayAnimation: React.FC<SpotifyPlayAnimationProps> = ({ isPlaying }) => {
const IconVariants = useCallback((i: number) => ({
initial: { opacity: 0, scale: 0, y: 0, x: 0, rotate: 0, rotateY: 0, filter: 'blur(0px)' },
animate: {
opacity: [0, 1, 0],
scale: [0, 0.8, 1.2, 0.4],
y: [-5, -50],
x: [0, i % 2 === 0 ? 12 + i * 5 : -(12 + i * 5)],
rotate: [0, i % 2 === 0 ? 5 + i * 2 : -(5 + i * 2)],
rotateY: [0, 20, 45],
filter: ['blur(0px)', 'blur(0px)', 'blur(2px)'],
transition: {
duration: 3,
repeat: Infinity,
ease: "easeInOut",
times: [0, 0.4, 1],
delay: i * 0.5,
}
},
}), []);
const musicIcons: { type: IconType; position: string }[] = useMemo(() => {
return Array(5).fill(null).map((_, i) => ({
type: Math.random() < 0.5 ? 'Icon1' : 'Icon2',
position: i % 2 === 0 ? `left-${1 + i * 2}/8` : `right-${1 + i * 2}/8`
}));
}, []);
这段代码定义了音符图标的动画变体和位置,通过复杂的参数控制实现了丰富的动画效果。
值得一提的是,歌曲信息的实时更新使用了 这个强大的 React Hooks 库,它专门用于数据请求和缓存管理。 SWR
另外,唱片封面上的圆形纹理和光泽效果是 CSS 来实现的,这里没有使用图片代替,测试了两种方案之后,使用 CSS 样式比图片加载更快。
图片模糊载入动画
为了优化图片加载体验,我参考了 Loading Images With the “Blur Down”
布局动画
在"好物"页面,我运用了 Framer Motion 的 Layout 动画功能。当你点击图片时,它会从原来的位置优雅地浮起来,形成一种流畅的过渡效果。如果你对这种动画感兴趣,可以参考: Layout animations
微不足道的细微动画
网站中还有许多不起眼但同样重要的小动画。比如,代码块右上角的"复制代码"按钮就有一个精巧的切换动画。当你点击复制后,它不会立即变成成功图标,而是有一个微妙的过渡效果。这种细节可能很容易被忽视,但正是这些小细节累积起来,才能营造出整体流畅、精致的用户体验。
以下是实现这个效果的核心代码:
<Tooltip content="复制代码" >
<button
onClick={copyToClipboard}
className="group absolute right-2 top-[7px] rounded-md transition-all duration-300"
aria-label="复制代码"
>
<Icon
name="clipboard"
className={c(
"size-5 transition-all duration-300",
isCopied ? "scale-0 opacity-0" : "scale-100 opacity-100",
"text-neutral-400 dark:text-neutral-500 group-hover:text-neutral-500 dark:group-hover:text-primary-dark"
)}
/>
<Icon
name="tick"
className={c(
"absolute left-1/2 top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 transform transition-all duration-300",
isCopied ? "scale-100 opacity-100" : "scale-0 opacity-0",
"text-gr dark:text-gr-dark"
)}
/>
</button>
</Tooltip>
<Tooltip content="复制代码" >
<button
onClick={copyToClipboard}
className="group absolute right-2 top-[7px] rounded-md transition-all duration-300"
aria-label="复制代码"
>
<Icon
name="clipboard"
className={c(
"size-5 transition-all duration-300",
isCopied ? "scale-0 opacity-0" : "scale-100 opacity-100",
"text-neutral-400 dark:text-neutral-500 group-hover:text-neutral-500 dark:group-hover:text-primary-dark"
)}
/>
<Icon
name="tick"
className={c(
"absolute left-1/2 top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 transform transition-all duration-300",
isCopied ? "scale-100 opacity-100" : "scale-0 opacity-0",
"text-gr dark:text-gr-dark"
)}
/>
</button>
</Tooltip>
结尾
除了上面提到的这些,网站中还有许多其他的动画效果,限于篇幅就不一一介绍了。而且,我还在不断优化和改进这些动画。就像精心打磨一件产品一样,我正在慢慢将这个博客构建成我理想中的样子。
这个过程不仅是在创造一个个人网站,更是一次深入学习 Next.js 和 Framer Motion 的机会。通过实践,我不断加深对这些技术的理解,同时也在探索如何将创意转化为现实。
每一个动画,每一个交互,都是我对用户体验的思考。我希望通过这些细节,能够为访问者带来一丝惊喜和愉悦。当然,这个过程还远未结束。我会继续关注新的 Web 动画技术,不断尝试和创新,让这个网站始终保持生机和活力。
如果你对网站中的任何动画效果感兴趣,或者有任何改进的建议,欢迎随时与我交流。让我们一起探讨如何打造更加丰富、流畅的 Web 体验!