LogoLaviecn

Horizontal Scroll Menu

Demo

Installation

Shadcn CLI

npx shadcn@latest add https://laviecn.vercel.app/r/horizontal-scroll-menu.json
pnpm dlx shadcn@latest add https://laviecn.vercel.app/r/horizontal-scroll-menu.json
yarn dlx shadcn@latest add https://laviecn.vercel.app/r/horizontal-scroll-menu.json
bun x shadcn@latest add https://laviecn.vercel.app/r/horizontal-scroll-menu.json

Manual

Copy and paste the following code into your project.

horizontal-scroll-menu.tsx
"use client"

import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

const SCROLL_AMOUNT = 200
const SCROLL_MULTIPLIER = 1.5

interface MenuItem {
  id: string
  name: string
}

interface HorizontalScrollMenuProps {
  menu: MenuItem[]
  selected?: string
  onFilterChange?: (filter: string) => void
  showScrollButton?: boolean
}

interface ScrollState {
  canScrollLeft: boolean
  canScrollRight: boolean
}

const useHorizontalScroll = (scrollAmount = SCROLL_AMOUNT) => {
  const scrollContainerRef = useRef<HTMLDivElement>(null)
  const [scrollState, setScrollState] = useState<ScrollState>({
    canScrollLeft: false,
    canScrollRight: false,
  })

  const checkScroll = useCallback(() => {
    const container = scrollContainerRef.current
    if (!container) return

    const { scrollLeft, scrollWidth, clientWidth } = container
    setScrollState({
      canScrollLeft: scrollLeft > 0,
      canScrollRight: scrollLeft < scrollWidth - clientWidth - 1,
    })
  }, [])

  const scroll = useCallback(
    (direction: "left" | "right") => {
      const container = scrollContainerRef.current
      if (!container) return

      const newScrollLeft =
        direction === "left"
          ? container.scrollLeft - scrollAmount
          : container.scrollLeft + scrollAmount

      container.scrollTo({
        left: newScrollLeft,
        behavior: "smooth",
      })
    },
    [scrollAmount]
  )

  const handleMouseDown = useCallback((e: React.MouseEvent) => {
    const container = scrollContainerRef.current
    if (!container) return

    const startX = e.pageX - container.offsetLeft
    const scrollLeft = container.scrollLeft

    const handleMouseMove = (e: MouseEvent) => {
      const x = e.pageX - container.offsetLeft
      const walk = (x - startX) * SCROLL_MULTIPLIER
      container.scrollLeft = scrollLeft - walk
    }

    const handleMouseUp = () => {
      document.removeEventListener("mousemove", handleMouseMove)
      document.removeEventListener("mouseup", handleMouseUp)
    }

    document.addEventListener("mousemove", handleMouseMove)
    document.addEventListener("mouseup", handleMouseUp)
  }, [])

  useEffect(() => {
    const container = scrollContainerRef.current
    if (!container) return

    const handleScroll = () => checkScroll()
    const handleResize = () => checkScroll()

    container.addEventListener("scroll", handleScroll)
    window.addEventListener("resize", handleResize)

    checkScroll()

    return () => {
      container.removeEventListener("scroll", handleScroll)
      window.removeEventListener("resize", handleResize)
    }
  }, [checkScroll])

  return {
    scrollContainerRef,
    scrollState,
    scroll,
    handleMouseDown,
  }
}

export function HorizontalScrollMenu({
  menu,
  selected,
  onFilterChange,
  showScrollButton = false,
}: HorizontalScrollMenuProps) {
  const [selectedFilter, setSelectedFilter] = useState(selected)
  const { scrollContainerRef, scrollState, scroll, handleMouseDown } =
    useHorizontalScroll()

  const handleFilterChange = useCallback(
    (filter: string) => {
      setSelectedFilter(filter)
      onFilterChange?.(filter)
    },
    [onFilterChange]
  )

  const buttonVariants = useMemo(
    () => ({
      selected: "default",
      unselected:
        "bg-secondary text-secondary-foreground hover:bg-secondary/50",
    }),
    []
  )

  return (
    <div className="relative w-full">
      <div className="relative container flex h-10 items-center justify-center px-3">
        {showScrollButton && (
          <Button
            variant="secondary"
            size="icon"
            className={cn(
              "bg-background hover:bg-background absolute left-0 z-10 h-8 w-8 rounded-full",
              !scrollState.canScrollLeft && "hidden"
            )}
            onClick={() => scroll("left")}
          >
            <ChevronLeft className="size-4" />
          </Button>
        )}

        <div
          ref={scrollContainerRef}
          className="scrollbar-none w-full overflow-x-auto"
          style={{
            scrollbarWidth: "none",
            msOverflowStyle: "none",
          }}
          onMouseDown={handleMouseDown}
        >
          <div className="flex gap-2">
            {menu.map((item) => (
              <Button
                key={item.id}
                variant={selectedFilter === item.id ? "default" : "ghost"}
                className={cn(
                  "h-8 text-sm font-medium whitespace-nowrap",
                  selectedFilter === item.id
                    ? buttonVariants.selected
                    : buttonVariants.unselected
                )}
                onClick={() => handleFilterChange(item.id)}
              >
                {item.name}
              </Button>
            ))}
          </div>
        </div>

        {showScrollButton && (
          <Button
            variant="secondary"
            size="icon"
            className={cn(
              "bg-background hover:bg-background absolute right-0 z-10 h-8 w-8 rounded-full",
              !scrollState.canScrollRight && "hidden"
            )}
            onClick={() => scroll("right")}
          >
            <ChevronRight className="size-4" />
          </Button>
        )}
      </div>
    </div>
  )
}

Update the import paths to match your project setup.

Props

HorizontalScrollMenuProps

PropTypeDefault
menu
MenuItem[]
-
selected?
string
-
onFilterChange?
(filter: string) => void
-
showScrollButton?
boolean
false
PropTypeDefault
id
string
-
name
string
-