Next.js Conf 2024 Cursor Animation
October 3, 2024
Vercel recently made this really cool animation to announce Next.js Conf 2024. Everytime you visit their landing page, a swarm of cursors swoop in and drop these text blocks that reveal the conference date along with a CTA button.
I thought it looked pretty cool and figured, why not recreate it? So I did. Turns out it’s much easier than I thought and I’m quite happy with the result.
Slowing down the animation reveals the animation can be broken down into several sequences.
- Cursors and highlighted blocks fade in.
- Move the blocks along a bezier curve to their final positions.
- Move the cursors out, blur and fade them away.
- Highlighted blocks fade out while the content fades in.
I initially used Svelte for the first implementation but later switched to Astro because no interactivity was required and my site was already built with Astro. I could’ve used plain HTML and CSS but being able to componentize makes everything much cleaner.
Also, I’m going to skip most of the styling details since they’re fairly straight-forward to implement. I’ve assumed certain aspects to be obvious based on the final result — for instance, highlights and cursors should be positioned absolute
and the elements wrapping these should be relative
etc.
In the beginning, the markup should look something like this. {item}
represents the element we want to animate.
<div class="item-with-cursor">
<div class="item">
{item}
</div>
<div class="highlight" />
<div class="cursor">
<CursorSVG />
<div class="nametag">{name}</div>
</div>
</div>
Because the animation should be staggered, we’ll use a CSS variable for animation-delay
and increment it for each nth occurrence. In this case, we delay each item by 500ms
.
.item-with-cursor {
--cursor-index: 0;
--cursor-delay: calc(var(--cursor-index) * 500ms);
}
.item-with-cursor:nth-child(1) {
--cursor-index: 1;
}
.item-with-cursor:nth-child(2) {
--cursor-index: 2;
}
/* Many more of these nth-child blocks but you get the idea. */
Now, we can implement the first step of the animation: fading in the cursors over 500ms
each, followed by the highlights fading in.
I’ll skip the trivial keyframe implementations — fade-in
transitions opacity
from 0
to 1
, while fade-out
does the reverse.
.item-with-cursor {
animation: 500ms ease var(--cursor-delay) both fade-in;
}
.highlight {
animation: 500ms ease calc(500ms + var(--cursor-delay)) both fade-in;
}
For step two, we’ll animate the items along a cubic bezier curve using offset-path. Alternatively, a simple diagonal line (M 0 50 L 100 0
) can be used instead of the bezier curve — I initially did that. But a bezier curve will make the cursor movement looks more natural.
Wrap the .item-with-cursor
in a relative
div and place the SVG path right next to it. This will break the nth-child
selector on .item-with-cursor
so make sure to change it to select the outer div instead.
<div class="item-with-svg-path">
<div class="item-with-cursor">...</div>
<svg class="bezier-curve-svg">
<!-- This is one of the curves used by the original animation. -->
<path
id="curve-1"
d="M1 80.5C74.5 104.833 232.8 123 278 0.5"
/>
</svg>
</div>
The SVG should be positioned absolute
, and both the SVG and the item should be shifted 100% to the left. We can then adjust the item’s offset properties so that the end of the path is anchored to the item’s top left
.
Make sure to set offset-rotate
to 0deg
so that it doesn’t rotate and follow the curve’s path.
.bezier-curve-svg, .item-with-cursor {
/* 277px is the width of the curve. */
transform: translate(-277px, 0);
}
.item-with-cursor {
offset-rotate: 0deg;
offset-distance: 100%;
offset-anchor: top left;
offset-path: url(#curve-1);
}
Do the same for items that animate from the right. Shift the SVG 100% to the right instead of the left, and anchor it to the top right
of the item. Here’s how everything should look when the curves are visible.
Now, a simple keyframes implementation cursor-in
, which tweens offset-distance
from 0
to 100%
, will animate the items.
.item-with-cursor {
animation:
500ms ease var(--cursor-delay) both fade-in,
1s ease calc(250ms + var(--cursor-delay)) both cursor-in;
}
@keyframes cursor-in {
0% {
offset-distance: 0;
}
100% {
offset-distance: 100%;
}
}
For step three, we’ll move the cursors out while applying a blur effect and fading them away. Adjust the final transform values to tweak the effect: in this case, we’re moving the cursor left by 100 pixels (-100px
) and down by 50 pixels (50px
).
.cursor {
animation: 1s ease calc(1.5s + var(--cursor-delay)) both cursor-out;
}
@keyframes cursor-out {
0% {
transform: translate(0, 0);
filter: blur(0);
opacity: 1;
}
100% {
transform: translate(-100px, 50px);
filter: blur(10px);
opacity: 0;
}
}
Then for step four, fade out the highlight blocks while the content of the .item
elements fades in.
.highlight {
animation:
500ms ease calc(500ms + var(--cursor-delay)) both fade-in,
500ms ease calc(2s + var(--cursor-delay)) both fade-out;
}
.item {
animation: 1s ease calc(1.5s + var(--cursor-delay)) both fade-in;
}
And there you have it! The entire animation in under 200 lines of code, with styles included.
What’s really awesome about this animation is that it won’t mess with the positioning of the elements being animated. Whether you’re using flexbox, grid, inline-block, or block layouts—whether in rows or columns—it’ll work like a charm. Even on mobile.
Here’s Figma logo, animated.
I later refactored everything into a single Astro component so I could reuse it across the website. Here’s what the source code for the above demo looks like.
<CursorBlocks class="grid grid-cols-2">
<div class="bg-[#f24c17]" data-direction="bottom-left" />
<div class="bg-[#ff7362]" data-direction="bottom-right" />
<div class="bg-[#a358ff]" data-direction="bottom-left" />
<div class="bg-[#0fbefe]" data-direction="bottom-right" />
<div class="bg-[#00d085]" data-direction="bottom-right" />
</CursorBlocks>
During build time, Astro loops through the children and applies the wrapping and styles based on the provided data attributes.