Components
Card 1
This is a card description.Card 2
This is a card description.Card 3
This is a card description.Card 4
This is a card description.Card 5
This is a card description.Card 6
This is a card description.Card 7
This is a card description.Card 8
This is a card description.Card 9
This is a card description.Card 10
This is a card description.Card 11
This is a card description.Card 12
This is a card description.Card 13
This is a card description.Card 14
This is a card description.Card 15
This is a card description.Card 16
This is a card description.Card 17
This is a card description.Card 18
This is a card description.Card 19
This is a card description.Card 20
This is a card description.Card 21
This is a card description.Card 22
This is a card description.Card 23
This is a card description.Card 24
This is a card description.Card 25
This is a card description.Card 26
This is a card description.Card 27
This is a card description.Card 28
This is a card description.Card 29
This is a card description.Card 30
This is a card description.Card 31
This is a card description.Card 32
This is a card description.Card 33
This is a card description.Card 34
This is a card description.Card 35
This is a card description.Card 36
This is a card description.Card 37
This is a card description.Card 38
This is a card description.Card 39
This is a card description.Card 40
This is a card description.Card 41
This is a card description.Card 42
This is a card description.Card 43
This is a card description.Card 44
This is a card description.Card 45
This is a card description.Card 46
This is a card description.Card 47
This is a card description.Card 48
This is a card description.Card 49
This is a card description.Card 50
This is a card description.Card 51
This is a card description.Card 52
This is a card description.Card 53
This is a card description.Card 54
This is a card description.Card 55
This is a card description.Card 56
This is a card description.Card 57
This is a card description.Card 58
This is a card description.Card 59
This is a card description.Card 60
This is a card description.Card 61
This is a card description.Card 62
This is a card description.Card 63
This is a card description.Card 64
This is a card description.Card 65
This is a card description.Card 66
This is a card description.Card 67
This is a card description.Card 68
This is a card description.Card 69
This is a card description.Card 70
This is a card description.Card 71
This is a card description.Card 72
This is a card description.Card 73
This is a card description.Card 74
This is a card description.Card 75
This is a card description.Card 76
This is a card description.Card 77
This is a card description.Card 78
This is a card description.Card 79
This is a card description.Card 80
This is a card description.Card 81
This is a card description.Card 82
This is a card description.Card 83
This is a card description.Card 84
This is a card description.Card 85
This is a card description.Card 86
This is a card description.Card 87
This is a card description.Card 88
This is a card description.Card 89
This is a card description.Card 90
This is a card description.Card 91
This is a card description.Card 92
This is a card description.Card 93
This is a card description.Card 94
This is a card description.Card 95
This is a card description.Card 96
This is a card description.Card 97
This is a card description.Card 98
This is a card description.Card 99
This is a card description.Card 100
This is a card description.import { Scroller } from "@/components/ui/scroller";
export function ScrollerDemo() {
return (
<Scroller className="flex h-80 w-full flex-col gap-2.5 p-4">
{Array.from({ length: 100 }).map((_, index) => (
<div
key={index}
className="flex h-40 flex-col rounded-md bg-accent p-4"
>
<div className="font-medium text-lg">Card {index + 1}</div>
<span className="text-muted-foreground text-sm">
This is a card description.
</span>
</div>
))}
</Scroller>
);
}
Installation
CLI
npx shadcn@latest add "https://diceui.com/r/scroller"
pnpm dlx shadcn@latest add "https://diceui.com/r/scroller"
yarn dlx shadcn@latest add "https://diceui.com/r/scroller"
bun x shadcn@latest add "https://diceui.com/r/scroller"
Manual
Install the following dependencies:
npm install @radix-ui/react-slot
pnpm add @radix-ui/react-slot
yarn add @radix-ui/react-slot
bun add @radix-ui/react-slot
Copy the refs composition utilities into your lib/compose-refs.ts
file.
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// eslint-disable-next-line react-hooks/exhaustive-deps
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };
Copy and paste the following code into your project.
"use client";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
} from "lucide-react";
import * as React from "react";
const DATA_TOP_SCROLL = "data-top-scroll";
const DATA_BOTTOM_SCROLL = "data-bottom-scroll";
const DATA_LEFT_SCROLL = "data-left-scroll";
const DATA_RIGHT_SCROLL = "data-right-scroll";
const DATA_TOP_BOTTOM_SCROLL = "data-top-bottom-scroll";
const DATA_LEFT_RIGHT_SCROLL = "data-left-right-scroll";
const scrollerVariants = cva("", {
variants: {
orientation: {
vertical: [
"overflow-y-auto",
"data-[top-scroll=true]:[mask-image:linear-gradient(0deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]",
"data-[bottom-scroll=true]:[mask-image:linear-gradient(180deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]",
"data-[top-bottom-scroll=true]:[mask-image:linear-gradient(#000,#000,transparent_0,#000_var(--scroll-shadow-size),#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]",
],
horizontal: [
"overflow-x-auto",
"data-[left-scroll=true]:[mask-image:linear-gradient(270deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]",
"data-[right-scroll=true]:[mask-image:linear-gradient(90deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]",
"data-[left-right-scroll=true]:[mask-image:linear-gradient(to_right,#000,#000,transparent_0,#000_var(--scroll-shadow-size),#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]",
],
},
hideScrollbar: {
true: "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
false: "",
},
},
defaultVariants: {
orientation: "vertical",
hideScrollbar: false,
},
});
type ScrollDirection = "up" | "down" | "left" | "right";
type ScrollVisibility = {
[key in ScrollDirection]: boolean;
};
interface ScrollerProps
extends VariantProps<typeof scrollerVariants>,
React.ComponentPropsWithoutRef<"div"> {
size?: number;
offset?: number;
asChild?: boolean;
withNavigation?: boolean;
scrollStep?: number;
scrollTriggerMode?: "press" | "hover" | "click";
}
const Scroller = React.forwardRef<HTMLDivElement, ScrollerProps>(
(props, forwardedRef) => {
const {
orientation = "vertical",
hideScrollbar,
className,
size = 40,
offset = 0,
scrollStep = 40,
style,
asChild,
withNavigation = false,
scrollTriggerMode = "press",
...scrollerProps
} = props;
const containerRef = React.useRef<HTMLDivElement | null>(null);
const composedRef = useComposedRefs(forwardedRef, containerRef);
const [scrollVisibility, setScrollVisibility] =
React.useState<ScrollVisibility>({
up: false,
down: false,
left: false,
right: false,
});
const onScrollBy = React.useCallback(
(direction: ScrollDirection) => {
const container = containerRef.current;
if (!container) return;
const scrollMap: Record<ScrollDirection, () => void> = {
up: () => (container.scrollTop -= scrollStep),
down: () => (container.scrollTop += scrollStep),
left: () => (container.scrollLeft -= scrollStep),
right: () => (container.scrollLeft += scrollStep),
};
scrollMap[direction]();
},
[scrollStep],
);
const scrollHandlers = React.useMemo(
() => ({
up: () => onScrollBy("up"),
down: () => onScrollBy("down"),
left: () => onScrollBy("left"),
right: () => onScrollBy("right"),
}),
[onScrollBy],
);
React.useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
function onScroll() {
if (!container) return;
const isVertical = orientation === "vertical";
if (isVertical) {
const scrollTop = container.scrollTop;
const clientHeight = container.clientHeight;
const scrollHeight = container.scrollHeight;
if (withNavigation) {
setScrollVisibility((prev) => {
const newUp = scrollTop > offset;
const newDown = scrollTop + clientHeight < scrollHeight;
if (prev.up !== newUp || prev.down !== newDown) {
return {
...prev,
up: newUp,
down: newDown,
};
}
return prev;
});
}
const hasTopScroll = scrollTop > offset;
const hasBottomScroll =
scrollTop + clientHeight + offset < scrollHeight;
const isVerticallyScrollable = scrollHeight > clientHeight;
if (hasTopScroll && hasBottomScroll && isVerticallyScrollable) {
container.setAttribute(DATA_TOP_BOTTOM_SCROLL, "true");
container.removeAttribute(DATA_TOP_SCROLL);
container.removeAttribute(DATA_BOTTOM_SCROLL);
} else {
container.removeAttribute(DATA_TOP_BOTTOM_SCROLL);
if (hasTopScroll) container.setAttribute(DATA_TOP_SCROLL, "true");
else container.removeAttribute(DATA_TOP_SCROLL);
if (hasBottomScroll && isVerticallyScrollable)
container.setAttribute(DATA_BOTTOM_SCROLL, "true");
else container.removeAttribute(DATA_BOTTOM_SCROLL);
}
}
const scrollLeft = container.scrollLeft;
const clientWidth = container.clientWidth;
const scrollWidth = container.scrollWidth;
if (withNavigation) {
setScrollVisibility((prev) => {
const newLeft = scrollLeft > offset;
const newRight = scrollLeft + clientWidth < scrollWidth;
if (prev.left !== newLeft || prev.right !== newRight) {
return {
...prev,
left: newLeft,
right: newRight,
};
}
return prev;
});
}
const hasLeftScroll = scrollLeft > offset;
const hasRightScroll = scrollLeft + clientWidth + offset < scrollWidth;
const isHorizontallyScrollable = scrollWidth > clientWidth;
if (hasLeftScroll && hasRightScroll && isHorizontallyScrollable) {
container.setAttribute(DATA_LEFT_RIGHT_SCROLL, "true");
container.removeAttribute(DATA_LEFT_SCROLL);
container.removeAttribute(DATA_RIGHT_SCROLL);
} else {
container.removeAttribute(DATA_LEFT_RIGHT_SCROLL);
if (hasLeftScroll) container.setAttribute(DATA_LEFT_SCROLL, "true");
else container.removeAttribute(DATA_LEFT_SCROLL);
if (hasRightScroll && isHorizontallyScrollable)
container.setAttribute(DATA_RIGHT_SCROLL, "true");
else container.removeAttribute(DATA_RIGHT_SCROLL);
}
}
onScroll();
container.addEventListener("scroll", onScroll);
window.addEventListener("resize", onScroll);
return () => {
container.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, [orientation, offset, withNavigation]);
const composedStyle = React.useMemo<React.CSSProperties>(
() => ({
"--scroll-shadow-size": `${size}px`,
...style,
}),
[size, style],
);
const activeDirections = React.useMemo<ScrollDirection[]>(() => {
if (!withNavigation) return [];
return orientation === "vertical" ? ["up", "down"] : ["left", "right"];
}, [orientation, withNavigation]);
const ScrollerPrimitive = asChild ? Slot : "div";
const ScrollerImpl = (
<ScrollerPrimitive
data-slot="scroller"
{...scrollerProps}
ref={composedRef}
style={composedStyle}
className={cn(
scrollerVariants({ orientation, hideScrollbar, className }),
)}
/>
);
const navigationButtons = React.useMemo(() => {
if (!withNavigation) return null;
return activeDirections
.filter((direction) => scrollVisibility[direction])
.map((direction) => (
<ScrollButton
key={direction}
data-slot="scroll-button"
direction={direction}
onClick={scrollHandlers[direction]}
triggerMode={scrollTriggerMode}
/>
));
}, [
activeDirections,
scrollVisibility,
scrollHandlers,
scrollTriggerMode,
withNavigation,
]);
if (withNavigation) {
return (
<div className="relative w-full">
{navigationButtons}
{ScrollerImpl}
</div>
);
}
return ScrollerImpl;
},
);
Scroller.displayName = "Scroller";
const scrollButtonVariants = cva(
"absolute z-10 transition-opacity [&>svg]:size-4 [&>svg]:opacity-80 hover:[&>svg]:opacity-100",
{
variants: {
direction: {
up: "-translate-x-1/2 top-2 left-1/2",
down: "-translate-x-1/2 bottom-2 left-1/2",
left: "-translate-y-1/2 top-1/2 left-2",
right: "-translate-y-1/2 top-1/2 right-2",
},
},
defaultVariants: {
direction: "up",
},
},
);
const directionToIcon: Record<ScrollDirection, React.ElementType> = {
up: ChevronUp,
down: ChevronDown,
left: ChevronLeft,
right: ChevronRight,
} as const;
interface ScrollButtonProps extends React.ComponentPropsWithoutRef<"button"> {
direction: ScrollDirection;
triggerMode?: "press" | "hover" | "click";
}
const ScrollButton = React.forwardRef<HTMLButtonElement, ScrollButtonProps>(
(props, forwardedRef) => {
const {
direction,
className,
triggerMode = "press",
onClick,
...buttonProps
} = props;
const [autoScrollTimer, setAutoScrollTimer] = React.useState<number | null>(
null,
);
const onAutoScrollStart = React.useCallback(
(event?: React.MouseEvent<HTMLButtonElement>) => {
if (autoScrollTimer !== null) return;
if (triggerMode === "press") {
const timer = window.setInterval(onClick ?? (() => {}), 50);
setAutoScrollTimer(timer);
} else if (triggerMode === "hover") {
const timer = window.setInterval(() => {
if (event) onClick?.(event);
}, 50);
setAutoScrollTimer(timer);
}
},
[autoScrollTimer, onClick, triggerMode],
);
const onAutoScrollStop = React.useCallback(() => {
if (autoScrollTimer === null) return;
window.clearInterval(autoScrollTimer);
setAutoScrollTimer(null);
}, [autoScrollTimer]);
const eventHandlers = React.useMemo(() => {
const triggerModeHandlers: Record<
NonNullable<ScrollerProps["scrollTriggerMode"]>,
React.ComponentPropsWithoutRef<"button">
> = {
press: {
onPointerDown: onAutoScrollStart,
onPointerUp: onAutoScrollStop,
onPointerLeave: onAutoScrollStop,
onClick: () => {},
},
hover: {
onPointerEnter: onAutoScrollStart,
onPointerLeave: onAutoScrollStop,
onClick: () => {},
},
click: {
onClick,
},
} as const;
return triggerModeHandlers[triggerMode] ?? {};
}, [triggerMode, onAutoScrollStart, onAutoScrollStop, onClick]);
React.useEffect(() => {
return () => onAutoScrollStop();
}, [onAutoScrollStop]);
const Icon = directionToIcon[direction];
return (
<button
type="button"
{...buttonProps}
{...eventHandlers}
ref={forwardedRef}
className={cn(scrollButtonVariants({ direction, className }))}
>
<Icon />
</button>
);
},
);
ScrollButton.displayName = "ScrollButton";
export { Scroller };
Layout
Import the parts, and compose them together.
import { Scroller } from "@/components/ui/scroller"
<Scroller>
{/* Scrollable content */}
</Scroller>
Examples
Horizontal Scroll
Set the orientation
to horizontal
to enable horizontal scrolling.
Card 1
Scroll horizontallyCard 2
Scroll horizontallyCard 3
Scroll horizontallyCard 4
Scroll horizontallyCard 5
Scroll horizontallyCard 6
Scroll horizontallyCard 7
Scroll horizontallyCard 8
Scroll horizontallyCard 9
Scroll horizontallyCard 10
Scroll horizontallyimport { Scroller } from "@/components/ui/scroller";
export function ScrollerHorizontalDemo() {
return (
<Scroller orientation="horizontal" className="w-full p-4" asChild>
<div className="flex items-center gap-2.5">
{Array.from({ length: 10 }).map((_, index) => (
<div
key={index}
className="flex h-32 w-[180px] shrink-0 flex-col items-center justify-center rounded-md bg-accent p-4"
>
<div className="font-medium text-lg">Card {index + 1}</div>
<span className="text-muted-foreground text-sm">
Scroll horizontally
</span>
</div>
))}
</div>
</Scroller>
);
}
Hidden Scrollbar
Set the hideScrollbar
to true
to hide the scrollbar while maintaining scroll functionality.
import { Scroller } from "@/components/ui/scroller";
export function ScrollerHiddenDemo() {
return (
<Scroller className="flex h-80 w-full flex-col gap-2.5 p-4" hideScrollbar>
{Array.from({ length: 20 }).map((_, index) => (
<div
key={index}
className="flex h-40 flex-col rounded-md bg-accent p-4"
>
<div className="font-medium text-lg">Card {index + 1}</div>
<span className="text-muted-foreground text-sm">
Scroll smoothly without visible scrollbars
</span>
</div>
))}
</Scroller>
);
}
Navigation Buttons
Set the withNavigation
to true
to enable navigation buttons.
import { Scroller } from "@/components/ui/scroller";
export function ScrollerNavigationDemo() {
return (
<Scroller
hideScrollbar
withNavigation
scrollTriggerMode="press"
className="flex h-80 w-full flex-col gap-2.5 p-4"
>
{Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="flex flex-col rounded-md bg-accent p-4">
<div className="font-medium text-lg">Card {index + 1}</div>
<span className="text-muted-foreground text-sm">
Use the navigation arrows to scroll
</span>
</div>
))}
</Scroller>
);
}
API Reference
Scroller
The main scrollable container component.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
scrollTriggerMode? | "press" | "hover" | "click" | "press" |
scrollStep? | number | 40 |
withNavigation? | boolean | false |
offset? | number | 0 |
size? | number | 40 |
hideScrollbar? | boolean | false |
orientation? | "vertical" | "horizontal" | "vertical" |