Inspired by Base UI
Scroll Area Rails Components
Provides consistent, styled scrollbars across all browsers and operating systems while maintaining native scrolling behavior.
Installation
1. Stimulus Controller Setup
Start by adding the following controller to your project:
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="scroll-area"
export default class extends Controller {
static targets = ["viewport", "scrollbar", "thumb", "root"]
static values = {
hideDelay: { type: Number, default: 600 } // The delay in milliseconds before hiding the scrollbars
}
connect() {
this.scrolling = false
this.hovering = false
this.hideTimeout = null
this.isDragging = false
this.updateOverflow()
this.updateAllScrollbars()
// Observe viewport size changes
this.resizeObserver = new ResizeObserver(() => {
this.updateOverflow()
this.updateAllScrollbars()
})
if (this.hasViewportTarget) {
this.resizeObserver.observe(this.viewportTarget)
}
}
disconnect() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
if (this.hideTimeout) {
clearTimeout(this.hideTimeout)
}
}
// Viewport events
onViewportScroll(event) {
this.updateAllScrollbars()
this.updateOverflow()
this.setScrolling(true)
// Clear previous timeout
if (this.hideTimeout) {
clearTimeout(this.hideTimeout)
}
// Set new timeout - only hide if not hovering
this.hideTimeout = setTimeout(() => {
// Keep scrollbar visible if still hovering
if (!this.hovering) {
this.setScrolling(false)
}
}, this.hideDelayValue)
}
// Mouse enter/leave on root
onRootMouseEnter() {
this.setHovering(true)
}
onRootMouseLeave() {
if (!this.isDragging) {
this.setHovering(false)
// Also clear scrolling state when mouse leaves
this.setScrolling(false)
}
}
// Scrollbar track click - jump to position
onScrollbarClick(event) {
// Don't handle if clicking on thumb
if (event.target.dataset.scrollAreaTarget === "thumb") return
const scrollbar = event.currentTarget
const orientation = this.getOrientation(scrollbar)
const isVertical = orientation === "vertical"
const viewport = this.viewportTarget
// Get click position relative to scrollbar
const rect = scrollbar.getBoundingClientRect()
const clickPos = isVertical
? event.clientY - rect.top
: event.clientX - rect.left
const scrollbarSize = isVertical ? scrollbar.offsetHeight : scrollbar.offsetWidth
const viewportSize = isVertical ? viewport.offsetHeight : viewport.offsetWidth
const contentSize = isVertical ? viewport.scrollHeight : viewport.scrollWidth
// Calculate thumb size
const thumbSize = Math.max((viewportSize / contentSize) * scrollbarSize, 20)
// Calculate scroll position (center thumb on click position)
const maxScroll = scrollbarSize - thumbSize
const thumbPosition = Math.max(0, Math.min(clickPos - thumbSize / 2, maxScroll))
const scrollRatio = thumbPosition / maxScroll
// Apply scroll
if (isVertical) {
viewport.scrollTop = scrollRatio * (contentSize - viewportSize)
} else {
viewport.scrollLeft = scrollRatio * (contentSize - viewportSize)
}
}
// Thumb drag events
onThumbMouseDown(event) {
// Ignore right-click
if (event.button !== 0) return
event.preventDefault()
event.stopPropagation() // Prevent scrollbar click from firing
this.isDragging = true
this.setHovering(true)
const thumb = event.currentTarget
const scrollbar = thumb.closest('[data-scroll-area-target="scrollbar"]')
const orientation = this.getOrientation(scrollbar)
const isVertical = orientation === "vertical"
const viewport = this.viewportTarget
// Get initial positions
const startPos = isVertical ? event.clientY : event.clientX
const startScroll = isVertical ? viewport.scrollTop : viewport.scrollLeft
// Calculate scrollbar metrics
const scrollbarSize = isVertical ? scrollbar.offsetHeight : scrollbar.offsetWidth
const viewportSize = isVertical ? viewport.offsetHeight : viewport.offsetWidth
const contentSize = isVertical ? viewport.scrollHeight : viewport.scrollWidth
const thumbSize = (viewportSize / contentSize) * scrollbarSize
const onMouseMove = (e) => {
const currentPos = isVertical ? e.clientY : e.clientX
const delta = currentPos - startPos
const scrollDelta = (delta / scrollbarSize) * contentSize
if (isVertical) {
viewport.scrollTop = startScroll + scrollDelta
} else {
viewport.scrollLeft = startScroll + scrollDelta
}
}
const onMouseUp = (e) => {
this.isDragging = false
// Check if mouse is still over the root element
if (this.hasRootTarget) {
const rect = this.rootTarget.getBoundingClientRect()
const isStillInside = (
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom
)
// Only set hovering to false if mouse left the container
if (!isStillInside) {
this.setHovering(false)
}
}
cleanup()
}
const onContextMenu = (e) => {
// Cancel drag on right-click
this.isDragging = false
this.setHovering(false)
cleanup()
}
const cleanup = () => {
document.removeEventListener("mousemove", onMouseMove)
document.removeEventListener("mouseup", onMouseUp)
document.removeEventListener("contextmenu", onContextMenu)
}
document.addEventListener("mousemove", onMouseMove)
document.addEventListener("mouseup", onMouseUp)
document.addEventListener("contextmenu", onContextMenu)
}
// Update methods
updateOverflow() {
if (!this.hasViewportTarget) return
const viewport = this.viewportTarget
const hasOverflowX = viewport.scrollWidth > viewport.clientWidth
const hasOverflowY = viewport.scrollHeight > viewport.clientHeight
// Update root data attributes
if (this.hasRootTarget) {
this.rootTarget.dataset.hasOverflowX = hasOverflowX
this.rootTarget.dataset.hasOverflowY = hasOverflowY
// Calculate overflow distances
const overflowXStart = viewport.scrollLeft
const overflowXEnd = viewport.scrollWidth - viewport.clientWidth - viewport.scrollLeft
const overflowYStart = viewport.scrollTop
const overflowYEnd = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop
// Add small threshold to handle rounding errors (1px tolerance)
const threshold = 1
// Set data attributes for overflow edges
if (overflowXStart > threshold) {
this.rootTarget.dataset.overflowXStart = ""
} else {
delete this.rootTarget.dataset.overflowXStart
}
if (overflowXEnd > threshold) {
this.rootTarget.dataset.overflowXEnd = ""
} else {
delete this.rootTarget.dataset.overflowXEnd
}
if (overflowYStart > threshold) {
this.rootTarget.dataset.overflowYStart = ""
} else {
delete this.rootTarget.dataset.overflowYStart
}
if (overflowYEnd > threshold) {
this.rootTarget.dataset.overflowYEnd = ""
} else {
delete this.rootTarget.dataset.overflowYEnd
}
// Set CSS variables for overflow distances
this.rootTarget.style.setProperty("--scroll-area-overflow-x-start", `${overflowXStart}px`)
this.rootTarget.style.setProperty("--scroll-area-overflow-x-end", `${overflowXEnd}px`)
this.rootTarget.style.setProperty("--scroll-area-overflow-y-start", `${overflowYStart}px`)
this.rootTarget.style.setProperty("--scroll-area-overflow-y-end", `${overflowYEnd}px`)
}
}
updateAllScrollbars() {
if (!this.hasScrollbarTarget) return
this.scrollbarTargets.forEach(scrollbar => {
this.updateScrollbarPosition(scrollbar)
this.updateScrollbarVisibility(scrollbar)
})
}
updateScrollbarPosition(scrollbar) {
if (!this.hasViewportTarget) return
const viewport = this.viewportTarget
const thumb = this.getThumbForScrollbar(scrollbar)
if (!thumb) return
const orientation = this.getOrientation(scrollbar)
const isVertical = orientation === "vertical"
const scrollRatio = isVertical
? viewport.scrollTop / (viewport.scrollHeight - viewport.clientHeight)
: viewport.scrollLeft / (viewport.scrollWidth - viewport.clientWidth)
const scrollbarSize = isVertical ? scrollbar.offsetHeight : scrollbar.offsetWidth
const viewportSize = isVertical ? viewport.offsetHeight : viewport.offsetWidth
const contentSize = isVertical ? viewport.scrollHeight : viewport.scrollWidth
// Calculate thumb size
const thumbSize = Math.max((viewportSize / contentSize) * scrollbarSize, 20) // Minimum 20px
const maxScroll = scrollbarSize - thumbSize
const thumbPosition = scrollRatio * maxScroll
if (isVertical) {
thumb.style.height = `${thumbSize}px`
thumb.style.transform = `translateY(${thumbPosition}px)`
} else {
thumb.style.width = `${thumbSize}px`
thumb.style.transform = `translateX(${thumbPosition}px)`
}
}
updateScrollbarVisibility(scrollbar) {
if (!this.hasViewportTarget) return
const viewport = this.viewportTarget
const orientation = this.getOrientation(scrollbar)
const isVertical = orientation === "vertical"
const hasOverflow = isVertical
? viewport.scrollHeight > viewport.clientHeight
: viewport.scrollWidth > viewport.clientWidth
scrollbar.dataset.visible = hasOverflow
}
setScrolling(scrolling) {
this.scrolling = scrolling
this.updateScrollbarState()
}
setHovering(hovering) {
this.hovering = hovering
this.updateScrollbarState()
}
updateScrollbarState() {
if (!this.hasScrollbarTarget) return
this.scrollbarTargets.forEach(scrollbar => {
if (this.scrolling) {
scrollbar.dataset.scrolling = ""
} else {
delete scrollbar.dataset.scrolling
}
if (this.hovering) {
scrollbar.dataset.hovering = ""
} else {
delete scrollbar.dataset.hovering
}
})
}
// Helper methods
getOrientation(element) {
return element.dataset.scrollAreaOrientationValue || "vertical"
}
getThumbForScrollbar(scrollbar) {
return scrollbar.querySelector('[data-scroll-area-target="thumb"]')
}
}
2. Custom CSS
Here is the custom CSS I've used to style the scroll areas:
/* Hide scrollbar for scroll area component */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
/* Scroll Area Fade Effects - Register custom properties for animation */
@property --fade-start-opacity {
syntax: '';
initial-value: 1;
inherits: false;
}
@property --fade-end-opacity {
syntax: '';
initial-value: 1;
inherits: false;
}
@property --fade-start-x-opacity {
syntax: '';
initial-value: 1;
inherits: false;
}
@property --fade-end-x-opacity {
syntax: '';
initial-value: 1;
inherits: false;
}
@property --fade-start-y-opacity {
syntax: '';
initial-value: 1;
inherits: false;
}
@property --fade-end-y-opacity {
syntax: '';
initial-value: 1;
inherits: false;
}
/* Scroll Area Fade Effects */
.scroll-fade-x {
--fade-start-opacity: 1;
--fade-end-opacity: 1;
--fade-size: 25px;
mask-image:
linear-gradient(to right,
hsl(0 0% 0% / var(--fade-start-opacity)),
black var(--fade-size),
black calc(100% - var(--fade-size)),
hsl(0 0% 0% / var(--fade-end-opacity))
);
-webkit-mask-image:
linear-gradient(to right,
hsl(0 0% 0% / var(--fade-start-opacity)),
black var(--fade-size),
black calc(100% - var(--fade-size)),
hsl(0 0% 0% / var(--fade-end-opacity))
);
transition: --fade-start-opacity 300ms ease-out, --fade-end-opacity 300ms ease-out;
}
.scroll-fade-y {
--fade-start-opacity: 1;
--fade-end-opacity: 1;
--fade-size: 40px;
mask-image:
linear-gradient(to bottom,
hsl(0 0% 0% / var(--fade-start-opacity)),
black var(--fade-size),
black calc(100% - var(--fade-size)),
hsl(0 0% 0% / var(--fade-end-opacity))
);
-webkit-mask-image:
linear-gradient(to bottom,
hsl(0 0% 0% / var(--fade-start-opacity)),
black var(--fade-size),
black calc(100% - var(--fade-size)),
hsl(0 0% 0% / var(--fade-end-opacity))
);
transition: --fade-start-opacity 300ms ease-out, --fade-end-opacity 300ms ease-out;
}
.scroll-fade-both {
--fade-start-x-opacity: 1;
--fade-end-x-opacity: 1;
--fade-start-y-opacity: 1;
--fade-end-y-opacity: 1;
--fade-size: 40px;
--fade-top-offset: 0px; /* Offset from top to skip header areas */
--fade-left-offset: 0px; /* Offset from left to skip sticky columns */
mask-image:
linear-gradient(to right,
black var(--fade-left-offset),
hsl(0 0% 0% / var(--fade-start-x-opacity)) var(--fade-left-offset),
black calc(var(--fade-left-offset) + var(--fade-size)),
black calc(100% - var(--fade-size)),
hsl(0 0% 0% / var(--fade-end-x-opacity))
),
linear-gradient(to bottom,
black var(--fade-top-offset),
hsl(0 0% 0% / var(--fade-start-y-opacity)) var(--fade-top-offset),
black calc(var(--fade-top-offset) + var(--fade-size)),
black calc(100% - var(--fade-size)),
hsl(0 0% 0% / var(--fade-end-y-opacity))
);
-webkit-mask-image:
linear-gradient(to right,
black var(--fade-left-offset),
hsl(0 0% 0% / var(--fade-start-x-opacity)) var(--fade-left-offset),
black calc(var(--fade-left-offset) + var(--fade-size)),
black calc(100% - var(--fade-size)),
hsl(0 0% 0% / var(--fade-end-x-opacity))
),
linear-gradient(to bottom,
black var(--fade-top-offset),
hsl(0 0% 0% / var(--fade-start-y-opacity)) var(--fade-top-offset),
black calc(var(--fade-top-offset) + var(--fade-size)),
black calc(100% - var(--fade-size)),
hsl(0 0% 0% / var(--fade-end-y-opacity))
);
mask-composite: intersect;
-webkit-mask-composite: source-in;
transition: --fade-start-x-opacity 300ms ease-out, --fade-end-x-opacity 300ms ease-out,
--fade-start-y-opacity 300ms ease-out, --fade-end-y-opacity 300ms ease-out;
}
/* Update fade opacity when scrolled - fade appears when there's overflow */
[data-overflow-x-start] .scroll-fade-x,
[data-overflow-x-start] .scroll-fade-both {
--fade-start-opacity: 0;
--fade-start-x-opacity: 0;
}
[data-overflow-x-end] .scroll-fade-x,
[data-overflow-x-end] .scroll-fade-both {
--fade-end-opacity: 0;
--fade-end-x-opacity: 0;
}
[data-overflow-y-start] .scroll-fade-y,
[data-overflow-y-start] .scroll-fade-both {
--fade-start-opacity: 0;
--fade-start-y-opacity: 0;
}
[data-overflow-y-end] .scroll-fade-y,
[data-overflow-y-end] .scroll-fade-both {
--fade-end-opacity: 0;
--fade-end-y-opacity: 0;
}
Examples
Vertical scroll area
A vertical scroll area with custom styled scrollbar that appears on hover or during scrolling.
<div data-controller="scroll-area" data-scroll-area-target="root" data-action="mouseenter->scroll-area#onRootMouseEnter mouseleave->scroll-area#onRootMouseLeave" class="relative h-80 w-full max-w-md rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-700 dark:bg-neutral-800">
<!-- Viewport -->
<div data-scroll-area-target="viewport" data-action="scroll->scroll-area#onViewportScroll" class="h-full overflow-auto scrollbar-hide scroll-fade-y pr-1">
<div class="flex flex-col gap-4 text-sm leading-relaxed text-neutral-900 dark:text-neutral-100">
<p>
Vernacular architecture is building done outside any academic tradition, and without
professional guidance. It is not a particular architectural movement or style, but
rather a broad category, encompassing a wide range and variety of building types, with
differing methods of construction, from around the world, both historical and extant and
classical and modern.
</p>
<p>
Vernacular architecture constitutes 95% of the world's built environment, as estimated in 1995 by Amos Rapoport, as measured against the small
percentage of new buildings every year designed by architects and built by engineers.
</p>
<p>
This type of architecture usually serves immediate, local needs, is constrained by the
materials available in its particular region and reflects local traditions and cultural
practices. The study of vernacular architecture does not examine formally schooled
architects, but instead that of the design skills and tradition of local builders, who
were rarely given any attribution for the work.
</p>
<p>
More recently, vernacular architecture has been examined by designers and the building industry in an effort to be more energy
conscious with contemporary design and construction—part of a broader interest in
sustainable design.
</p>
<p>
Vernacular architecture can be contrasted against polite architecture which is characterized by stylistic elements of design intentionally incorporated for aesthetic purposes which go beyond a building's functional requirements.
</p>
</div>
</div>
<!-- Vertical Scrollbar -->
<div data-scroll-area-target="scrollbar" data-scroll-area-orientation-value="vertical" data-action="click->scroll-area#onScrollbarClick" class="absolute right-2 top-2 bottom-2 flex w-1.5 justify-center rounded-full bg-black/5 backdrop-blur-sm opacity-0 transition-opacity duration-150 delay-300 pointer-events-none data-[hovering]:opacity-100 data-[hovering]:duration-100 data-[hovering]:delay-0 data-[hovering]:pointer-events-auto data-[scrolling]:opacity-100 data-[scrolling]:duration-100 data-[scrolling]:delay-0 data-[scrolling]:pointer-events-auto data-[visible=false]:hidden dark:bg-white/10">
<!-- Thumb -->
<div data-scroll-area-target="thumb" data-action="mousedown->scroll-area#onThumbMouseDown" class="relative w-full rounded-full bg-neutral-500 transition-opacity duration-100 dark:bg-neutral-400"></div>
</div>
</div>
Horizontal scroll area
Perfect for image carousels, card galleries, and any content that needs horizontal scrolling. Note that you can also use the Tailwind CSS scroll-snap-align utility to snap the items into place.
<div data-controller="scroll-area" data-scroll-area-target="root" data-action="mouseenter->scroll-area#onRootMouseEnter mouseleave->scroll-area#onRootMouseLeave" class="relative w-full max-w-2xl">
<!-- Viewport -->
<div class="rounded-xl border border-black/10 dark:border-white/10 bg-neutral-50 dark:bg-neutral-900 p-4">
<div class="overflow-x-auto scrollbar-hide flex pb-2 snap-align-start gap-4 scroll-fade-x" data-scroll-area-target="viewport" data-action="scroll->scroll-area#onViewportScroll">
<% 10.times do |i| %>
<div class="flex-shrink-0 w-64 h-48 rounded-lg border border-black/10 dark:border-white/10 bg-white dark:bg-neutral-800 p-4 flex flex-col justify-between">
<div>
<h3 class="font-semibold text-neutral-900 dark:text-white mb-2">Card <%= i + 1 %></h3>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
This is a horizontally scrollable card. Scroll to see more cards.
</p>
</div>
<div class="flex items-center gap-2">
<div class="size-8 rounded-full bg-neutral-300 dark:bg-neutral-600"></div>
<span class="text-xs text-neutral-500 dark:text-neutral-400">User <%= i + 1 %></span>
</div>
</div>
<% end %>
</div>
</div>
<!-- Horizontal Scrollbar -->
<div data-scroll-area-target="scrollbar" data-scroll-area-orientation-value="horizontal" data-action="click->scroll-area#onScrollbarClick" class="mx-2 absolute left-2 right-2 bottom-2 flex h-1.5 items-center rounded-full bg-black/5 backdrop-blur-sm opacity-0 transition-opacity duration-150 delay-300 pointer-events-none data-[hovering]:opacity-100 data-[hovering]:duration-100 data-[hovering]:delay-0 data-[hovering]:pointer-events-auto data-[scrolling]:opacity-100 data-[scrolling]:duration-100 data-[scrolling]:delay-0 data-[scrolling]:pointer-events-auto data-[visible=false]:hidden dark:bg-white/10">
<!-- Thumb -->
<div data-scroll-area-target="thumb" data-action="mousedown->scroll-area#onThumbMouseDown" class="relative h-full rounded-full bg-neutral-500 transition-opacity duration-100 dark:bg-neutral-400"></div>
</div>
</div>
Table with both scrollbars
A table that handles wide content gracefully with both vertical and horizontal scrollbars.
<div class="relative w-full max-w-4xl h-96 bg-white dark:bg-neutral-800 rounded-xl border border-black/10 dark:border-white/10 overflow-hidden"
data-controller="scroll-area"
data-scroll-area-target="root"
data-action="mouseenter->scroll-area#onRootMouseEnter mouseleave->scroll-area#onRootMouseLeave">
<!-- Shared Viewport with both scroll directions -->
<div data-scroll-area-target="viewport"
data-action="scroll->scroll-area#onViewportScroll"
class="h-full overflow-auto scrollbar-hide scroll-fade-both"
style="--fade-top-offset: 40px;">
<table class="w-full min-w-[800px]">
<thead class="sticky top-0 z-10 bg-neutral-50 dark:bg-neutral-900 after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-black/10 dark:after:bg-white/10">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider dark:text-neutral-400">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider dark:text-neutral-400">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider dark:text-neutral-400">Email</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider dark:text-neutral-400">Role</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider dark:text-neutral-400">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider dark:text-neutral-400">Created At</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-black/10 dark:bg-neutral-800 dark:divide-white/10">
<% 30.times do |i| %>
<tr class="even:bg-neutral-50 dark:even:bg-neutral-700/50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100"><%= i + 1 %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100">User <%= i + 1 %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-600 dark:text-neutral-300">user<%= i + 1 %>@example.com</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-600 dark:text-neutral-300"><%= ["Admin", "Editor", "Viewer"].sample %></td>
<td class="px-6 py-4 whitespace-nowrap">
<% status = ["Active", "Inactive", "Pending"].sample %>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
<%= case status
when "Active" then "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
when "Inactive" then "bg-neutral-100 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300"
when "Pending" then "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
end %>">
<%= status %>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-600 dark:text-neutral-300">Nov <%= rand(1..30) %>, 2025</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<!-- Vertical Scrollbar (right) - starts below sticky header -->
<div data-scroll-area-target="scrollbar"
data-scroll-area-orientation-value="vertical"
data-action="click->scroll-area#onScrollbarClick"
class="absolute right-2 top-12 bottom-4 flex w-1.5 justify-center rounded-full bg-black/5 backdrop-blur-sm opacity-0 transition-opacity duration-150 delay-300 pointer-events-none data-[hovering]:opacity-100 data-[hovering]:duration-100 data-[hovering]:delay-0 data-[hovering]:pointer-events-auto data-[scrolling]:opacity-100 data-[scrolling]:duration-100 data-[scrolling]:delay-0 data-[scrolling]:pointer-events-auto data-[visible=false]:hidden dark:bg-white/10">
<div data-scroll-area-target="thumb"
data-action="mousedown->scroll-area#onThumbMouseDown"
class="relative w-full rounded-full bg-neutral-500 transition-opacity duration-100 dark:bg-neutral-400"></div>
</div>
<!-- Horizontal Scrollbar (bottom) - leaves space for vertical scrollbar -->
<div data-scroll-area-target="scrollbar"
data-scroll-area-orientation-value="horizontal"
data-action="click->scroll-area#onScrollbarClick"
class="absolute left-2 right-4 bottom-2 flex h-1.5 items-center rounded-full bg-black/5 backdrop-blur-sm opacity-0 transition-opacity duration-150 delay-300 pointer-events-none data-[hovering]:opacity-100 data-[hovering]:duration-100 data-[hovering]:delay-0 data-[hovering]:pointer-events-auto data-[scrolling]:opacity-100 data-[scrolling]:duration-100 data-[scrolling]:delay-0 data-[scrolling]:pointer-events-auto data-[visible=false]:hidden dark:bg-white/10">
<div data-scroll-area-target="thumb"
data-action="mousedown->scroll-area#onThumbMouseDown"
class="relative h-full rounded-full bg-neutral-500 transition-opacity duration-100 dark:bg-neutral-400"></div>
</div>
</div>
Configuration
The scroll area component is powered by a Stimulus controller that provides custom scrollbar functionality, overflow detection, and smooth fade effects for scrollable content.
Controller Setup
Basic scroll area structure with required data attributes:
<div data-controller="scroll-area"
data-scroll-area-target="root"
data-action="mouseenter->scroll-area#onRootMouseEnter
mouseleave->scroll-area#onRootMouseLeave"
class="relative h-80 w-full max-w-md">
<div data-scroll-area-target="viewport"
data-action="scroll->scroll-area#onViewportScroll"
class="h-full overflow-auto scrollbar-hide scroll-fade-y">
<!-- Your content here -->
</div>
<div data-scroll-area-target="scrollbar"
data-scroll-area-orientation-value="vertical"
data-action="click->scroll-area#onScrollbarClick"
class="absolute right-2 top-2 bottom-2 w-1.5 rounded-full bg-neutral-200">
<div data-scroll-area-target="thumb"
data-action="mousedown->scroll-area#onThumbMouseDown"
class="relative w-full rounded-full bg-neutral-500"></div>
</div>
</div>
Configuration Values
Targets
Actions
CSS Fade Effects
Apply these CSS classes to create smooth fade effects at scroll edges:
Always Visible Scrollbar
By default, scrollbars auto-hide and only appear on hover or scroll. To keep the scrollbar always visible, modify the scrollbar element classes:
Remove these classes from the scrollbar element:
opacity-0
transition-opacity duration-150 delay-300
pointer-events-none
data-[hovering]:opacity-100 data-[hovering]:duration-100 data-[hovering]:delay-0 data-[hovering]:pointer-events-auto
data-[scrolling]:opacity-100 data-[scrolling]:duration-100 data-[scrolling]:delay-0 data-[scrolling]:pointer-events-auto
And add these instead:
opacity-100
pointer-events-auto
<div data-scroll-area-target="scrollbar"
data-scroll-area-orientation-value="vertical"
data-action="click->scroll-area#onScrollbarClick"
class="absolute right-2 top-2 bottom-2 flex w-1.5 justify-center rounded-full bg-black/5 backdrop-blur-sm opacity-100 pointer-events-auto data-[visible=false]:hidden dark:bg-white/10">
<div data-scroll-area-target="thumb"
data-action="mousedown->scroll-area#onThumbMouseDown"
class="relative w-full rounded-full bg-neutral-500 transition-opacity duration-100 dark:bg-neutral-400"></div>
</div>
Features
- Native Scrolling: Preserves all native browser scroll behavior including touch, keyboard, and mouse wheel
- Auto-hiding Scrollbars: Scrollbars fade out when not in use and appear on hover or scroll (can be configured to always show)
- Drag-to-Scroll: Click and drag the scrollbar thumb to scroll content smoothly
- Click-to-Jump: Click anywhere on the scrollbar track to jump to that position
- Responsive Sizing: Scrollbar thumb size automatically adjusts based on content length
- Overflow Detection: Automatically detects and reports overflow state via data attributes
- Smooth Fade Effects: Optional gradient fades at scroll edges to indicate more content
- Sticky Element Support: Skip fade effects on sticky headers/columns with CSS offsets
- Dark Mode: Full support for light and dark themes
Data Attributes
The controller automatically sets these data attributes on the root element for styling and detection:
-
data-has-overflow-x- Set totruewhen content overflows horizontally -
data-has-overflow-y- Set totruewhen content overflows vertically -
data-overflow-x-start- Present when scrolled away from the left edge -
data-overflow-x-end- Present when there's more content to scroll right -
data-overflow-y-start- Present when scrolled away from the top edge -
data-overflow-y-end- Present when there's more content to scroll down
Why Custom Scrollbars Matter
Visual Consistency
Default scrollbars look different on every platform. Windows has thick scrollbars, macOS has disappearing ones, and each browser adds its own quirks. Custom scrollbars solve this by giving you complete control over appearance across all platforms.
- Same appearance on Windows, Mac, and Linux
- Matches your design system colors and spacing
- Professional look without platform specific quirks
Native Behavior Preserved
Unlike other custom scrollbar solutions that rely on JavaScript for scrolling, this component keeps the native browser scrolling intact. Users get the smooth performance they expect without any compromises.
- Touch gestures work naturally on mobile devices
- Keyboard navigation remains fully functional
- Mouse wheel and trackpad scrolling feel smooth
Smooth Fade Effects
The gradient fade at scroll edges is what makes this component special. It subtly communicates to users that there's more content to discover, without the need for arrows or other explicit indicators. The fade automatically appears and disappears based on scroll position.
- Fades dynamically based on scroll position
- Supports horizontal, vertical, or both directions
- Customizable fade size and offset for sticky elements