Scrollable Container Controls with React
Managing a scrollable container with React by adding button controls, and seeing when they should be enabled or disabled.
The below still serves as an explanation of the code, and so still has value if you’re interested to learn more about it.
I recently had to solve an issue: A container has a list of items which can be scrolled horizontally. However, it is not obvious you can scroll if a scrollbar isn’t visible. This is the case sometimes depending on OS and user preferences.
The agreed solution was to add some buttons to let the user know there’s more content available. Clicking these buttons would scroll either backwards or forwards. Seems straight-forward enough, though there were some things to consider:
- We need to know if there is overflow on a given container.
- A button should be disabled if there isn’t any more content to scroll left or right to.
- We should re-evaluate if there’s overflow and if we can scroll whenever a new item is added or removed.
For the purposes of showing how I ended up with a solution, I won’t be doing the following:
- Writing optimised code - I don’t want to go off on a tangent and refactor with hooks, or HoCs. This demo exists in a world where it’ll only be used in one place, ever.
- Nice styles.
Also, a chunk of this isn’t really React-specific. You could take the DOM parts out and drop them into any other setup that you’d like. My use-case just happened to require('react')
.
Setup
If you don’t want to go through the initial setup on your own machine you can use CodeSandbox and select the React preset.
I’m going to work off of a fresh install of Create React App by typing the following into my terminal:
npx create-react-app scrollable-container
If you’re unfamiliar with npx
, you can read the announcement. It’ll do a bunch of interesting things, but for our use case it’ll let us use CRA without installing it globally first.
Once that’s done, I’ll cd
into my new project directory and run npm start
, or yarn start
which will give us a development server on localhost:3000
by default. Next, I’ll clear out bits of src/App.js
that I don’t need so I’m left with this:
import React from 'react';
function App() {
return (
<div className="App">
</div>
);
}
export default App;
I’m then going to delete src/App.css
and update src/index.css
for some initial styles.
*, *:before, *:after {
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
I’m setting the body
to display: flex
because I plan on having only one node in there, so this is more of a convenience to centre it in the page.
In the last part of the setup, we’ll create the component where all the interesting stuff is going to happen. I’m going to call it ScrollableContainer
in a new components
folder in the src
directory. I’ll also add some styles.
import React, { PureComponent } from 'react'
import debounce from 'lodash.debounce'
export default class ScrollableContainer extends PureComponent {
constructor() {
super()
this.state = {
items: [...Array(7).keys()],
hasOverflow: false,
canScrollLeft: false,
canScrollRight: false
}
this.handleClickAddItem = this.handleClickAddItem.bind(this)
this.handleClickRemoveItem = this.handleClickRemoveItem.bind(this)
this.checkForOverflow = this.checkForOverflow.bind(this)
this.checkForScrollPosition = this.checkForScrollPosition.bind(this)
this.debounceCheckForOverflow = debounce(this.checkForOverflow, 1000)
this.debounceCheckForScrollPosition = debounce(
this.checkForScrollPosition,
200
)
this.container = null
}
checkForScrollPosition() {}
checkForOverflow() {}
handleClickAddItem() {
this.setState(state => {
return {
items: [...state.items, state.items.length]
}
})
}
handleClickRemoveItem() {
this.setState(state => {
return {
items: state.items.slice(0, -1)
}
})
}
buildItems() {
return this.state.items.map(item => {
return (
<li className="item" key={item}>
{item + 1}
</li>
)
})
}
buildControls() {
return (
<div className="item-controls">
<button type="button">Previous</button>
<button type="button" onClick={this.handleClickAddItem}>
Add Item
</button>
<button type="button" onClick={this.handleClickRemoveItem}>
Remove Item
</button>
<button type="button">Next</button>
</div>
)
}
render() {
return (
<>
<ul
className="item-container"
ref={node => {
this.container = node
}}
>
{this.buildItems()}
</ul>
{this.buildControls()}
</>
)
}
}
.App {
max-width: 500px;
}
.item-container {
display: flex;
overflow-x: scroll;
list-style: none;
padding: 0;
margin: 0;
}
.item {
padding: 30px;
margin-right: 2px;
background-color: coral;
flex-shrink: 0;
}
.item-controls {
text-align: center;
}
That’s a fair sized chunk of code. We’ve started installing lodash.debounce
with npm install lodash.debounce
in order to ease up on the DOM events we’ll be hooking into, which are bound in the constructor.
The rest of the code displays the items and is responsible for adding, and removing items. We also capture a reference to the container, which we will call via this.container
.
Next up, we’ll start filling in the stubs.
Check for Overflow
To check for an overflow, we’ll need to check the width of the container vs its scroll width. We can do that, and set the result as follows in the checkForOverflow
method:
checkForOverflow() {
const { scrollWidth, clientWidth } = this.container
const hasOverflow = scrollWidth > clientWidth
this.setState({ hasOverflow })
}
Nothing will happen until we call that method, and there’s a couple of ways we’ll do it.
First up we’ll add a componentDidMount
method, and we can do the following:
componentDidMount() {
this.checkForOverflow()
}
However, you’ll only see the state change hasOverflow
if the initial count of items warrants an overflow. If you change items: [...Array(7).keys()]
to items: [...Array(15).keys()]
then you should see the state change in React Dev Tools when the component mounts.
If we leave it back at 7 though, we’ll need to consider when someone adds or removes an item. This is where the componentDidUpdate
lifecycle method comes in. We can check if the length of the previous items array is different from the current one.
componentDidUpdate(prevProps, prevState) {
if (prevState.items.length !== this.state.items.length) {
this.checkForOverflow()
}
}
Try adding and removing items now, and you’ll see hasOverflow
changing.
There are some things you can do with this, such as showing and hiding the controls if you so wish, or use it to change the styles.
Enable/Disable Controls
You can see in the state we have canScrollLeft
and canScrollRight
. We’ll use these to enable and disable the controls.
So for starters let’s update the buttons disabled
property to use these:
buildControls() {
const { canScrollLeft, canScrollRight } = this.state
return (
<div className="item-controls">
<button type="button" disabled={!canScrollLeft}>
Previous
</button>
{/* ... add and remove buttons ... */}
<button type="button" disabled={!canScrollRight}>
Next
</button>
</div>
)
}
Based on the initial state, both of these should be disabled, so let’s figure out when they should be clickable.
checkForScrollPosition() {
const { scrollLeft, scrollWidth, clientWidth } = this.container
this.setState({
canScrollLeft: scrollLeft > 0,
canScrollRight: scrollLeft !== scrollWidth - clientWidth
})
}
If the scrollLeft
is greater than 0, then we know the container has been scrolled a bit. It represents the number of pixels scrolled from the left.
To check if we can scroll right, we need to look at the scrollWidth
of the container, which will be the entire length of the container if the overflow wasn’t hidden, and the clientWidth
which is the width we can physically see.
Say the scrollWidth
is 600, and the clientWidth
is 500. If we subtract those, we get 100 which is the maximum value scrollLeft
can be. As long as scrollLeft
isn’t 100, we know there’s still more to scroll.
In order to get this method called, we’ll need to add some more code into componentDidMount
to listen for scroll events on the container, to ensure we have access to the container ref. We’ll also add an initial call to this.checkForScrollPosition()
in case we have a large number of items on mount that need to be scrollable.
componentDidMount() {
this.checkForOverflow()
this.checkForScrollPosition()
this.container.addEventListener(
'scroll',
this.debounceCheckForScrollPosition
)
}
Finally, we’ll need to update componentDidUpdate
to check again. This covers an issue where you’ve scrolled to the end of the container, and clicked Add Item
. You’ll see the next button is still disabled even though there’s new space to scroll.
componentDidUpdate(prevProps, prevState) {
if (prevState.items.length !== this.state.items.length) {
this.checkForOverflow()
this.checkForScrollPosition()
}
}
Adding some more items, and scrolling the container, you’ll see the buttons toggle between enabled and disabled.
Scrolling the Container
The last step is to make those next and previous buttons actually do something. To start, we’ll create the method that will scroll the container:
scrollContainerBy(distance) {
this.container.scrollBy({ left: distance, behavior: 'smooth' })
}
This will accept a number which will scroll the container, and it’ll be a smooth scroll as well to make it look profesh. If we pass 200 into there, it’ll scroll to the right 200px, and we can then pass -200 to go the opposite way. That leaves our buttons like this:
buildControls() {
const { canScrollLeft, canScrollRight } = this.state
return (
<div className="item-controls">
<button
type="button"
disabled={!canScrollLeft}
onClick={() => {
this.scrollContainerBy(-200)
}}
>
Previous
</button>
{/* ... add and remove buttons ... */}
<button
type="button"
disabled={!canScrollRight}
onClick={() => {
this.scrollContainerBy(200)
}}
>
Next
</button>
</div>
)
}
Finally then to wrap it all up, we need to remove our event listeners in componentWillUnmount
. We’ll need to call cancel
on the debounced method in case it fires after unmounting, and it tries updating state on a component that doesn’t exist anymore.
componentWillUnmount() {
this.container.removeEventListener(
'scroll',
this.debounceCheckForScrollPosition
)
this.debounceCheckForOverflow.cancel()
}