ScrollArea
Native scrolling with a thin overlay thumb, and edges that fade only where there is more.
The idea
The scrolling is the browser's own, because a JavaScript re-implementation loses trackpad momentum, rubber-banding and every keyboard shortcut people rely on without knowing it. Only the bar is replaced: a five-pixel thumb over the content that appears while you scroll or point and fades once you stop. Each edge fades the content out only while there is more that way, and the fade grows with distance from the edge, so it eases in rather than switching on.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { ScrollArea } from "mihirmodi-ui";
// Native scrolling; only the bar is replaced. A label makes the
// viewport a focusable region, so the keyboard can scroll it.
<ScrollArea label="Changelog" className="h-[240px]" viewportClassName="px-4 py-2">
<ul>{entries}</ul>
</ScrollArea>
<ScrollArea orientation="horizontal" label="Plates" viewportClassName="pb-3">
<div className="flex gap-2">{thumbnails}</div>
</ScrollArea>Props
| Prop | Type | Notes |
|---|---|---|
| orientation"vertical" | "horizontal" | "vertical" | "horizontal"= "vertical" | Which way it scrolls. |
| labelstring | string | Makes the viewport a focusable region, so the keyboard can scroll it. |
| fadenumber | number= 24 | How far the edge fade reaches, in px. |
| className / viewportClassNamestring | string | Height on the outer box; padding on the viewport, so it scrolls. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
export type ScrollAreaProps = {
children: ReactNode;
/** Which way it scrolls. */
orientation?: "vertical" | "horizontal";
/**
* An accessible name. With one, the viewport becomes a focusable region, so
* a keyboard can scroll it with the arrows and Page keys — leave it out only
* when the content inside is itself focusable all the way down.
*/
label?: string;
/** How far the edge fade reaches, in px. */
fade?: number;
/** On the outer box — give it a height (or a width, horizontally). */
className?: string;
/** On the scrolling viewport — padding goes here, so it scrolls with the content. */
viewportClassName?: string;
style?: CSSProperties;
};
/** How long the thumb stays after the last scroll. */
const LINGER = 900;
/**
* A scrolling box with a thin overlay thumb and edges that fade where there
* is more.
*
* The scrolling is the browser's own. Re-implementing it in JavaScript loses
* momentum on a trackpad, rubber-banding on a phone, and every keyboard
* shortcut people do not know they rely on, so only the scrollbar is replaced.
* The native one is hidden and a five-pixel thumb is drawn over the content
* instead: it takes no width from the layout, appears while you scroll or
* point at the box, and fades once you stop — present when it answers
* "where am I", gone when it would only be furniture. It can be dragged.
*
* Each edge fades the content out over a short distance, but only while there
* is more to scroll that way — and the fade grows with the distance from the
* edge rather than switching on, so it eases in as you leave the top instead
* of jumping. A fade at an edge you cannot scroll past would say "there is
* more" and be lying. The thumb and the fades are written straight to the DOM
* in a frame callback, so scrolling never re-renders the content.
*/
export function ScrollArea({
children,
orientation = "vertical",
label,
fade = 24,
className = "",
viewportClassName = "",
style,
}: ScrollAreaProps) {
const vertical = orientation === "vertical";
const view = useRef<HTMLDivElement>(null);
const thumb = useRef<HTMLDivElement>(null);
const frame = useRef(0);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [scrolling, setScrolling] = useState(false);
const [hover, setHover] = useState(false);
const [dragging, setDragging] = useState(false);
const [scrollable, setScrollable] = useState(false);
const paint = useCallback(() => {
frame.current = 0;
const el = view.current;
const t = thumb.current;
if (!el) return;
const size = vertical ? el.clientHeight : el.clientWidth;
const total = vertical ? el.scrollHeight : el.scrollWidth;
const pos = vertical ? el.scrollTop : el.scrollLeft;
const max = Math.max(0, total - size);
setScrollable(max > 1);
const start = Math.min(fade, Math.max(0, pos));
const end = Math.min(fade, Math.max(0, max - pos));
el.style.setProperty("--ui-fade-a", `${start}px`);
el.style.setProperty("--ui-fade-b", `${end}px`);
if (t && max > 1) {
const track = size - 8; // 4px clear at either end
const len = Math.max(24, (size / total) * track);
const off = 4 + (pos / max) * (track - len);
t.style[vertical ? "height" : "width"] = `${len}px`;
t.style.transform = vertical ? `translateY(${off}px)` : `translateX(${off}px)`;
}
}, [fade, vertical]);
const schedule = useCallback(() => {
if (!frame.current) frame.current = requestAnimationFrame(paint);
}, [paint]);
useEffect(() => {
const el = view.current;
if (!el) return;
paint();
const ro = new ResizeObserver(schedule);
ro.observe(el);
if (el.firstElementChild) ro.observe(el.firstElementChild);
return () => {
ro.disconnect();
cancelAnimationFrame(frame.current);
frame.current = 0;
clearTimeout(timer.current);
};
}, [paint, schedule]);
const onScroll = () => {
schedule();
setScrolling(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setScrolling(false), LINGER);
};
// Dragging the thumb maps its travel onto the content's: the thumb moves
// `track - len` while the content moves `total - size`.
const onThumbDown = (e: ReactPointerEvent<HTMLDivElement>) => {
const el = view.current;
const t = thumb.current;
if (!el || !t) return;
e.preventDefault();
t.setPointerCapture(e.pointerId);
setDragging(true);
const from = vertical ? e.clientY : e.clientX;
const startPos = vertical ? el.scrollTop : el.scrollLeft;
const size = vertical ? el.clientHeight : el.clientWidth;
const total = vertical ? el.scrollHeight : el.scrollWidth;
const len = vertical ? t.offsetHeight : t.offsetWidth;
const ratio = (total - size) / Math.max(1, size - 8 - len);
const move = (ev: PointerEvent) => {
const d = ((vertical ? ev.clientY : ev.clientX) - from) * ratio;
if (vertical) el.scrollTop = startPos + d;
else el.scrollLeft = startPos + d;
};
const up = () => {
setDragging(false);
t.removeEventListener("pointermove", move);
t.removeEventListener("pointerup", up);
t.removeEventListener("pointercancel", up);
};
t.addEventListener("pointermove", move);
t.addEventListener("pointerup", up);
t.addEventListener("pointercancel", up);
};
const dir = vertical ? "to bottom" : "to right";
const mask = `linear-gradient(${dir}, transparent 0, #000 var(--ui-fade-a, 0px), #000 calc(100% - var(--ui-fade-b, 0px)), transparent 100%)`;
const visible = scrollable && (scrolling || hover || dragging);
return (
<div
className={`relative overflow-hidden ${className}`}
style={style}
onPointerEnter={(e) => e.pointerType === "mouse" && setHover(true)}
onPointerLeave={() => setHover(false)}
>
<div
ref={view}
onScroll={onScroll}
role={label ? "region" : undefined}
aria-label={label}
tabIndex={label ? 0 : undefined}
className={`h-full w-full overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${
vertical ? "overflow-y-auto overflow-x-hidden" : "overflow-x-auto overflow-y-hidden"
} ${viewportClassName}`}
style={{ maskImage: mask, WebkitMaskImage: mask }}
>
{/* One child to observe, so a change in content height re-measures the thumb. */}
<div className={vertical ? "" : "w-max"}>{children}</div>
</div>
<div
ref={thumb}
aria-hidden
onPointerDown={onThumbDown}
className={`absolute rounded-full bg-gray-alpha-500 transition-[opacity,background-color] duration-200 ease-out hover:bg-gray-alpha-600 motion-reduce:transition-none ${
vertical ? "right-[3px] top-0 w-[5px]" : "bottom-[3px] left-0 h-[5px]"
} ${dragging ? "bg-gray-alpha-600" : ""} ${visible ? "opacity-100" : "pointer-events-none opacity-0"}`}
/>
</div>
);
}