How does Web & Crafts make this awesome, buttery smooth transition where text and objects seem to come out of the abyss and it feels perfectly fluid?
Let me show you how I used a little AI to quickly reproduce the effect in Builder.io, how the code works with GSAP timelines, and how you can iterate visually. I will also show how I use Nano Banana and Veo 3 to make those 3D objects.
You can hand code your layout, but I am lazy. I use the Builder.io Chrome extension to copy layouts directly from the web.
Paste into Builder, iterate in the chat, and you have a starting point: a static section that scrolls like any other page content.
We are going to use the GSAP library to add scroll animations. GSAP gives precise timeline control that is easy to tweak in code.
The key move is to fade in each letter of the headline from blurry and transparent to clear and focused, with a stagger between letters.
Here is the prompt I used to initially recreate the effect:
Let's use GSAP to add a scroll animation to the DESIGN section.
The content should be sticky centered during scroll.
Fade in each letter of DESIGN from blurry transparent to clear and focused.
The animation should stagger between each letter so 2 or 3 letters are animating but are at different stages.
The paragraph and Learn More will fade in and up once the full word is revealed.
Note that I’m not trying to do everything at once, just getting the basic fades in and I can refine with feedback.
In my case the initial results were good but some of the layout was off, so I can switch to Design Mode in Builder to do some precision fixes:
Once the text feels right, we can add the video. Drag in the butterfly video, place it where we want, and tell the agent to make the video fade in as the letters animate.
I used the prompt:
Can you add this video to the DESIGN animation?
It will be just behind and to the right of the N in Design and will fade in at the same time DESIGN animates
Builder generates UIs using your design system by default. In my case I wanted some additional adjustments, so you can just select the video layer in Design Mode and position it exactly.
Iterate until the timing and placement are what you want, and you can tell the Builder.io agent to repeat this same technique for the two sections below.
In Builder you can ask where the GSAP code lives. In my case, it is in design-showcase.tsx
.
You can go to Code Mode and tweak any details you like, like the blur amounts and offsets. For fine tuning, this can be much faster than chasing prompts.
Let’s walk through the actual code powering these effects and explain the key parts.
GSAP is not a React library, it works on DOM nodes. So we’ll need to query the DOM for some of the important parts, like the videos, text, and buttons.
Importantly, if the user prefers reduced motion, we skip animations and set everything to visible.
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const lettersGroups = Array.from(sectionRef.current.querySelectorAll(".letters"));
const videos = Array.from(sectionRef.current.querySelectorAll(".video"));
const bodies = Array.from(sectionRef.current.querySelectorAll(".body"));
const ctas = Array.from(sectionRef.current.querySelectorAll(".cta"));
if (prefersReducedMotion) {
[...lettersGroups, ...videos, ...bodies, ...ctas].forEach((el) => {
Object.assign(el.style, {
opacity: "1",
filter: "none",
transform: "none",
pointerEvents: "auto",
});
});
return;
}
We make letters blurred and offscreen, videos blurred and offset, body and CTA hidden. These are the starting frames for our transitions.
lettersGroups.forEach((group, i) => {
const letters = group.querySelectorAll(".letter");
gsap.set(letters, {
opacity: 0,
filter: "blur(16px)",
xPercent: i === 0 ? -60 : -40,
});
gsap.set(group, { opacity: 1 });
});
videos.forEach((v, i) =>
gsap.set(v, { opacity: 0, filter: "blur(22px)", x: i === 1 ? 120 : -120, pointerEvents: "none" })
);
bodies.forEach((b) => gsap.set(b, { opacity: 0, filter: "blur(18px)", y: 24 }));
ctas.forEach((c) =>
gsap.set(c, { opacity: 0, filter: "blur(18px)", y: 18, pointerEvents: "none" })
);
We have a small phases array at the top that we use for rendering in react and for our gsap timelines.
const phases = [
{
id: "design",
title: "Design",
body: "Intelligent design ...",
href: "#",
video: { src: "design.mp4", poster: "design.jpg" },
},
{ id: "build", ... },
{ id: "market", ... },
];
Now we can iterate over these and establish our from
and to
states for each part of the animation:
const phaseDuration = 1.4;
const hold = 0.35;
const exitDelay = hold + 0.42;
phases.forEach((_, i) => {
const group = lettersGroups[i];
const letters = group?.querySelectorAll(".letter") ?? [];
const video = videos[i];
const body = bodies[i];
const cta = ctas[i];
const isLast = i === phases.length - 1;
const tl = gsap.timeline({ defaults: { ease: "power2.out" } });
// headline letters
tl.fromTo(
letters,
// starting styles
{ opacity: 0, filter: "blur(16px)", xPercent: i === 0 ? -60 : -40 },
// ending styles
{ opacity: 1, filter: "blur(0px)", xPercent: 0, duration: phaseDuration * 0.55, stagger: { each: 0.07, from: "start" } },
0
).to(letters, { opacity: 1, filter: "blur(0px)", duration: hold }, ">-");
// video
tl.fromTo(
video,
{ ... },
{ ..., onStart: () => gsap.set(video, { pointerEvents: "auto" }) },
0.15
).to(video, { opacity: 1, filter: "blur(0px)", duration: hold }, ">-");
// body and CTA
tl.fromTo(
body,
{ ... },
{ ... },
0.18
).to(body, { opacity: 1, filter: "blur(0px)", duration: hold }, ">-");
tl.fromTo(
cta,
{ ... },
{ ..., onStart: () => gsap.set(cta, { pointerEvents: "auto" }) },
0.22
).to(cta, { opacity: 1, filter: "blur(0px)", duration: hold }, ">-");
base.add(tl, base.duration());
});
Timeline anchors explained:
- The fourth argument to fromTo is a number offset from the timeline beginning (e.g. a delay)
">"
means start at the end of the previous animation.">-"
means start at the previous end minus a small overlap, so sequences blend together.
Now we create a ScrollTrigger
to bind our timeline to the scroll position of our container.
base
collects each per-phase timeline. ScrollTrigger pins the content and scrubs the animation to scroll position.
end: () => "+=" + window.innerHeight * 6.6
sets a cinematic scroll length relative to the viewport.
const base = gsap.timeline({ defaults: { ease: "power2.out" } });
ScrollTrigger.create({
animation: base,
trigger: sectionRef.current,
start: "top top",
end: () => `+=${window.innerHeight * 6.6}`,
scrub: 1.1,
pin: contentRef.current,
pinSpacing: true,
invalidateOnRefresh: true,
});
Why this feels good
- Pinning keeps the content fixed while the world scrolls under it
- Scrub 1.1 adds a slight easing to the scroll linkage so motion feels natural.
Now, with the above techniques, you can recreate the animations from the WAC site. But if you want to add cool 3d holographic objects like they do, you’ll want to generate some of your own.
Here’s how to create your own
- In Gemini, go to
Tools
and selectCreate images
. You need a reference, so upload a screenshot of a style you like (e.g. the holographic style of WAC). - Paste the screenshot into Gemini and first force the AI to describe the style in excruciating detail.
- Ask it to generate a new image, for example a floating cog in that same style. If colors are off, give feedback and iterate.
Then, we’ll need switch to Veo 3 to animate the image. Create a new chat, select Tools
, then Create videos with Veo
. Upload your reference image, and ask Veo to animate it (e.g. I used a cog, so I asked Veo to make it spin).
If the loop is not seamless, use a loop tool to (e.g. Sora) to align the first and last frames. Once you have a clean loop, download it and replace your placeholder video.
Then, just pop the new video in your code or Builder.io, and repeat to your hearts desire!