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()
}