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.

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.

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.

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.

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.

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.

<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.

Card 1

This is a horizontally scrollable card. Scroll to see more cards.

User 1

Card 2

This is a horizontally scrollable card. Scroll to see more cards.

User 2

Card 3

This is a horizontally scrollable card. Scroll to see more cards.

User 3

Card 4

This is a horizontally scrollable card. Scroll to see more cards.

User 4

Card 5

This is a horizontally scrollable card. Scroll to see more cards.

User 5

Card 6

This is a horizontally scrollable card. Scroll to see more cards.

User 6

Card 7

This is a horizontally scrollable card. Scroll to see more cards.

User 7

Card 8

This is a horizontally scrollable card. Scroll to see more cards.

User 8

Card 9

This is a horizontally scrollable card. Scroll to see more cards.

User 9

Card 10

This is a horizontally scrollable card. Scroll to see more cards.

User 10
<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.

ID Name Email Role Status Created At
1 User 1 user1@example.com Viewer Pending Nov 16, 2025
2 User 2 user2@example.com Editor Inactive Nov 2, 2025
3 User 3 user3@example.com Viewer Inactive Nov 20, 2025
4 User 4 user4@example.com Admin Pending Nov 24, 2025
5 User 5 user5@example.com Editor Pending Nov 1, 2025
6 User 6 user6@example.com Admin Pending Nov 23, 2025
7 User 7 user7@example.com Editor Pending Nov 22, 2025
8 User 8 user8@example.com Viewer Active Nov 12, 2025
9 User 9 user9@example.com Editor Inactive Nov 20, 2025
10 User 10 user10@example.com Editor Inactive Nov 24, 2025
11 User 11 user11@example.com Editor Active Nov 4, 2025
12 User 12 user12@example.com Admin Active Nov 1, 2025
13 User 13 user13@example.com Editor Pending Nov 3, 2025
14 User 14 user14@example.com Editor Active Nov 2, 2025
15 User 15 user15@example.com Editor Inactive Nov 3, 2025
16 User 16 user16@example.com Admin Inactive Nov 22, 2025
17 User 17 user17@example.com Viewer Pending Nov 3, 2025
18 User 18 user18@example.com Viewer Active Nov 20, 2025
19 User 19 user19@example.com Admin Pending Nov 29, 2025
20 User 20 user20@example.com Admin Pending Nov 6, 2025
21 User 21 user21@example.com Editor Pending Nov 8, 2025
22 User 22 user22@example.com Editor Pending Nov 30, 2025
23 User 23 user23@example.com Editor Active Nov 4, 2025
24 User 24 user24@example.com Viewer Active Nov 29, 2025
25 User 25 user25@example.com Viewer Inactive Nov 28, 2025
26 User 26 user26@example.com Editor Inactive Nov 20, 2025
27 User 27 user27@example.com Editor Inactive Nov 2, 2025
28 User 28 user28@example.com Viewer Active Nov 2, 2025
29 User 29 user29@example.com Viewer Active Nov 22, 2025
30 User 30 user30@example.com Admin Pending Nov 18, 2025
<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

Prop Description Type Default
hideDelay
Time in milliseconds before scrollbar fades out after scrolling stops number 600

Targets

Target Description Required
root
The root container that tracks hover state and overflow data attributes Required
viewport
The scrollable content area (the element that actually scrolls) Required
scrollbar
The custom scrollbar track element Required
thumb
The draggable scrollbar thumb element Required

Actions

Action Description Usage
onRootMouseEnter
Shows scrollbar when mouse enters the scroll area mouseenter->scroll-area#onRootMouseEnter
onRootMouseLeave
Hides scrollbar when mouse leaves the scroll area mouseleave->scroll-area#onRootMouseLeave
onViewportScroll
Updates scrollbar position and visibility as content scrolls scroll->scroll-area#onViewportScroll
onScrollbarClick
Jumps to clicked position when scrollbar track is clicked click->scroll-area#onScrollbarClick
onThumbMouseDown
Enables dragging the scrollbar thumb to scroll content mousedown->scroll-area#onThumbMouseDown

CSS Fade Effects

Apply these CSS classes to create smooth fade effects at scroll edges:

Class Description Customization
scroll-fade-x Fades edges horizontally when content is scrollable --fade-size: 25px
scroll-fade-y Fades edges vertically when content is scrollable --fade-size: 40px
scroll-fade-both Fades both horizontal and vertical edges --fade-top-offset
--fade-left-offset

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 to true when content overflows horizontally
  • data-has-overflow-y - Set to true when 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

Table of contents

Get notified when new components come out