Testing and implementing an in-page scroll link in React
One of the most commonly used strategies to allow visitors to navigate through sections of the same page is smooth scrolling. In contrast to the strategy of simply scrolling the page through an anchor added to the URL, which immediately transports the visitor to the desired section, smooth scrolling offers a more fluid experience, guiding the visitor to the final destination in a much more pleasant way.
Let's write an automated test in order to blueprint our component. In the following example, we will use Jest and React Testing Library (RTL). The first step is to name this component and specify its main behavior:
describe('Scroll Link', () => {
it('should scroll the page to the element with the given id');
});
We'll call the component <ScrollLink />
and pass the id
of the element to which the page should scroll when the link is clicked. Now, let's write the test using the AAA method (Arrange, Act, Assert). In the Arrange step, we need to import modules and utilities, make any necessary mocks, and everything else that is needed for us to act (Act) and then immediately ensure (Assert) that the result was what we actually expected.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ScrollLink from './scroll-link'
describe('Scroll Link', () => {
beforeEach(() => {
window.scroll = jest.fn();
});
function mockSection({ id, topDistance }){
const section = document.createElement('section');
section.setAttribute('id', id);
section.getBoundingClientRect = jest.fn(() => ({ top: topDistance }));
document.body.appendChild(section);
}
it('should scroll the page to the element with the given id', async () => {
const id = 'intelligence';
const label = "Intelligence";
mockSection({ id, topDistance: 400 });
render(<ScrollLink to={id}>{label}</ScrollLink>);
});
});
Test 100% arranged! Note that it is necessary to mock the return of the getBoundingClientRect
function since the jsdom
still does not implement any features related to styles (as I write this post). Time to act:
it('should scroll the page to the element with the given id', async () => {
const id = 'intelligence';
const label = "Intelligence";
mockSection({ id, topDistance: 400 });
render(<ScrollLink to={id}>{label}</ScrollLink>);
await userEvent.click(screen.getByRole('link', { name: label }));
});
Immediately after clicking the link, we ensure that the scroll was executed exactly with the expected parameters:
it('should scroll the page to the element with the given id', async () => {
const id = 'intelligence';
const label = "Intelligence";
mockSection({ id, topDistance: 400 });
render(<ScrollLink to={id}>{label}</ScrollLink>);
await userEvent.click(screen.getByRole('link', { name: label }));
expect(window.scroll).toHaveBeenCalledWith({
top: 400,
left: 0,
behavior: 'smooth'
});
});
With the test completed and failing, let's now write the implementation of ScrollLink
that makes the designed behavior a reality:
const ScrollLink = ({ to, children }) => {
const navigate = evt => {
evt.preventDefault();
window.scroll({
top: getElementTopDistance(to) + window.scrollY,
left: 0,
behavior: 'smooth'
});
}
const getElementTopDistance = elementId => {
const el = document.querySelector(`#${elementId}`);
return el.getBoundingClientRect().top;
}
return (
<a href={`#${to}`} onClick={navigate}>{children}</a>
)
}
Note that in addition to the implementation being quite concise as it does not depend on any third-party code, the link respects all accessibility-related requirements.
Initial result of the in-page scroll implementation
There's just a small issue with the experience. At the end of the scroll, the section title is too close to the top of the viewport. To eliminate this discomfort, we can improve ScrollLink
by adding an optional property that we can call offset
. This property will represent how far the target element should be away from the top of the viewport. Let's design the new use case:
it('should optionally add an offset to the target element', async () => {
const id = 'intelligence';
const label = "Intelligence";
mockSection({ id, topDistance: 400 });
render(<ScrollLink to={id} offset={40}>{label}</ScrollLink>);
await userEvent.click(screen.getByRole('link', { name: label }));
expect(window.scroll).toHaveBeenCalledWith({
top: 360,
left: 0,
behavior: 'smooth'
});
});
Then, we enhance ScrollLink
to make the new test pass:
const ScrollLink = ({ to, children, offset = 0 }) => {
const navigate = evt => {
evt.preventDefault();
window.scroll({
top: getElementTopDistance(to) + window.scrollY - offset,
left: 0,
behavior: 'smooth'
});
}
const getElementTopDistance = elementId => {
const el = document.querySelector(`#${elementId}`);
return el.getBoundingClientRect().top;
}
return (
<a href={`#${to}`} onClick={navigate}>{children}</a>
)
}
Final result of the in-page scroll implementation
Well done! Our codebase now has a component that can be used on any screen of the site or application, capable of scrolling the page to any element that is properly identified by an id
.
Hands-on: In this gist, you will find an index.html
containing all the necessary code to try out ScrollLink
locally, exactly as shown by the animations used to illustrate this post.
Learn more: If you enjoyed this post, you might be interested in learning how to mock search params in tests for React components.