Scroll Fade
A scrollable container with gradient fade edges to indicate overflow.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/scroll-fade.jsonUsage
import { ScrollFade } from "@/components/ui/scroll-fade"Examples
Default
A scrolling region whose edges fade, so it is obvious there is more above or below. The height goes on ScrollFade itself: with nothing bounding it there is no overflow and no fade.
Scrollable item 1
Scrollable item 2
Scrollable item 3
Scrollable item 4
Scrollable item 5
Scrollable item 6
Scrollable item 7
Scrollable item 8
Scrollable item 9
Scrollable item 10
Scrollable item 11
Scrollable item 12
Horizontal
A single line that runs past the edge scrolls sideways under the same overlay thumb. The content is w-max min-w-full, and its trailing padding is the room that lets the last characters clear the right skim.
npx shadcn@latest add -o https://ui.mattdowney.com/r/kit.json
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | "vertical" | "horizontal" | "vertical" | The axis that scrolls. horizontal fades the left and right edges and runs the thumb along the bottom. The content has to be wider than the region to overflow, so give it w-max min-w-full. |
| skimHeight | number | 24 | Size of the gradient fade skim at each edge along the scroll axis, in pixels (a width when orientation is horizontal). The fade ramps in with scroll distance, solid at a fully scrolled edge; nothing is reserved at rest. |
| fade | "both" | "start" | "end" | "top" | "bottom" | "both" | Which edges show the fade skim. Use bottom when the container has a frozen header (Table stickyHeader): a top skim would fade the sticky header as rows scroll under it. start and end are the orientation-neutral spellings of top/left and bottom/right. |
"use client"
import * as React from "react"
import { cn } from "@/registry/lib/utils"
interface ScrollFadeProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* The axis that scrolls (default: "vertical"). "horizontal" is for a single
* line of overflowing content such as a code sample: the viewport scrolls
* sideways, the skim fades the left and right edges, and the thumb runs along
* the bottom. The content must be wider than the viewport to overflow, so give
* it `w-max min-w-full`; a block child is only as wide as the viewport and
* never overflows.
*/
orientation?: "vertical" | "horizontal"
/**
* Size of the fade skim at each edge along the scroll axis, in pixels
* (default: 24). The name predates `orientation`; in a horizontal region this
* is a width.
*/
skimHeight?: number
/**
* Which edges show the fade skim (default: "both"). Use "bottom" when the
* container holds a frozen header at the top: a top skim would fade the
* sticky header as rows scroll under it, so only the bottom edge should
* signal overflow. The custom thumb is unaffected. `start` and `end` are the
* orientation-neutral spellings (top/left and bottom/right); `top` and
* `bottom` are accepted in either orientation as the same two edges.
*/
fade?: "both" | "start" | "end" | "top" | "bottom"
/**
* Classes for the scrolling viewport itself. Reach for this when the region
* is capped by MAX height rather than given a fixed one: the viewport is
* `h-full`, and a percentage height against an auto-height parent resolves to
* auto, so a `max-h-*` on the root alone leaves the viewport as tall as its
* content and the root simply clips it with no scrolling at all. Putting the
* cap here keeps the viewport the scroller.
*/
viewportClassName?: string
}
// Float the thumb this far from the top/bottom/right edges (px).
const INSET = 4
const MIN_THUMB = 24
// Fade the thumb out this long after the last scroll/pointer activity (ms).
const HIDE_DELAY = 1000
// @use-when a scrollable region that needs to signal there is more content
// past its edge.
function ScrollFade({
className,
orientation = "vertical",
skimHeight = 24,
fade = "both",
viewportClassName,
children,
...props
}: ScrollFadeProps) {
const viewportRef = React.useRef<HTMLDivElement>(null)
const thumbRef = React.useRef<HTMLDivElement>(null)
const hideTimer = React.useRef<number | null>(null)
const drag = React.useRef<{ pointer: number; scroll: number } | null>(null)
React.useEffect(() => {
const el = viewportRef.current
const thumb = thumbRef.current
if (!el || !thumb) return
const horizontal = orientation === "horizontal"
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches
if (reduce) thumb.style.transition = "none"
// Show the thumb, then schedule a fade-out once activity stops. We keep it
// up while dragging or hovering the thumb so it stays grabbable.
function reveal() {
thumb!.style.opacity = "1"
thumb!.style.pointerEvents = "auto"
if (hideTimer.current) window.clearTimeout(hideTimer.current)
hideTimer.current = window.setTimeout(() => {
if (drag.current || thumb!.matches(":hover")) return
thumb!.style.opacity = "0"
thumb!.style.pointerEvents = "none"
}, HIDE_DELAY)
}
// One set of numbers for either axis: "scroll" is the offset along it,
// "track" the room the thumb travels in, "thumb" its length.
function geometry() {
const scroll = horizontal ? el!.scrollLeft : el!.scrollTop
const size = horizontal ? el!.scrollWidth : el!.scrollHeight
const client = horizontal ? el!.clientWidth : el!.clientHeight
const overflow = size - client
const track = client - INSET * 2
const thumbLength = Math.max((client / size) * track, MIN_THUMB)
return { overflow, track, thumbLength, scroll }
}
// Edge skim ramps in with scroll distance (start solid at the start, end solid
// at the end), and the overlay thumb tracks the scroll position. Both write
// straight to the DOM - no setState, no re-render per frame.
function update() {
const { overflow, track, thumbLength, scroll } = geometry()
const startSkim =
fade === "bottom" || fade === "end" ? 0 : Math.max(Math.min(scroll, skimHeight), 0)
const endSkim =
fade === "top" || fade === "start" ? 0 : Math.max(Math.min(overflow - scroll, skimHeight), 0)
el!.style.setProperty(horizontal ? "--sf-left" : "--sf-top", `${startSkim}px`)
el!.style.setProperty(horizontal ? "--sf-right" : "--sf-bottom", `${endSkim}px`)
// How far the region has scrolled from its start, capped at the skim,
// whatever `fade` says: a pinned column inside (Table's first column)
// reads it to draw its edge only once content is sliding under it.
el!.style.setProperty(horizontal ? "--sf-scroll-left" : "--sf-scroll-top", `${Math.max(Math.min(scroll, skimHeight), 0)}px`)
// Only a tab stop when there's something to scroll: a focused scrollable
// element gets native arrow/Page/Space scrolling, so keyboard users can
// reach overflow content the hidden scrollbar would otherwise strand.
el!.tabIndex = overflow > 0 ? 0 : -1
if (overflow <= 0) {
thumb!.style.display = "none"
return
}
thumb!.style.display = ""
const offset = INSET + (scroll / overflow) * (track - thumbLength)
// Anchor the track to the viewport's box, not the root's edge. Root
// padding (e.g. a sidebar's pt-8) must not put the track beside dead space.
if (horizontal) {
thumb!.style.width = `${thumbLength}px`
thumb!.style.left = `${el!.offsetLeft}px`
thumb!.style.transform = `translateX(${offset}px)`
} else {
thumb!.style.height = `${thumbLength}px`
thumb!.style.top = `${el!.offsetTop}px`
thumb!.style.transform = `translateY(${offset}px)`
}
}
function onScroll() {
update()
reveal()
}
// Drag the thumb to scroll.
function onThumbDown(e: PointerEvent) {
e.preventDefault()
drag.current = {
pointer: horizontal ? e.clientX : e.clientY,
scroll: horizontal ? el!.scrollLeft : el!.scrollTop,
}
thumb!.setPointerCapture(e.pointerId)
}
function onThumbMove(e: PointerEvent) {
if (!drag.current) return
const { overflow, track, thumbLength } = geometry()
const range = track - thumbLength
if (range <= 0) return
const next =
drag.current.scroll + (((horizontal ? e.clientX : e.clientY) - drag.current.pointer) / range) * overflow
if (horizontal) el!.scrollLeft = next
else el!.scrollTop = next
}
function onThumbUp() {
drag.current = null
reveal()
}
update()
el.addEventListener("scroll", onScroll, { passive: true })
thumb.addEventListener("pointerdown", onThumbDown)
thumb.addEventListener("pointermove", onThumbMove)
thumb.addEventListener("pointerup", onThumbUp)
const observer = new ResizeObserver(update)
observer.observe(el)
if (el.firstElementChild) observer.observe(el.firstElementChild)
return () => {
el.removeEventListener("scroll", onScroll)
thumb.removeEventListener("pointerdown", onThumbDown)
thumb.removeEventListener("pointermove", onThumbMove)
thumb.removeEventListener("pointerup", onThumbUp)
observer.disconnect()
if (hideTimer.current) window.clearTimeout(hideTimer.current)
}
}, [skimHeight, fade, orientation])
// The thumb is a sibling of the viewport (not inside it), so the edge mask
// never touches it. Single-layer mask: just the fade along the scroll axis.
const horizontal = orientation === "horizontal"
const mask = horizontal
? "linear-gradient(to right, transparent 0, #000 var(--sf-left, 0px), #000 calc(100% - var(--sf-right, 0px)), transparent 100%)"
: "linear-gradient(to bottom, transparent 0, #000 var(--sf-top, 0px), #000 calc(100% - var(--sf-bottom, 0px)), transparent 100%)"
return (
// Vertical: the region grows 4px past its box on every side and the
// viewport pads the same 4px back, so content sits exactly where it did
// but a ring drawn OUTSIDE a child's box (a Badge's 1px edge, a Button's
// 3px focus ring) lands inside the scroll box instead of being sheared at
// its edge: the clip-container rule, which every other kit part that
// wraps controls already keeps. Horizontal is left alone: its edges are a
// table's pinned column and a code line, where 4px would move the pin.
<div
data-slot="scroll-fade"
className={cn("relative min-h-0 overflow-hidden", !horizontal && "-m-1", className)}
{...props}
>
<div
data-slot="scroll-fade-viewport"
ref={viewportRef}
style={{ maskImage: mask, WebkitMaskImage: mask }}
// No reserved edge padding: the skim ramps from zero with scroll
// distance, so at rest there is no fade to clear and static padding
// reads as dead space above the first row.
// overscroll-none: an in-app scroll region should not rubber-band
// ("springy") at its own edges, nor chain its scroll to the page.
// `contain` stops the chaining but keeps the element's own bounce;
// `none` removes both, which is what an app surface like a data table
// wants.
// Horizontal: overflow-y-hidden so the one scrollable axis is the only
// one, and no h-full since the region is as tall as its line(s).
className={cn(
horizontal ? "overflow-x-auto overflow-y-hidden overscroll-none" : "min-h-0 h-full overflow-y-auto overscroll-none p-1",
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
viewportClassName
)}
>
{children}
</div>
{/* Custom overlay scrollbar. A native webkit thumb can't fade (transitions
on ::-webkit-scrollbar-thumb are ignored), so we render our own. It
floats over the content (no reserved gutter -> no layout shift) and is
shown on scroll activity, fading out once that stops. */}
<div
ref={thumbRef}
aria-hidden
data-slot="scroll-fade-thumb"
style={{ ...(horizontal ? { left: 0 } : { top: 0 }), transition: "opacity 200ms ease-out" }}
className={cn(
"pointer-events-none absolute rounded-full bg-border opacity-0 select-none touch-none hover:bg-muted-foreground",
horizontal ? "bottom-0.5 h-1" : "right-1.5 w-1"
)}
/>
</div>
)
}
export { ScrollFade }