{ "name": "scroller", "type": "registry:ui", "dependencies": [ "radix-ui" ], "files": [ { "path": "ui/scroller.tsx", "type": "registry:ui", "content": "\"use client\";\n\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport {\n ChevronDown,\n ChevronLeft,\n ChevronRight,\n ChevronUp,\n} from \"lucide-react\";\nimport { Slot as SlotPrimitive } from \"radix-ui\";\nimport * as React from \"react\";\nimport { useComposedRefs } from \"@/lib/compose-refs\";\nimport { cn } from \"@/lib/utils\";\n\nconst DATA_TOP_SCROLL = \"data-top-scroll\";\nconst DATA_BOTTOM_SCROLL = \"data-bottom-scroll\";\nconst DATA_LEFT_SCROLL = \"data-left-scroll\";\nconst DATA_RIGHT_SCROLL = \"data-right-scroll\";\nconst DATA_TOP_BOTTOM_SCROLL = \"data-top-bottom-scroll\";\nconst DATA_LEFT_RIGHT_SCROLL = \"data-left-right-scroll\";\n\nconst scrollerVariants = cva(\"\", {\n variants: {\n orientation: {\n vertical: [\n \"overflow-y-auto\",\n \"data-[top-scroll=true]:[mask-image:linear-gradient(0deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]\",\n \"data-[bottom-scroll=true]:[mask-image:linear-gradient(180deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]\",\n \"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)]\",\n ],\n horizontal: [\n \"overflow-x-auto\",\n \"data-[left-scroll=true]:[mask-image:linear-gradient(270deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]\",\n \"data-[right-scroll=true]:[mask-image:linear-gradient(90deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]\",\n \"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)]\",\n ],\n },\n hideScrollbar: {\n true: \"[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n false: \"\",\n },\n },\n defaultVariants: {\n orientation: \"vertical\",\n hideScrollbar: false,\n },\n});\n\ntype ScrollDirection = \"up\" | \"down\" | \"left\" | \"right\";\n\ntype ScrollVisibility = {\n [key in ScrollDirection]: boolean;\n};\n\ninterface ScrollerProps\n extends VariantProps,\n React.ComponentProps<\"div\"> {\n size?: number;\n offset?: number;\n asChild?: boolean;\n withNavigation?: boolean;\n scrollStep?: number;\n scrollTriggerMode?: \"press\" | \"hover\" | \"click\";\n}\n\nfunction Scroller(props: ScrollerProps) {\n const {\n orientation = \"vertical\",\n hideScrollbar,\n className,\n size = 40,\n offset = 0,\n scrollStep = 40,\n style,\n asChild,\n withNavigation = false,\n scrollTriggerMode = \"press\",\n ref,\n ...scrollerProps\n } = props;\n\n const containerRef = React.useRef(null);\n const composedRef = useComposedRefs(ref, containerRef);\n const [scrollVisibility, setScrollVisibility] =\n React.useState({\n up: false,\n down: false,\n left: false,\n right: false,\n });\n\n const onScrollBy = React.useCallback(\n (direction: ScrollDirection) => {\n const container = containerRef.current;\n if (!container) return;\n\n const scrollMap: Record void> = {\n up: () => (container.scrollTop -= scrollStep),\n down: () => (container.scrollTop += scrollStep),\n left: () => (container.scrollLeft -= scrollStep),\n right: () => (container.scrollLeft += scrollStep),\n };\n\n scrollMap[direction]();\n },\n [scrollStep],\n );\n\n const scrollHandlers = React.useMemo(\n () => ({\n up: () => onScrollBy(\"up\"),\n down: () => onScrollBy(\"down\"),\n left: () => onScrollBy(\"left\"),\n right: () => onScrollBy(\"right\"),\n }),\n [onScrollBy],\n );\n\n React.useLayoutEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n\n function onScroll() {\n if (!container) return;\n\n const isVertical = orientation === \"vertical\";\n\n if (isVertical) {\n const scrollTop = container.scrollTop;\n const clientHeight = container.clientHeight;\n const scrollHeight = container.scrollHeight;\n\n if (withNavigation) {\n setScrollVisibility((prev) => {\n const newUp = scrollTop > offset;\n const newDown = scrollTop + clientHeight < scrollHeight;\n\n if (prev.up !== newUp || prev.down !== newDown) {\n return {\n ...prev,\n up: newUp,\n down: newDown,\n };\n }\n return prev;\n });\n }\n\n const hasTopScroll = scrollTop > offset;\n const hasBottomScroll =\n scrollTop + clientHeight + offset < scrollHeight;\n const isVerticallyScrollable = scrollHeight > clientHeight;\n\n if (hasTopScroll && hasBottomScroll && isVerticallyScrollable) {\n container.setAttribute(DATA_TOP_BOTTOM_SCROLL, \"true\");\n container.removeAttribute(DATA_TOP_SCROLL);\n container.removeAttribute(DATA_BOTTOM_SCROLL);\n } else {\n container.removeAttribute(DATA_TOP_BOTTOM_SCROLL);\n if (hasTopScroll) container.setAttribute(DATA_TOP_SCROLL, \"true\");\n else container.removeAttribute(DATA_TOP_SCROLL);\n if (hasBottomScroll && isVerticallyScrollable)\n container.setAttribute(DATA_BOTTOM_SCROLL, \"true\");\n else container.removeAttribute(DATA_BOTTOM_SCROLL);\n }\n }\n\n const scrollLeft = container.scrollLeft;\n const clientWidth = container.clientWidth;\n const scrollWidth = container.scrollWidth;\n\n if (withNavigation) {\n setScrollVisibility((prev) => {\n const newLeft = scrollLeft > offset;\n const newRight = scrollLeft + clientWidth < scrollWidth;\n\n if (prev.left !== newLeft || prev.right !== newRight) {\n return {\n ...prev,\n left: newLeft,\n right: newRight,\n };\n }\n return prev;\n });\n }\n\n const hasLeftScroll = scrollLeft > offset;\n const hasRightScroll = scrollLeft + clientWidth + offset < scrollWidth;\n const isHorizontallyScrollable = scrollWidth > clientWidth;\n\n if (hasLeftScroll && hasRightScroll && isHorizontallyScrollable) {\n container.setAttribute(DATA_LEFT_RIGHT_SCROLL, \"true\");\n container.removeAttribute(DATA_LEFT_SCROLL);\n container.removeAttribute(DATA_RIGHT_SCROLL);\n } else {\n container.removeAttribute(DATA_LEFT_RIGHT_SCROLL);\n if (hasLeftScroll) container.setAttribute(DATA_LEFT_SCROLL, \"true\");\n else container.removeAttribute(DATA_LEFT_SCROLL);\n if (hasRightScroll && isHorizontallyScrollable)\n container.setAttribute(DATA_RIGHT_SCROLL, \"true\");\n else container.removeAttribute(DATA_RIGHT_SCROLL);\n }\n }\n\n onScroll();\n container.addEventListener(\"scroll\", onScroll);\n window.addEventListener(\"resize\", onScroll);\n\n return () => {\n container.removeEventListener(\"scroll\", onScroll);\n window.removeEventListener(\"resize\", onScroll);\n };\n }, [orientation, offset, withNavigation]);\n\n const composedStyle = React.useMemo(\n () => ({\n \"--scroll-shadow-size\": `${size}px`,\n ...style,\n }),\n [size, style],\n );\n\n const activeDirections = React.useMemo(() => {\n if (!withNavigation) return [];\n return orientation === \"vertical\" ? [\"up\", \"down\"] : [\"left\", \"right\"];\n }, [orientation, withNavigation]);\n\n const ScrollerPrimitive = asChild ? SlotPrimitive.Slot : \"div\";\n\n const ScrollerImpl = (\n \n );\n\n const navigationButtons = React.useMemo(() => {\n if (!withNavigation) return null;\n\n return activeDirections\n .filter((direction) => scrollVisibility[direction])\n .map((direction) => (\n \n ));\n }, [\n activeDirections,\n scrollVisibility,\n scrollHandlers,\n scrollTriggerMode,\n withNavigation,\n ]);\n\n if (withNavigation) {\n return (\n
\n {navigationButtons}\n {ScrollerImpl}\n
\n );\n }\n\n return ScrollerImpl;\n}\n\nconst scrollButtonVariants = cva(\n \"absolute z-10 transition-opacity [&>svg]:size-4 [&>svg]:opacity-80 hover:[&>svg]:opacity-100\",\n {\n variants: {\n direction: {\n up: \"top-2 left-1/2 -translate-x-1/2\",\n down: \"bottom-2 left-1/2 -translate-x-1/2\",\n left: \"top-1/2 left-2 -translate-y-1/2\",\n right: \"top-1/2 right-2 -translate-y-1/2\",\n },\n },\n defaultVariants: {\n direction: \"up\",\n },\n },\n);\n\nconst directionToIcon: Record = {\n up: ChevronUp,\n down: ChevronDown,\n left: ChevronLeft,\n right: ChevronRight,\n} as const;\n\ninterface ScrollButtonProps extends React.ComponentProps<\"button\"> {\n direction: ScrollDirection;\n triggerMode?: \"press\" | \"hover\" | \"click\";\n}\n\nfunction ScrollButton(props: ScrollButtonProps) {\n const {\n direction,\n className,\n triggerMode = \"press\",\n onClick,\n ref,\n ...buttonProps\n } = props;\n\n const [autoScrollTimer, setAutoScrollTimer] = React.useState(\n null,\n );\n\n const onAutoScrollStart = React.useCallback(\n (event?: React.MouseEvent) => {\n if (autoScrollTimer !== null) return;\n\n if (triggerMode === \"press\") {\n const timer = window.setInterval(onClick ?? (() => {}), 50);\n setAutoScrollTimer(timer);\n } else if (triggerMode === \"hover\") {\n const timer = window.setInterval(() => {\n if (event) onClick?.(event);\n }, 50);\n setAutoScrollTimer(timer);\n }\n },\n [autoScrollTimer, onClick, triggerMode],\n );\n\n const onAutoScrollStop = React.useCallback(() => {\n if (autoScrollTimer === null) return;\n\n window.clearInterval(autoScrollTimer);\n setAutoScrollTimer(null);\n }, [autoScrollTimer]);\n\n const eventHandlers = React.useMemo(() => {\n const triggerModeHandlers: Record<\n NonNullable,\n React.ComponentProps<\"button\">\n > = {\n press: {\n onPointerDown: onAutoScrollStart,\n onPointerUp: onAutoScrollStop,\n onPointerLeave: onAutoScrollStop,\n onClick: () => {},\n },\n hover: {\n onPointerEnter: onAutoScrollStart,\n onPointerLeave: onAutoScrollStop,\n onClick: () => {},\n },\n click: {\n onClick,\n },\n } as const;\n\n return triggerModeHandlers[triggerMode] ?? {};\n }, [triggerMode, onAutoScrollStart, onAutoScrollStop, onClick]);\n\n React.useEffect(() => {\n return () => onAutoScrollStop();\n }, [onAutoScrollStop]);\n\n const Icon = directionToIcon[direction];\n\n return (\n \n \n \n );\n}\n\nexport { Scroller };\n", "target": "" }, { "path": "lib/compose-refs.ts", "type": "registry:lib", "content": "/**\n * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx\n */\n\nimport * as React from \"react\";\n\ntype PossibleRef = React.Ref | undefined;\n\n/**\n * Set a given ref to a given value\n * This utility takes care of different types of refs: callback refs and RefObject(s)\n */\nfunction setRef(ref: PossibleRef, value: T) {\n if (typeof ref === \"function\") {\n return ref(value);\n }\n\n if (ref !== null && ref !== undefined) {\n ref.current = value;\n }\n}\n\n/**\n * A utility to compose multiple refs together\n * Accepts callback refs and RefObject(s)\n */\nfunction composeRefs(...refs: PossibleRef[]): React.RefCallback {\n return (node) => {\n let hasCleanup = false;\n const cleanups = refs.map((ref) => {\n const cleanup = setRef(ref, node);\n if (!hasCleanup && typeof cleanup === \"function\") {\n hasCleanup = true;\n }\n return cleanup;\n });\n\n // React <19 will log an error to the console if a callback ref returns a\n // value. We don't use ref cleanups internally so this will only happen if a\n // user's ref callback returns a value, which we only expect if they are\n // using the cleanup functionality added in React 19.\n if (hasCleanup) {\n return () => {\n for (let i = 0; i < cleanups.length; i++) {\n const cleanup = cleanups[i];\n if (typeof cleanup === \"function\") {\n cleanup();\n } else {\n setRef(refs[i], null);\n }\n }\n };\n }\n };\n}\n\n/**\n * A custom hook that composes multiple refs\n * Accepts callback refs and RefObject(s)\n */\nfunction useComposedRefs(...refs: PossibleRef[]): React.RefCallback {\n // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values\n return React.useCallback(composeRefs(...refs), refs);\n}\n\nexport { composeRefs, useComposedRefs };\n", "target": "" } ] }