Shades only the edges with more to scroll, tied to the scroll itself.
Scroll@base-ui/react
01Preview
Members
12 people02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react03Usage
import { ScrollShadow } from "@/components/ui/scroll-shadow";
<ScrollShadow label="Members" className="h-64">
<ul>…</ul>
</ScrollShadow>
// A chip row that fades out at whichever end has more
<ScrollShadow orientation="horizontal" variant="fade" scrollbar={false}>
<div className="flex w-max gap-1.5">…</div>
</ScrollShadow>04Source
"use client";
import { ScrollArea } from "@base-ui/react/scroll-area";
import { useId } from "react";
import { cn } from "@/lib/cn";
// Edges appear only where there is more to scroll. Where the browser has
// scroll-driven animations they are tied to the scroll position itself: the top
// edge fades in over the first `size` px, the bottom one fades out over the
// last, with no script and before hydration. Elsewhere the scroll area's own
// overflow attributes switch them with a short transition.
const css = `
[data-ss-root] { timeline-scope: var(--ss-y), var(--ss-x); }
[data-ss-viewport] { scroll-timeline: var(--ss-y) y, var(--ss-x) x; }
[data-ss-edge] { position: absolute; pointer-events: none; opacity: 0; transition: opacity 180ms var(--ease-out-quart); }
[data-ss-edge="top"] { inset: 0 0 auto 0; height: var(--ss-size); background: linear-gradient(to bottom, color-mix(in oklab, var(--ss-color) 90%, transparent), transparent); box-shadow: inset 0 1px 0 var(--line-2); }
[data-ss-edge="bottom"] { inset: auto 0 0 0; height: var(--ss-size); background: linear-gradient(to top, color-mix(in oklab, var(--ss-color) 90%, transparent), transparent); box-shadow: inset 0 -1px 0 var(--line-2); }
[data-ss-edge="left"] { inset: 0 auto 0 0; width: var(--ss-size); background: linear-gradient(to right, color-mix(in oklab, var(--ss-color) 90%, transparent), transparent); box-shadow: inset 1px 0 0 var(--line-2); }
[data-ss-edge="right"] { inset: 0 0 0 auto; width: var(--ss-size); background: linear-gradient(to left, color-mix(in oklab, var(--ss-color) 90%, transparent), transparent); box-shadow: inset -1px 0 0 var(--line-2); }
[data-ss-root][data-overflow-y-start] > [data-ss-edge="top"],
[data-ss-root][data-overflow-y-end] > [data-ss-edge="bottom"],
[data-ss-root][data-overflow-x-start] > [data-ss-edge="left"],
[data-ss-root][data-overflow-x-end] > [data-ss-edge="right"] { opacity: 1; }
@property --ss-fade-top { syntax: "<length>"; inherits: false; initial-value: 0px; }
@property --ss-fade-bottom { syntax: "<length>"; inherits: false; initial-value: 0px; }
@property --ss-fade-left { syntax: "<length>"; inherits: false; initial-value: 0px; }
@property --ss-fade-right { syntax: "<length>"; inherits: false; initial-value: 0px; }
[data-ss-variant="fade"] > [data-ss-viewport] {
--ss-fade-top: min(var(--ss-size), var(--scroll-area-overflow-y-start, 0px));
--ss-fade-bottom: min(var(--ss-size), var(--scroll-area-overflow-y-end, 0px));
--ss-fade-left: min(var(--ss-size), var(--scroll-area-overflow-x-start, 0px));
--ss-fade-right: min(var(--ss-size), var(--scroll-area-overflow-x-end, 0px));
-webkit-mask-image: linear-gradient(to bottom, transparent, var(--fg) var(--ss-fade-top), var(--fg) calc(100% - var(--ss-fade-bottom)), transparent), linear-gradient(to right, transparent, var(--fg) var(--ss-fade-left), var(--fg) calc(100% - var(--ss-fade-right)), transparent);
mask-image: linear-gradient(to bottom, transparent, var(--fg) var(--ss-fade-top), var(--fg) calc(100% - var(--ss-fade-bottom)), transparent), linear-gradient(to right, transparent, var(--fg) var(--ss-fade-left), var(--fg) calc(100% - var(--ss-fade-right)), transparent);
-webkit-mask-composite: source-in;
mask-composite: intersect;
}
@keyframes ss-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes ss-out { from { opacity: 1; } to { opacity: 0; } }
@keyframes ss-fade-top { from { --ss-fade-top: 0px; } to { --ss-fade-top: var(--ss-size); } }
@keyframes ss-fade-bottom { from { --ss-fade-bottom: var(--ss-size); } to { --ss-fade-bottom: 0px; } }
@keyframes ss-fade-left { from { --ss-fade-left: 0px; } to { --ss-fade-left: var(--ss-size); } }
@keyframes ss-fade-right { from { --ss-fade-right: var(--ss-size); } to { --ss-fade-right: 0px; } }
@supports (animation-timeline: scroll()) {
[data-ss-edge] { transition: none; animation-timing-function: linear; animation-fill-mode: both; animation-duration: auto; }
[data-ss-edge="top"] { animation-name: ss-in; animation-timeline: var(--ss-y); animation-range: 0 var(--ss-size); }
[data-ss-edge="bottom"] { animation-name: ss-out; animation-timeline: var(--ss-y); animation-range: calc(100% - var(--ss-size)) 100%; }
[data-ss-edge="left"] { animation-name: ss-in; animation-timeline: var(--ss-x); animation-range: 0 var(--ss-size); }
[data-ss-edge="right"] { animation-name: ss-out; animation-timeline: var(--ss-x); animation-range: calc(100% - var(--ss-size)) 100%; }
[data-ss-variant="fade"] > [data-ss-viewport] {
animation-name: ss-fade-top, ss-fade-bottom, ss-fade-left, ss-fade-right;
animation-timing-function: linear;
animation-fill-mode: both;
animation-duration: auto;
animation-timeline: var(--ss-y), var(--ss-y), var(--ss-x), var(--ss-x);
animation-range: 0 var(--ss-size), calc(100% - var(--ss-size)) 100%, 0 var(--ss-size), calc(100% - var(--ss-size)) 100%;
}
/* These follow the scroll position; they are state, not motion, so reduced motion keeps them. */
@media (prefers-reduced-motion: reduce) {
[data-ss-root] [data-ss-edge], [data-ss-root][data-ss-variant="fade"] > [data-ss-viewport] { animation-duration: auto !important; }
}
}
`;
const edgesFor = { vertical: ["top", "bottom"], horizontal: ["left", "right"], both: ["top", "bottom", "left", "right"] } as const;
export type ScrollShadowProps = Omit<ScrollArea.Root.Props, "children" | "className"> & {
children?: React.ReactNode;
className?: string;
/** Which way it scrolls. */
orientation?: "vertical" | "horizontal" | "both";
/**
* shadow draws a hairline and a soft shade at each edge with more content;
* fade masks the content itself out toward those edges, for any background.
*/
variant?: "shadow" | "fade";
/** Depth of the shade or fade, and the scroll distance over which it arrives, in px. */
size?: number;
/** Show the thin overlay scrollbar while hovering or scrolling. */
scrollbar?: boolean;
/** Names the scroll region (role=region) for assistive tech. Set it whenever the region can take focus. */
label?: string;
viewportRef?: React.Ref<HTMLDivElement>;
viewportClassName?: string;
};
export function ScrollShadow({
orientation = "vertical",
variant = "shadow",
size = 20,
scrollbar = true,
label,
viewportRef,
viewportClassName,
className,
style,
children,
...rest
}: ScrollShadowProps) {
const id = useId().replace(/[^a-zA-Z0-9_-]/g, "");
const vertical = orientation !== "horizontal";
const horizontal = orientation !== "vertical";
return (
<ScrollArea.Root
data-ss-root=""
data-ss-variant={variant}
data-orientation={orientation}
className={cn("relative min-h-0 overflow-hidden", className)}
style={{ "--ss-y": `--ss-y-${id}`, "--ss-x": `--ss-x-${id}`, "--ss-size": `${size}px`, "--ss-color": "var(--scroll-shadow-color, var(--page))", ...(style as React.CSSProperties) } as React.CSSProperties}
{...rest}
>
<style href="stealth-scroll-shadow" precedence="default">
{css}
</style>
<ScrollArea.Viewport
ref={viewportRef}
data-ss-viewport=""
role={label ? "region" : undefined}
aria-label={label}
className={cn(
"size-full overscroll-contain rounded-[inherit] outline-none",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3",
!vertical && "overscroll-y-auto",
viewportClassName,
)}
>
<ScrollArea.Content
className={cn(horizontal && !vertical && "h-full")}
// Vertical-only content takes the viewport's width, so long rows truncate instead of scrolling sideways.
style={horizontal ? undefined : { minWidth: 0 }}
>
{children}
</ScrollArea.Content>
</ScrollArea.Viewport>
{variant === "shadow" && edgesFor[orientation].map((edge) => <span key={edge} data-ss-edge={edge} aria-hidden />)}
{scrollbar && vertical && <Bar orientation="vertical" />}
{scrollbar && horizontal && <Bar orientation="horizontal" />}
{scrollbar && vertical && horizontal && <ScrollArea.Corner />}
</ScrollArea.Root>
);
}
// A thin overlay bar that shows while you hover or scroll, and thickens under the pointer.
function Bar({ orientation }: { orientation: "vertical" | "horizontal" }) {
return (
<ScrollArea.Scrollbar
orientation={orientation}
className={cn(
"group/bar z-1 flex justify-center p-0.5 opacity-0 transition-opacity duration-200 ease-out-quart",
"pointer-events-none data-hovering:pointer-events-auto data-hovering:opacity-100 data-scrolling:pointer-events-auto data-scrolling:opacity-100 data-scrolling:duration-75",
orientation === "vertical" ? "w-2.5" : "h-2.5 flex-col",
)}
>
<ScrollArea.Thumb
className={cn(
"rounded-full bg-fg-4 transition-[background-color,width,height] duration-150 group-hover/bar:bg-fg-3 active:bg-fg-2",
orientation === "vertical" ? "w-1 group-hover/bar:w-1.5" : "h-1 group-hover/bar:h-1.5",
)}
/>
</ScrollArea.Scrollbar>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | "vertical" | "horizontal" | "both" | "vertical" | Which way it scrolls, and so which edges can shade. |
| variant | "shadow" | "fade" | "shadow" | A hairline and a soft shade over the content, or a mask that fades the content itself (works on any background, including images). |
| size | number | 20 | Depth of the shade or fade in px, and the scroll distance over which it arrives. |
| scrollbar | boolean | true | A thin overlay scrollbar that shows while hovering or scrolling and thickens under the pointer. |
| label | string | — | Names the scroll region (role=region) for assistive tech. Set it whenever the region can take focus. |
| viewportRef | Ref<HTMLDivElement> | — | The scrolling element, for scrollTo and scroll listeners. |
| viewportClassName | string | — | Classes for the scrolling element, e.g. padding or scroll-snap. |
06Notes
Behavior
- Where the browser has scroll-driven animations, each edge is tied to the scroll position with pure CSS: the top shade grows over the first 20px of scroll and the bottom one recedes over the last 20px. It works before hydration and costs no script.
- Elsewhere the scroll area's overflow attributes switch each edge on or off with a 180ms fade, and the fade variant reads the exact distance from each edge, so it is still proportional.
- Content that doesn't overflow shows no edges at all. Filter a list down to one row and the shading goes with it.
- Vertical-only content takes the viewport's width, so long names and emails truncate instead of scrolling sideways. Shade color follows --scroll-shadow-color (the page color by default).
Motion
- There is no timed animation on scroll: the edge is a function of position, so it can never lag or overshoot the hand.
- The scrollbar fades in over 200ms on hover and appears within 75ms of scrolling; the thumb widens from 4 to 6px under the pointer.
- Reduced motion keeps the edges tracking the scroll, since they describe where the content is rather than decorate it.
Accessibility
- A native scroll container underneath, so wheel, touch, keyboard and find-in-page all work as they would anyway.
- When it overflows, the viewport joins the tab order so keyboard users can scroll it with the arrow keys, and shows a hairline focus ring. Give it a label.
- Edges and the scrollbar are decorative and hidden from assistive tech.