Anatomy of a scrollspy component with React and TypeScript (1/2)

How to create a basic scrollspy component and integrate it with existing content.

Tom Allen
Frontend Weekly
7 min readDec 12, 2018

--

A scrollspy is a common type of in-page navigation that tracks certain page elements and shows which element the user’s screen is currently centred on. In this tutorial, we will create one with React and TypeScript. Later we will look at how to make the component more reusable, and eventually publish it on npm.

The finished Scrollspy

What does a Scrollspy need to do?

Before creating the component itself, let’s think about the behaviour we are trying to achieve. A basic scrollspy component needs to do two things, and it’s right in the name: scroll and spy.

First, it needs to display a clickable menu of page elements that a user can navigate to. That is the ‘scroll’ part.

Second, it needs to update itself to highlight the currently centred item. It does this by ‘spying’ on the user’s position in the page.

Now we have an idea of what we need the component to be able to do, we can think about what we need to build.

What does a Scrollspy component need to have?

The two requirements, scrolling and spying, will give us the shape of our component props and state respectively.

The Scroll Menu

The requirements of the scroll menu will give us the shape of the components props.

We know the scrollspy needs to display a menu, and we know that the appearance of the menu will need to change. This means that we are going to be adding and removing an active class from items in the list. To make the component reusable, we should design the component in such a way that this class can be passed in as a prop.

<Scrollspy activeItemClassName='active' />

Elsewhere, we can define an activeclass in our CSS, for example:

.active{
font-weight:bold
}

We also know that the menu items are going to relate to the page content, and we would like the user of the component to be able to specify which bits of content to include in the menu. The way we are going to do this is by allowing the user to pass in an array of ids for page elements that we are going to track.

Thus, our TypeScript interface for the Scrollspy component will look something like this:

interface ScrollspyProps {
ids: string[];
activeItemClassName?: string;
}

We can anticipate that down the road we will want to let the user pass in other classes to control the look of the menu items more generally, and the menu itself. Let’s add those to our interface now:

interface ScrollspyProps {
ids: string[];
activeItemClassName?: string;
itemContainerClassName?: string;
itemClassName?: string;
}

Handle scrolling

To handle the navigation aspect of the menu elegantly, we are going to make use of an onClick function rather than using a normal link. There is a useful function called scrollIntoView() which does pretty much exactly what we want, but we are going to wrap it in our own function so we can pass it a few options.

private scrollTo(element: HTMLElement) {
element.scrollIntoView({
behavior: "smooth",
block: "start",
inline: "nearest"
});
}

Be aware that support for some of these options is experimental or non-existent in some browsers. In such cases, clicking the menu item will jump to that section rather than scrolling elegantly.

The Spy

The more interesting part of the component is the spying behaviour. The spy requirements will give us the shape of the components state.

The state of the component needs to keep track of which item should have the active class applied to it at a given moment, that is, which item is currently in the centre of the view. We know that we will be passing in an array of strings corresponding to page elements. Our state can be an array of those elements, plus a boolean to indicate which one is active.

interface SpyItem {
inView: boolean;
element: HTMLElement;
}
interface ScrollspyState {
items: SpyItem[];
}

This state needs to be updated periodically to reflect changes in the user’s position on the page. Let’s write a function that we can call periodically to achieve this result.

What is the spy function going to have to do? First, it’s going to have to map over the items we are tracking and get the corresponding element. Then it’s going to find out if that element is in view. Finally, it is going to update the state with its findings.

private spy() {
const items = this.props.ids
.map(id => {
const element = document.getElementById(id);
// TODO check if its in view
})
this.setState({ items: items});
}
}

How can we figure out if an element is currently in view? We can write a function that uses some built in information and a tiny bit of math.

private isInView = (element: HTMLElement) => {
const rect = element.getBoundingClientRect();
return rect.top >= 0 && rect.bottom <= window.innerHeight;
};

We can easily find out the current position of the element’s bounding rectangle by calling getBoundingClientRect(). This will return a friendly object that includes the top and bottom position of the element as numbers we can compare these to the innerHeight of the window.

The inner height of the window runs from 0 at the top of the screen to some number (depending on the current size of the browser window). If the top of the bounding rectangle is greater than 0, and the bottom of the bounding rectangle is greater than the innerHeight, we can conclude that the item is currently visible on the page.

However, there is something that we should be aware of: the bounding rectangle may not be precisely what we want. For example, the elements we are tracking may have top padding, or there may be a fixed bar at the top of the page. We can’t anticipate exactly how our scrollspy component is going to be used, but we can accommodate some of these possibilities by allowing the user to pass in an offset value specific to their design. The offset will be passed in as an optional prop, so we will need to update our interface (described earlier) to include it, and use that prop here.

private isInView = (element: HTMLElement) => {

const { offset } = this.props;
const rect = element.getBoundingClientRect();
return rect.top >= 0 - offset && rect.bottom <=
window.innerHeight + offset;
};

Now we have this function, we can add it to our spy function.

private spy() {
const items = this.props.ids
.map(id => {
const element = document.getElementById(id);
if (element) {
return {
inView: this.isInView(element),
element
} as SpyItem;
} else {
return;
}
})
this.setState({ items: items});
}
}

The map will now return SpyItems as defined in our interface.

The spy function needs to be run periodically. We can achieve this by using setInterval() and the React lifecycle methods.

private timer: number;public componentDidMount() {
this.timer = window.setInterval(() => this.spy(), 100);
}
public componentWillUnmount() {
window.clearInterval(this.timer);
}

When the component mounts, we attach a timer that calls our spy function periodically. To be good citizens, we should use clearInterval() when our component unmounts so that it is not still consuming resources when our component is no longer in use.

Rendering

Rendering the scrollspy component is now simply a matter of mapping over the state and applying the appropriate classes. We can make use of the popular classnames package to make this a little easier by adding it to our dependencies.

yarn add classnames

Now we can add it to our component:.

public render() {    const {
itemContainerClassName, activeItemClassName, itemClassName
} = this.props;
return (
<ul className={classNames(itemContainerClassName)}>
{this.state.items.map((item, k) => {
return (
<li
className={classNames(
itemClassName,
item.inView ? activeItemClassName : null
)}
key={k}
onClick={() => {
this.scrollTo(item.element);
}}
>
{item.element.innerText}
</li>
);
})}
</ul>
);
}

Behaviour tweaks

Our scrollspy should work quite nicely. In addition to some extra error checking we should do, there is also an issue where multiple page elements can be active simultaneously. We want to ensure that only one item, the one closest to the top of the page, is active at a given time. Let’s update the spy component to add this behaviour.

private spy() {
const items = this.props.ids
.map(id => {
const element = document.getElementById(id);
if (element) {
return {
inView: this.isInView(element),
element
} as SpyItem;
} else {
return;
}
})
.filter(item => item);
const firstTrueItem = items.find(item => !!item && item.inView);if (!firstTrueItem) {
return; // don't update state
} else {
const update = items.map(item => {
return { ...item, inView: item === firstTrueItem } as SpyItem;
});
this.setState({ items: update });
}
}

What we want is to find the first item in our list that is in view, if no item is in view, we just return without updating the state. This is because we can assume that the item already marked as inView in our state has scrolled off the top of the page and should still be counted as active.

The next change is to map over the items again, marking any item that isn’t the first item in the view as not in view. Our property named inView should really be renamed to something more accurate, like isActive to reflect what it is actually telling us.

Conclusion

Starting with a description of a scrollspy component, we have ascertained how they work and built each constituent part on our own with TypeScript and React. You can see the whole component here. In the future we will improve the reusability of the component and release it as an npm package.

--

--