Scroll Fade
A scrollable container with gradient fade edges to indicate overflow.
Installation
$
Usage
import { ScrollFade } from "@/components/ui/scroll-fade"Examples
Default
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| skimHeight | number | 24 | Height of the gradient fade skim at each edge, in pixels. The fade ramps in with scroll distance, solid at a fully scrolled edge; nothing is reserved at rest. |
| fade | "both" | "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. |
"use client"
import * as React from "react"
import { cn } from "@/registry/lib/utils"
interface ScrollFadeProps extends React.HTMLAttributes<HTMLDivElement> {
/** Height of the fade skim at each edge, in pixels (default: 24) */
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.
*/
fade?: "both" | "top" | "bottom"
}
// 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
function ScrollFade({
className,
skimHeight = 24,
fade = "both",
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<{ y: number; top: number } | null>(null)
React.useEffect(() => {
const el = viewportRef.current
const thumb = thumbRef.current
if (!el || !thumb) return
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)
}
function geometry() {
const { scrollTop, scrollHeight, clientHeight } = el!
const overflow = scrollHeight - clientHeight
const trackH = clientHeight - INSET * 2
const thumbH = Math.max((clientHeight / scrollHeight) * trackH, MIN_THUMB)
return { overflow, trackH, thumbH, scrollTop }
}
// Edge skim ramps in with scroll distance (top solid at the top, bottom 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, trackH, thumbH, scrollTop } = geometry()
const topSkim = fade === "bottom" ? 0 : Math.max(Math.min(scrollTop, skimHeight), 0)
const bottomSkim = fade === "top" ? 0 : Math.max(Math.min(overflow - scrollTop, skimHeight), 0)
el!.style.setProperty("--sf-top", `${topSkim}px`)
el!.style.setProperty("--sf-bottom", `${bottomSkim}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 = ""
thumb!.style.height = `${thumbH}px`
// 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.
thumb!.style.top = `${el!.offsetTop}px`
thumb!.style.transform = `translateY(${INSET + (scrollTop / overflow) * (trackH - thumbH)}px)`
}
function onScroll() {
update()
reveal()
}
// Drag the thumb to scroll.
function onThumbDown(e: PointerEvent) {
e.preventDefault()
drag.current = { y: e.clientY, top: el!.scrollTop }
thumb!.setPointerCapture(e.pointerId)
}
function onThumbMove(e: PointerEvent) {
if (!drag.current) return
const { overflow, trackH, thumbH } = geometry()
const range = trackH - thumbH
if (range <= 0) return
el!.scrollTop = drag.current.top + ((e.clientY - drag.current.y) / range) * overflow
}
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])
// The thumb is a sibling of the viewport (not inside it), so the edge mask
// never touches it. Single-layer mask: just the vertical fade.
const mask =
"linear-gradient(to bottom, transparent 0, #000 var(--sf-top, 0px), #000 calc(100% - var(--sf-bottom, 0px)), transparent 100%)"
return (
<div data-slot="scroll-fade" className={cn("relative min-h-0 overflow-hidden", 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.
className="min-h-0 h-full overflow-y-auto overscroll-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{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={{ top: 0, transition: "opacity 200ms ease-out" }}
className="pointer-events-none absolute right-0.5 w-1 rounded-full bg-border opacity-0 select-none touch-none hover:bg-muted-foreground"
/>
</div>
)
}
export { ScrollFade }