The goal
To create a React component which will make any given component vertically or horizontally scrollable using the mouse wheel, so that we don't need to change the component to make it scrollable. In this example, we will make a data table scrollable: the table contents will scroll up or down when we roll the mouse wheel.
The Scrollable component will be visible as the orange-brown around the table, and the table inside will become scrollable (see the image above). Inside the scroller, the mouse cursor will change to an up/down arrow (⇳) or a right/left arrow (⬄) depending on the scroll direction.
FILE 1 - useScroll.ts
A new React custom hook for adding a "wheel" event listener to the "ref" of the scroll box. (The event listener is automatically removed in the end, to avoid a memory leak.) The event will call the provided "onScroll" function with "up" or "down" as parameter.
import { useEffect } from "react";
const useScroll = (
ref: React.RefObject<HTMLElement>,
onScroll: (direction: "up" | "down") => void
) => {
const callOnScroll = (event: Event): void => {
event.preventDefault(); // prevent background scroll
// @ts-expect-error xxx
const wheelEvent = event as React.WheelEvent<HTMLElement>;
onScroll(wheelEvent.deltaY > 0 ? "up" : "down");
};
// ------------------------------------
useEffect(() => {
if (!ref) return;
const current = ref.current;
if (current) {
current?.addEventListener("wheel", callOnScroll);
}
return () => {
current?.removeEventListener("wheel", callOnScroll);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, ref?.current, onScroll]);
};
export default useScroll;
FILE 2 - Scroller.tsx
Our new React "wrapper" React component consists of a "div" wrapping the given children. We create a "ref" for it so a "wheel" event can be attached to it, for calling the provided "onScroll" function. To the right of (or under) the children we also insert a ScrollIndicator component to show the scroll progress. We also change the cursor inside the div to show we have entered the scrollable area.
import { ReactNode, useRef } from "react";
import styled from "styled-components";
import { useScroll } from "../hooks";
import { ScrollIndicator } from ".";
type ContainerProps = {
$isHorizontal: boolean;
};
const Container = styled.div<ContainerProps>`
border: 2px solid var(--color-secondary);
border-radius: 6px;
padding: 4px;
cursor: ${({ $isHorizontal }) => ($isHorizontal ? "ew-resize" : "ns-resize")};
display: flex;
flex-direction: ${({ $isHorizontal }) => ($isHorizontal ? "column" : "row")};
gap: 4px;
`;
// ------------------------------------
type Props = {
pageOffset: number;
pageSize: number;
itemCount: number;
step: number;
isHorizontal?: boolean;
setPageOffset: (pageOffset: number) => void;
children: ReactNode;
};
// ------------------------------------
const Scroller = ({
pageOffset,
pageSize,
itemCount,
step,
isHorizontal = false,
setPageOffset,
children,
}: Props) => {
const refBoxWithScroll = useRef(null);
// ------------------------------------
const onScroll = (direction: "up" | "down") => {
if (direction === "up" && pageOffset < itemCount - pageSize) {
setPageOffset(pageOffset + step);
} else if (direction === "down" && pageOffset > 0) {
setPageOffset(pageOffset - step);
}
};
// ------------------------------------
useScroll(refBoxWithScroll, onScroll);
// ------------------------------------
return (
<Container
data-testid="Scroller"
ref={refBoxWithScroll}
$isHorizontal={isHorizontal}
>
{children}
<ScrollIndicator
pageOffset={pageOffset}
pageSize={pageSize}
itemCount={itemCount}
isHorizontal={isHorizontal}
/>
</Container>
);
};
export default Scroller;
FILE 3 - ScrollIndicator.tsx
This React component displays an orange-brown bar inside a gray bar (vertical or horizontal), showing the scroll progress. At this moment it is passive, and does not react to any mouse drag events. Many different html/css ways are possible to implement this visual, but I have chosen the one here, as i've found it simple, safe and it works. One useful trick here is to use "%" values, so we don't have to calculate any "px" values.
import styled from "styled-components";
type ContainerProps = {
$isHorizontal: boolean;
};
const Container = styled.div<ContainerProps>`
background-color: var(--color-gray-lighter);
border-radius: 3px;
display: flex;
flex-direction: ${({ $isHorizontal }) => ($isHorizontal ? "row" : "column")};
`;
type BarProps = {
$isHorizontal: boolean;
$length: number;
$isColoured?: boolean;
};
const Bar = styled.div<BarProps>`
border-radius: 3px;
${({ $isHorizontal }) => ($isHorizontal ? "height" : "width")}: 10px;
${({ $isHorizontal, $length }) =>
($isHorizontal ? "width" : "height") + `: ${$length}%`};
${({ $isColoured }) =>
$isColoured && "background-color: var(--color-secondary);"}
`;
// ------------------------------------
type Props = {
pageOffset: number;
pageSize: number;
itemCount: number;
isHorizontal: boolean;
};
const ScrollIndicator = ({
pageOffset,
pageSize,
itemCount,
isHorizontal,
}: Props) => {
if (itemCount <= pageSize) return null; // 1 page, no scroll
// All length values are percentages:
const bar1Length = (pageOffset / itemCount) * 100;
const minLength = 1;
const bar2Length = Math.max(minLength, (pageSize / itemCount) * 100);
return (
<Container data-testid="ScrollIndicator" $isHorizontal={isHorizontal}>
<Bar $isHorizontal={isHorizontal} $length={bar1Length} />
<Bar $isHorizontal={isHorizontal} $length={bar2Length} $isColoured />
</Container>
);
};
export default ScrollIndicator;
FILE 4 - DemoComponent.tsx
In this demo component, we wrap a table component inside the Scroller. When the cursor is inside the Scroller, moving the mouse wheel will call the given onScroll function. The onScroll function will increment or decrement an "offset" state variable. The table displays 10 transaction rows (for the given transaction indexes), starting from this "offset", so the table will be scrolling.
import { useState } from "react";
import { Scroller, SelectedFieldTable } from "../components";
const pageSize = 10;
// ------------------------------------
type Props = {
transactionIndexes: number[];
};
const DemoComponent = ({ transactionIndexes }: Props) => {
const [offset, setOffset] = useState(0);
// ------------------------------------
const transactionIndexesSlice = transactionIndexes.slice(
offset,
offset + pageSize
);
// ------------------------------------
return (
<Scroller
pageOffset={offset}
pageSize={pageSize}
itemCount={transactionIndexes.length}
step={1}
setPageOffset={setOffset}
>
<SelectedFieldTable transactionIndexes={transactionIndexesSlice} />
</Scroller>
);
};
export default DemoComponent;
- By the way, I also use this same ScrollIndicator component inside a Paging component somewhere else, to indicate the paging progress. The Paging component consists of: a horizontal ScrollIndicator, 4 buttons, and the page index & count. Paging code and visuals as follows:
<div data-testid="Paging">
<ScrollIndicator
pageOffset={pageIndex * pageSize}
pageSize={pageSize}
itemCount={itemCount}
isHorizontal
/>
...
Corrections/suggestions are welcome.
Top comments (0)