Scroll restoration is a feature that we tend to take for granted. With traditional HTML-based pages and navigation (i.e., server-rendered websites), the browser has always handled this interaction for us.
With the shift away from server-side rendering and towards the browser, apps rendered on the client side and even JS-enhanced HTML pages have lost this very useful user experience feature. One of the most important places to have scroll restoration working correctly is on a product listing page (PLP) on an ecommerce website. In this article, we’re going to explore a technique to manually restore the scroll position of the browser so that our ecommerce users get a consistent and performant experience.
Let’s imagine we have an ecommerce website with a PLP that displays four products per row and 32 products per page. Each product card is roughly 500px tall (which accounts for a product image, product name, color/size information, price, and an “add to cart” button).
Now let’s assume a user navigating the website has a browser resolution of 1920 x 1080px. This means they can see two rows of products at a time. In total, there are up to four “viewports” of scrolling available to the user for this PLP: 32 products per page, four products per row, eight rows in total, and two rows per viewport.
Now let’s imagine that a user has scrolled down the PLP and clicked on the 15th product, so the browser navigates them to the product description page (PDP) for that product as a result. This means they’ve selected a product on the fourth row.
Now the user wants to go back to the listing page and continue to look through the rest of the products, so they press the back button. The browser navigates back, and when the listing page has finished loading, the products that are visible are on the first and second rows.
The user then has to scroll down manually, hunting for the product they had just viewed, in order to continue looking at the rest of the products. This is a very common cause of frustration with ecommerce users, and one that causes them to lose trust in the website and potentially the brand.
The user has no idea why this has happened, but as developers, we do. Our PLP is rendered and populated completely in the browser, so when the browser first paints the HTML (possibly including just the header and footer), the section where the products are rendered is just an empty div.
When the browser tries to natively restore the scroll position using the Y coordinate where the scrollbar was located prior to navigating to the PDP, the page won’t be long enough. The browser is unable to restore the scroll position accurately, because it doesn’t know when our application has finished rendering, and therefore when the right time to restore should be.
In these situations, we’ll need to handle restoring the scroll position ourselves.
To really understand what scroll restoration is, let’s have a look at some situations where we would need to manually control scroll restoration: server-rendered web pages with JS-rendered content (hybrid pages), and applications rendered fully on the client side (e.g., a React application).
First, a baseline. Here we have a server-rendered website, with all HTML available to the browser as soon as it starts to render:
As we can see, navigating back to the PLP from a PDP restores the scroll perfectly to the product that we initially clicked on before navigating to the PDP.
Next, we have a hybrid page. This is a page where most of the HTML content is available to the browser on first paint, but some – in this case, a list of products – is populated later by JavaScript.
We can see that there is a delay between the header and footer being displayed, and the products being loaded and then rendered:
As we can see in this example, when navigating back to the PLP, the browser stays at the top of the viewport, and isn’t able to restore scroll to the position of the product that we initially clicked before navigating to the PDP.
Finally, here we have a fully client-side application built with React:
In this final example, we see similar behavior to the hybrid approach; that is, the browser isn’t able to scroll restore to the product that was initially clicked on.
Implementing scroll restoration is very similar for both hybrid pages and fully client-side apps, in that we’ll make use of JavaScript to find the product to which we will restore our scroll location. For the purposes of this article, we’ll cover implementing scroll restoration in a React application.
If you would like to see an implementation of scroll restoration for a hybrid application, check out the GitHub repository for this article. We will be making references to the code in the repository at times, but the important code will be present within this article.
We’re going to start with a very basic application that has two pages: the first page contains 32 products with its source code in a component called PLP.jsx
, and the second page is simply a blank page that will act as our placeholder PDP, with source code in a component called PDP.jsx
.
We also have a ProductCard.jsx
component, which is used to render each of the 32 products.
First, we need to store a reference of the product the user selected. We’re going to do that using sessionStorage
and an ID of the product.
Why sessionStorage
? This is just the format that has been chosen for this demonstration. We could very easily use a global state manager in order to store the ID of the product to restore, or some other way of keeping the value in memory. Also, we don’t need this data to stick around for a long time (like it would with localStorage
). If a user closes the tab, then the data can be safely forgotten.
The code necessary to make that happen is a simple function that is called when a link within the ProductCard
component is activated. To do that, we define the function in our PLP
component, and pass it into the ProductCard
component, calling it when a link is clicked:
PLP.jsx
const PLP = () => { const persistScrollPosition = (id) => { sessionStorage.setItem("scroll-position-product-id-marker", id); }; return ( <ProductCard product={product} onSelect={persistScrollPosition} /> ); }
ProductCard.jsx
const ProductCard = (props) => { const { product, onSelect } = props; const { id } = product; return ( <div> {/* ... */} <Link to="/pdp" onClick={() => onSelect(id)} /> {/* ... */} </div> ); }
Now that we have stored the product that was clicked, we need to scroll the browser to that product when the PLP gets rendered again.
To do this, we’ll make use of a callback function in setState
in order to render the rest of the application, and then pass a restorationRef
through to the ProductCard
that needs to be scrolled into view:
PLP.jsx
const PLP = () => { // ... const [productMarkerId] = React.useState(() => { // Lazy initialise the productMarkerId const persistedId = sessionStorage.getItem( "scroll-position-product-id-marker" ); sessionStorage.removeItem("scroll-position-product-id-marker"); return persistedId ? persistedId : null; }); // ... return ( <ProductCard product={product} onSelect={persistScrollPosition} restorationRef={Number(productMarkerId) === product.id ? restorationRef : null} /> ); }
ProductCard.jsx
const ProductCard = () => { const { restorationRef } = props; React.useEffect(() => { // restorationRef is only provided to the ProductCard that needs to be scrolled to if (!restorationRef) { return; } // Restoring scroll here ensures the previously selected product will always be restored, no matter how long the API request to get products takes restorationRef.current.scrollIntoView({ behavior: 'auto', block: 'center' }); }) // ... };
The magic happens on line 11 in the ProductCard.jsx
component above. The value of auto
for behavior
has been selected because browser scroll restoration doesn’t usually use an animation, but if an effect is desired, we could easily use smooth
.
As for block
, the value of center
has been chosen because it ensures that no sticky elements will get in the way. For example, if we used the value start
and we had a sticky header (something that is very common in ecommerce), then the top of the product row would be covered by that header. This wouldn’t give an accurate scroll restoration experience despite it being technically correct.
That’s it! That’s all we need in order to manually implement scroll restoration. 🎉
However, if we really want to strive for a quality user experience, then we should take it a few steps further.
Imagine this scenario: a customer selects a product on a PLP and views the description page for that product. Then, they use the menu to navigate to another PLP and click a description page for different product, before using the menu once more to go back to the original PLP. The user has been navigating forward the entire time. Do you know what would happen?
Well, scroll would be restored to the product that was selected a few navigations ago, even though the user is moving forward to the listing page. This is far from the ideal behavior, and would likely cause confusion for users.
To work around this, we can add a check to our lazy state initialization function that will determine if a user is actually moving back to the listing page, or simply navigating to it again from another page on the site.
In React, we can use React Router and the useHistory
hook, which would look like this:
import { useHistory } from 'react-router-dom'; const PLP = () => { const history = useHistory(); // ... const [productMarkerId] = React.useState(() => { // History action will be POP when a user is "moving back" to a page. Alternative will be "PUSH" if (history.action !== 'POP') { return null; } // ... }); };
The next question is, what happens if the listing page uses the very common infinite loading pattern to provide an endless list of products as the user scrolls down the page? When a user clicks on a product, then hits the back button, how does the app know what to load so that the right product can be scroll restored? Let’s have a look at that next.
The first thing we need to ensure for our infinite loading listing page is that we’re keeping track of the page that is being loaded. We can easily do this in a state value, but that wouldn’t be very useful to a user, specifically a user who wants to share a page of products.
We should consider tracking the latest loaded page of products in the URL. This gives the user great feedback to see how many pages of products they’ve loaded (without having to search around the app’s UI to try and find that information), and it also gives us the opportunity to use the page number in the URL bar for our scroll restoration!
Before we get into the technical side of things, let’s update our example scenario:
Imagine our ecommerce website has a single listing page. We have our 32 products, but we don’t want to show them all at once and render a huge, long page. Instead, we want to load and render just the first 12, then the next 12, then the final eight. We’re going to do this using of infinite loading.
As the user approaches the last row of products, they’ll see a “Load Next” button, which will load the next page of products, and append them to the end of the current list. As pages are loaded, a query string in the URL will update ?page=2
to ?page=3
when the final page gets loaded.
A user then selects one of the products and navigates to the description page for that product. When the user has finished viewing that description page and triggers the back button, what page will the browser load?
When we go back, the browser is going to reinstate the most recent URL that we were on prior to the navigation event, which would be ?page=3
. Provided that the product that the user initially selected was on page three, our scroll restoration will work without a problem.
However, what if the user scrolled down to load all three pages of products, then scrolled back up and clicked on a product that is in page two’s dataset? What happens when they trigger the back action from the description page?
Well, the third page of results will still be loaded, and the product that the user selected won’t be found, so scroll restoration won’t occur. The user will be left at the top of the viewport, viewing the first row of products from page three. This is not what the user will expect, and we can make this experience much better.
Recall the persistScrollPosition
function that we added to our PLP
component. We can add a further step to this that will update the query string in the URL just before the navigation event occurs. Remember, the onSelect
prop of ProductCard
gets added to the onClick
prop of the Link
component within ProductCard
.
Also note that in the Link
component, the onClick
function will be executed before the navigation to the path in to
.
Using this knowledge, we can quickly change the query string in the URL just before the navigation event is triggered. This means when the user clicks on the back button from a description page, the browser will load the page of products that contains the product the user initially selected! Let’s see that in code:
const PLP = () => { // ... const persistScrollPosition = (id, pageNo) => { // Set the page value in the query string to match the page that the selected product is on history.replace(`?page=${pageNo}`); // ... }; // ... }
For the sake of keeping this article focused on the scroll restoration technique, I’m not going to post all the changes required to implement infinite loading. If a reference is required, the pattern can be found implemented in the previously linked GitHub repository.
As we can see in the code above, we simply call history.replace
with the page number from which the selected product was loaded. This will require a slight update to the product data that is added to the products
array, so that it can remember which page the product actually belongs to.
With that very simple update, we can rely on the browser to reinstate the URL with the correct page number of the product that the user selected, irrespective of how many pages were infinitely loaded, and our scroll restoration mechanism will kick in and work as expected.
There is a lot more that can be implemented for scroll restoration to create a great user experience. In the interest of not making this article too long, here is a very brief overview of those extra features that can make an ecommerce site’s user experience a level above the rest.
Rather than displaying a basic loading message, prefill the page with “placeholder” products that, when replaced, won’t cause scroll jank. Scroll jank occurs when the browser attempts to scroll restore, but only scrolls to the bottom of the (very short) page because products haven’t yet been rendered.
scrollRestoration
propertyTell the browser that we’re going to handle scroll restoration by setting the scrollRestoration
property in the History API to manual
. This is also helpful in avoiding scroll jank.
Consider the accessibility of scroll restoration. When a user navigates back, which element will be given focus? Which element should be given focus? The product that the user selected will be restored back into the viewport, but what happens if the user then hits the tab key?
In the examples above, we’re only tracking a single product for scroll restoration, assuming a simple PLP → PDP → (back) PLP pattern. But what if the user moves forward through multiple different PLPs and PDPs (using the menu), then decides to hit the back button a bunch of times?
The example code in this article won’t handle that, but it would be fairly trivial to update it for that functionality.
As mentioned earlier, scroll restoration was a feature that we previously relied entirely on browsers to handle for us, so we never gave it a second thought. However, with a lot more client-side and Progressive Web Apps being built, this simple feature is being lost, and it’s having a negative effect on the user experience of ecommerce apps.
In this article we’ve discussed some simple steps we can take to ensure we’re building ecommerce websites that are easy to navigate, and that users can trust.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
2 Replies to "Implementing scroll restoration in ecommerce React apps"
Great article. Thank you very much. It helped a lot.
It could be better if you had mentioned the `useRef` hook in the code.
Great article, thanks a lot! Also add to the request of useRef mention, had to search for it in github repo 🙂