Dice UI
Components

Scroller

A scrollable container with customizable scroll shadows and navigation buttons.

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.

Installation

CLI

npx shadcn@latest add "https://diceui.com/r/scroller"

Manual

Install the following dependencies:

npm install @radix-ui/react-slot

Copy the composition utilities into your lib/composition.ts file.

import * as React from "react";
 
/**
 * A utility to compose multiple event handlers into a single event handler.
 * Call originalEventHandler first, then ourEventHandler unless prevented.
 */
function composeEventHandlers<E>(
  originalEventHandler?: (event: E) => void,
  ourEventHandler?: (event: E) => void,
  { checkForDefaultPrevented = true } = {},
) {
  return function handleEvent(event: E) {
    originalEventHandler?.(event);
 
    if (
      checkForDefaultPrevented === false ||
      !(event as unknown as Event).defaultPrevented
    ) {
      return ourEventHandler?.(event);
    }
  };
}
 
/**
 * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
 */
 
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 { composeEventHandlers, composeRefs, useComposedRefs };

Copy and paste the following code into your project.

"use client";
 
import { useComposedRefs } from "@/lib/composition";
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],
    );
 
    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) => ({
              ...prev,
              up: scrollTop > offset,
              down: scrollTop + clientHeight < scrollHeight,
            }));
          }
 
          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) => ({
            ...prev,
            left: scrollLeft > offset,
            right: scrollLeft + clientWidth < scrollWidth,
          }));
        }
 
        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 = withNavigation
      ? React.useMemo<ScrollDirection[]>(
          () =>
            orientation === "vertical" ? ["up", "down"] : ["left", "right"],
          [orientation],
        )
      : [];
 
    const Comp = asChild ? Slot : "div";
 
    const ScrollerImpl = (
      <Comp
        {...scrollerProps}
        ref={composedRef}
        style={composedStyle}
        className={cn(
          scrollerVariants({ orientation, hideScrollbar, className }),
        )}
      />
    );
 
    if (withNavigation) {
      return (
        <div className="relative w-full">
          {activeDirections.map(
            (direction) =>
              scrollVisibility[direction] && (
                <ScrollButton
                  key={direction}
                  direction={direction}
                  onClick={() => onScrollBy(direction)}
                  triggerMode={scrollTriggerMode}
                />
              ),
          )}
          {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 horizontally
Card 2
Scroll horizontally
Card 3
Scroll horizontally
Card 4
Scroll horizontally
Card 5
Scroll horizontally
Card 6
Scroll horizontally
Card 7
Scroll horizontally
Card 8
Scroll horizontally
Card 9
Scroll horizontally
Card 10
Scroll horizontally

Hidden Scrollbar

Set the hideScrollbar to true to hide the scrollbar while maintaining scroll functionality.

Card 1
Scroll smoothly without visible scrollbars
Card 2
Scroll smoothly without visible scrollbars
Card 3
Scroll smoothly without visible scrollbars
Card 4
Scroll smoothly without visible scrollbars
Card 5
Scroll smoothly without visible scrollbars
Card 6
Scroll smoothly without visible scrollbars
Card 7
Scroll smoothly without visible scrollbars
Card 8
Scroll smoothly without visible scrollbars
Card 9
Scroll smoothly without visible scrollbars
Card 10
Scroll smoothly without visible scrollbars
Card 11
Scroll smoothly without visible scrollbars
Card 12
Scroll smoothly without visible scrollbars
Card 13
Scroll smoothly without visible scrollbars
Card 14
Scroll smoothly without visible scrollbars
Card 15
Scroll smoothly without visible scrollbars
Card 16
Scroll smoothly without visible scrollbars
Card 17
Scroll smoothly without visible scrollbars
Card 18
Scroll smoothly without visible scrollbars
Card 19
Scroll smoothly without visible scrollbars
Card 20
Scroll smoothly without visible scrollbars

Set the withNavigation to true to enable navigation buttons.

Card 1
Use the navigation arrows to scroll
Card 2
Use the navigation arrows to scroll
Card 3
Use the navigation arrows to scroll
Card 4
Use the navigation arrows to scroll
Card 5
Use the navigation arrows to scroll
Card 6
Use the navigation arrows to scroll
Card 7
Use the navigation arrows to scroll
Card 8
Use the navigation arrows to scroll
Card 9
Use the navigation arrows to scroll
Card 10
Use the navigation arrows to scroll

API Reference

Scroller

The main scrollable container component.

PropTypeDefault
orientation
"vertical" | "horizontal"
"vertical"
hideScrollbar
boolean
false
size
number
40
offset
number
0
withNavigation
boolean
false
scrollStep
number
40
scrollTriggerMode
"press" | "hover" | "click"
"press"
asChild
boolean
false

On this page