In this blog post, we'll explore how to use the Intersection Observer in a React app. We'll recreate the appealing secondary nav animations found on Linear's landing page — the reveal and highlight animations that occur on scroll. This is a fantastic way to enhance user experience and add a polished look to your website.
First, we create a Vite React app using the command npx create vite app
, selecting React as the library. Next, integrate Tailwind CSS by following the simple six-step process outlined in the official Tailwind CSS documentation.
Once setup is complete, launch the development server with npm run dev
and navigate to localhost:5173
to see your app in action.
The default Vite React app provides basic markup, but we're aiming for a design closer to Linear's landing page. Manually coding this from scratch is time-consuming, but AI can help us achieve 80% of the goal without as much effort.
Starting with this mockup in Figma, I used the Builder.io Figma plugin to convert the design into React + Tailwind CSS code using Visual Copilot.
I've also added markup for the secondary navbar and content sections, which can be found in the LandingPage.tsx
file.
The primary and secondary navbars are not aligned, but this doesn’t impact our learning about the Intersection Observer.
Before implementing the animation, let's understand the Intersection Observer API and its importance for our task.
The Intersection Observer is a JavaScript API that enables actions based on the visibility of elements within the viewport. It's highly efficient for detecting when an element becomes visible or hidden, making it ideal for scroll-based animations.
Here's a basic code example:
const observer = new IntersectionObserver(callback);
const targetElement = document.querySelector("selector");
observer.observe(targetElement);
The callback function is triggered when the target element enters or exits the visible part of the screen.
To simplify the implementation of the Intersection Observer in a React app, we'll use the react-intersection-observer
package. Install it in your project with the command:
npm i react-intersection-observer
This package offers a straightforward and React-friendly approach to using the Intersection Observer API.
Let's revisit the animation we're aiming to replicate:
To implement this, we need to focus on two aspects:
- Detecting when the wrapper of the four sections comes into view.
- Transitioning the navbar from hidden to visible below the primary navbar.
In the LandingPage
component, start by using the useInView
hook from react-intersection-observer
.
{/* At the top */}
import { useInView } from "react-intersection-observer";
{/* Within the component */}
const { ref, inView } = useInView({
threshold: 0.2,
});
The hook accepts a threshold
option, indicating the percentage of visibility before triggering. It returns a ref
and a state inView
indicating whether the element is in view.
Assign the ref
to the DOM element you want to monitor, which in our case is the section wrapper element.
<div id="section-wrapper" ref={ref}>
{/* 4 sections */}
</div>
Use the inView
property to conditionally apply classes to the secondary navbar, controlling visibility and transition effects:
<nav
className={`z-20 bg-white/5 fixed flex px-60 text-white list-none left-0 right-0 top-12 transition-all duration-[320ms] ${
inView
? "opacity-100 translate-y-0 backdrop-blur-[12px]"
: "opacity-0 translate-y-[-100%] backdrop-blur-none"
}`}
>
{/* 4 nav links */}
</nav>;
When you scroll down to the section wrapper element in the browser, the secondary navbar will reveal itself.
The next step involves expanding and highlighting the secondary nav link based on the section in view:
To achieve this, we focus on two aspects:
- Detecting when each individual section comes into view.
- Expanding and highlighting the link corresponding to the section in view.
Instead of useInView
, we use the InView
component from react-intersection-observer
for detecting the section in view.
This approach allows us to specify the component once within the map
method, rather than invoking the hook four times (once for each section).
Update the section wrapper element as follows:
//Import at the top
import { useInView, InView } from "react-intersection-observer";
// Section wrapper
<div id="section-wrapper" ref={ref}>
{sections.map((section) => (
<InView onChange={setInView} threshold={0.8} key={section}>
{({ ref }) => {
return (
<div
id={section}
ref={ref}
className="flex justify-center items-center py-[300px] text-white text-5xl"
>
{section}
</div>
);
}}
</InView>
))}
</div>;
For the InView
component, we specify three props: onChange
(a callback function for when the in-view state changes), threshold
(a number between 0
and 1
indicating the percentage that should be visible before triggering), and key
(for list rendering).
To track the current section in view, maintain a state updated by the setInView
function assigned to the onChange
prop. This state updates to the id
of the section in view.
// Import useState
import React, { useState } from "react";
// State to track current active section
const [visibleSection, setVisibleSection] = useState(sections[0]);
// callback called when a section is in view
const setInView = (inView, entry) => {
if (inView) {
setVisibleSection(entry.target.getAttribute("id"));
}
};
When a section is in view, we expand the corresponding nav link to accommodate two items and change the background color. For managing the width change, we maintain separate open
and closed
state widths. This approach allows us to dynamically adjust the width of each nav link, enhancing the visual feedback as the user scrolls through the sections.
const menuWidths = {
Issues: {
open: "124px",
closed: "65px",
},
Cycles: {
open: "128px",
closed: "65px",
},
Roadmaps: {
open: "178px",
closed: "94px",
},
Workflows: {
open: "176px",
closed: "92px",
},
};
Update the state of each secondary nav link based on the visibleSection
state, and adjust the background color accordingly.
{
sections.map((section) => (
<div
key={section}
className={`transition-all duration-300 flex rounded-full border border-white/5 bg-white/5 overflow-hidden px-3 py-0.5 backdrop-blur-none`}
style={{
width:
visibleSection === section
? menuWidths[section].open
: menuWidths[section].closed,
}}
>
<span
className={`-ml-2 mr-2 px-2 ${
visibleSection === section
? `bg-indigo-500/70 border-indigo-50 rounded-full`
: ``
}`}
>
{section}
</span>
<span>{section}</span>
</div>
))
}
And there you have it — our scroll based animation using Intersection Observer with React and Tailwind CSS. Here’s the final code.
Introducing Visual Copilot: convert Figma designs to high quality code in a single click.