Search for a command to run...
Augments native scroll functionality for custom, cross-browser styling.
npx shadcn@latest add @zaidan/scroll-areapnpx shadcn add @zaidan/scroll-areayarn dlx shadcn@latest add @zaidan/scroll-areabunx shadcn@latest add @zaidan/scroll-areaCopy and paste the following code into your project.
1import {2 type Accessor,3 type ComponentProps,4 createContext,5 createSignal,6 type JSX,7 mergeProps,8 onCleanup,9 onMount,10 splitProps,11 useContext,12} from "solid-js";13
14import { cn } from "~/lib/utils";15
16type ScrollAreaContextValue = {17 viewportRef: Accessor<HTMLDivElement | undefined>;18 contentRef: Accessor<HTMLDivElement | undefined>;19 hovered: Accessor<boolean>;20};21
22const ScrollAreaContext = createContext<ScrollAreaContextValue | null>(null);23
24const useScrollArea = () => {25 const context = useContext(ScrollAreaContext);26 if (!context) {27 throw new Error("useScrollArea must be used within a <ScrollArea />");28 }29 return context;30};31
32type ScrollAreaProps = ComponentProps<"div"> & {33 children?: JSX.Element;34};35
36const ScrollArea = (props: ScrollAreaProps) => {37 const [local, others] = splitProps(props, ["class", "children", "onMouseEnter", "onMouseLeave"]);38
39 let viewportRef: HTMLDivElement | undefined;40 const [hovered, setHovered] = createSignal(false);41
42 return (43 <ScrollAreaContext.Provider44 value={{45 viewportRef: () => viewportRef,46 contentRef: () => viewportRef,47 hovered,48 }}49 >50 {/* biome-ignore lint/a11y/noStaticElementInteractions: <hover tracking is a passive UI affordance — no keyboard equivalent needed since the inner viewport remains keyboard-scrollable> */}51 <div52 class={cn("relative overflow-clip", local.class)}53 data-slot="scroll-area"54 onMouseEnter={(e) => {55 setHovered(true);56 if (typeof local.onMouseEnter === "function") local.onMouseEnter(e);57 }}58 onMouseLeave={(e) => {59 setHovered(false);60 if (typeof local.onMouseLeave === "function") local.onMouseLeave(e);61 }}62 {...others}63 >64 <div65 class="size-full overflow-auto rounded-[inherit] outline-none transition-[color,box-shadow] [-ms-overflow-style:none] [scrollbar-width:none] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-webkit-scrollbar]:hidden"66 data-slot="scroll-area-viewport"67 ref={viewportRef}68 >69 {local.children}70 </div>71 <ScrollBar />72 <div data-slot="scroll-area-corner" />73 </div>74 </ScrollAreaContext.Provider>75 );76};77
78type ScrollBarProps = ComponentProps<"div"> & {79 orientation?: "vertical" | "horizontal";80};81
82const ScrollBar = (rawProps: ScrollBarProps) => {83 const props = mergeProps({ orientation: "vertical" as const }, rawProps);84 const [local, others] = splitProps(props, ["class", "orientation"]);85
86 const context = useScrollArea();87 const [thumbSize, setThumbSize] = createSignal(0);88 const [thumbPosition, setThumbPosition] = createSignal(0);89 const [isDragging, setIsDragging] = createSignal(false);90 const [dragOffset, setDragOffset] = createSignal(0);91 const [visible, setVisible] = createSignal(false);92
93 let scrollbarRef: HTMLDivElement | undefined;94 let thumbRef: HTMLDivElement | undefined;95
96 const isVertical = () => local.orientation === "vertical";97
98 const updateScrollbar = () => {99 const viewport = context.viewportRef();100 if (!viewport) return;101
102 if (isVertical()) {103 const ratio = viewport.clientHeight / viewport.scrollHeight;104 const size = Math.max(ratio * 100, 10);105 setThumbSize(size);106 setVisible(ratio < 1);107
108 const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;109 const scrollRatio = maxScrollTop > 0 ? viewport.scrollTop / maxScrollTop : 0;110 const maxThumbPosition = 100 - size;111 setThumbPosition(Math.min(scrollRatio * maxThumbPosition, maxThumbPosition));112 } else {113 const ratio = viewport.clientWidth / viewport.scrollWidth;114 const size = Math.max(ratio * 100, 10);115 setThumbSize(size);116 setVisible(ratio < 1);117
118 const maxScrollLeft = viewport.scrollWidth - viewport.clientWidth;119 const scrollRatio = maxScrollLeft > 0 ? viewport.scrollLeft / maxScrollLeft : 0;120 const maxThumbPosition = 100 - size;121 setThumbPosition(Math.min(scrollRatio * maxThumbPosition, maxThumbPosition));122 }123 };124
125 const handleScroll = () => {126 if (!isDragging()) {127 updateScrollbar();128 }129 };130
131 const handleThumbMouseDown = (e: MouseEvent) => {132 e.preventDefault();133 e.stopPropagation();134 const viewport = context.viewportRef();135 if (!viewport || !scrollbarRef || !thumbRef) return;136
137 setIsDragging(true);138
139 const thumbRect = thumbRef.getBoundingClientRect();140 if (isVertical()) {141 setDragOffset(e.clientY - thumbRect.top);142 } else {143 setDragOffset(e.clientX - thumbRect.left);144 }145 };146
147 const handleMouseMove = (e: MouseEvent) => {148 if (!isDragging()) return;149 e.preventDefault();150
151 const viewport = context.viewportRef();152 if (!viewport || !scrollbarRef) return;153
154 if (isVertical()) {155 const scrollbarRect = scrollbarRef.getBoundingClientRect();156 const scrollbarHeight = scrollbarRect.height;157 const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;158
159 if (maxScrollTop <= 0) return;160
161 const mousePositionInTrack = e.clientY - scrollbarRect.top - dragOffset();162
163 const ratio = viewport.clientHeight / viewport.scrollHeight;164 const computedThumbSize = Math.max(ratio * 100, 10);165 const maxThumbPosition = 100 - computedThumbSize;166
167 const thumbPositionPercent = Math.max(168 0,169 Math.min((mousePositionInTrack / scrollbarHeight) * 100, maxThumbPosition),170 );171
172 const scrollRatio = maxThumbPosition > 0 ? thumbPositionPercent / maxThumbPosition : 0;173 const newScrollTop = scrollRatio * maxScrollTop;174
175 viewport.scrollTop = newScrollTop;176 setThumbPosition(thumbPositionPercent);177 } else {178 const scrollbarRect = scrollbarRef.getBoundingClientRect();179 const scrollbarWidth = scrollbarRect.width;180 const maxScrollLeft = viewport.scrollWidth - viewport.clientWidth;181
182 if (maxScrollLeft <= 0) return;183
184 const mousePositionInTrack = e.clientX - scrollbarRect.left - dragOffset();185
186 const ratio = viewport.clientWidth / viewport.scrollWidth;187 const computedThumbSize = Math.max(ratio * 100, 10);188 const maxThumbPosition = 100 - computedThumbSize;189
190 const thumbPositionPercent = Math.max(191 0,192 Math.min((mousePositionInTrack / scrollbarWidth) * 100, maxThumbPosition),193 );194
195 const scrollRatio = maxThumbPosition > 0 ? thumbPositionPercent / maxThumbPosition : 0;196 const newScrollLeft = scrollRatio * maxScrollLeft;197
198 viewport.scrollLeft = newScrollLeft;199 setThumbPosition(thumbPositionPercent);200 }201 };202
203 const handleMouseUp = () => {204 setIsDragging(false);205 };206
207 const handleTrackClick = (e: MouseEvent) => {208 if (!scrollbarRef || !thumbRef) return;209 const viewport = context.viewportRef();210 if (!viewport) return;211
212 const rect = scrollbarRef.getBoundingClientRect();213 const thumbRect = thumbRef.getBoundingClientRect();214
215 if (isVertical()) {216 const clickY = e.clientY - rect.top;217 const thumbY = thumbRect.top - rect.top;218 const thumbHeight = thumbRect.height;219
220 if (clickY < thumbY) {221 viewport.scrollTop -= viewport.clientHeight;222 } else if (clickY > thumbY + thumbHeight) {223 viewport.scrollTop += viewport.clientHeight;224 }225 } else {226 const clickX = e.clientX - rect.left;227 const thumbX = thumbRect.left - rect.left;228 const thumbWidth = thumbRect.width;229
230 if (clickX < thumbX) {231 viewport.scrollLeft -= viewport.clientWidth;232 } else if (clickX > thumbX + thumbWidth) {233 viewport.scrollLeft += viewport.clientWidth;234 }235 }236 };237
238 onMount(() => {239 const viewport = context.viewportRef();240 if (!viewport) return;241
242 updateScrollbar();243
244 viewport.addEventListener("scroll", handleScroll);245
246 const resizeObserver = new ResizeObserver(() => {247 updateScrollbar();248 });249
250 const content = context.contentRef();251 if (content) {252 resizeObserver.observe(content);253 }254 resizeObserver.observe(viewport);255
256 document.addEventListener("mousemove", handleMouseMove);257 document.addEventListener("mouseup", handleMouseUp);258
259 onCleanup(() => {260 viewport.removeEventListener("scroll", handleScroll);261 resizeObserver.disconnect();262 document.removeEventListener("mousemove", handleMouseMove);263 document.removeEventListener("mouseup", handleMouseUp);264 });265 });266
267 const shown = () => visible() && (context.hovered() || isDragging());268
269 return (270 // biome-ignore lint/a11y/noStaticElementInteractions: <custom scrollbar — keyboard scroll is handled via the native viewport>271 // biome-ignore lint/a11y/useKeyWithClickEvents: <track click is a pointer-only convenience; keyboard users scroll via viewport>272 <div273 class={cn(274 "absolute z-scroll-area-scrollbar flex touch-none select-none p-px transition-opacity duration-150",275 {276 "top-0 right-0": isVertical(),277 "bottom-0 left-0 w-full": !isVertical(),278 "pointer-events-none opacity-0": !shown(),279 },280 local.class,281 )}282 data-orientation={local.orientation}283 data-horizontal={!isVertical()}284 data-vertical={isVertical()}285 data-slot="scroll-area-scrollbar"286 onClick={handleTrackClick}287 ref={scrollbarRef}288 {...others}289 >290 {/* biome-ignore lint/a11y/noStaticElementInteractions: <custom scrollbar thumb — keyboard scroll is handled via the native viewport> */}291 <div292 class="relative z-scroll-area-thumb flex-1 cursor-grab bg-border active:cursor-grabbing"293 data-slot="scroll-area-thumb"294 onMouseDown={handleThumbMouseDown}295 ref={thumbRef}296 style={{297 ...(isVertical()298 ? {299 position: "absolute",300 top:301 thumbPosition() === 0302 ? `calc(${thumbPosition()}% + 1px)`303 : `calc(${thumbPosition()}% - 1px)`,304 height: `${thumbSize()}%`,305 left: "1px",306 right: "1px",307 }308 : {309 position: "absolute",310 left:311 thumbPosition() === 0312 ? `calc(${thumbPosition()}% + 1px)`313 : `calc(${thumbPosition()}% - 1px)`,314 width: `${thumbSize()}%`,315 top: "1px",316 bottom: "1px",317 }),318 }}319 />320 </div>321 );322};323
324export { ScrollArea, ScrollBar };Here are the source code of all the examples from the preview page:
import { ScrollArea } from "~/components/ui/scroll-area";import { Separator } from "~/components/ui/separator";function ScrollAreaVertical() { return ( <Example title="Vertical"> <ScrollArea class="mx-auto h-72 w-48 rounded-md style-luma:rounded-2xl border"> <div class="p-4"> <h4 class="mb-4 font-medium text-sm leading-none">Tags</h4> <For each={tags}> {(tag) => ( <> <div class="text-sm">{tag}</div> <Separator class="my-2" /> </> )} </For> </div> </ScrollArea> </Example> );}import { ScrollArea, ScrollBar } from "~/components/ui/scroll-area";function ScrollAreaHorizontal() { return ( <Example title="Horizontal"> <ScrollArea class="mx-auto w-full max-w-96 rounded-md style-luma:rounded-2xl border p-4"> <div class="flex gap-4"> <For each={artworks}> {(artwork) => ( <figure class="shrink-0"> <div class="overflow-hidden rounded-md"> <img src={artwork.art} alt={`Photo by ${artwork.artist}`} class="aspect-3/4 h-fit w-fit object-cover" width={300} height={400} /> </div> <figcaption class="pt-2 text-muted-foreground text-xs"> Photo by <span class="font-semibold text-foreground">{artwork.artist}</span> </figcaption> </figure> )} </For> </div> <ScrollBar orientation="horizontal" /> </ScrollArea> </Example> );}