A scrolling box whose edges fade out while there is more content past them, and stop fading the moment there is not — the cue that tells a reader a wide table has columns off to the right, or that a panel continues below the fold. Reach for it wherever a box scrolls inside a page that does not: a wide data table or admin grid seen on a laptop or a phone, a horizontal tab strip, filter-chip row or category rail, a card carousel, a code block or build log that scrolls sideways, a long terms-and-conditions or changelog panel, the body of a modal or drawer, a sidebar nav taller than the viewport, a chat or comment pane, a dashboard table inside a card, and any responsive table that overflows on small screens. Common asks it answers: "scroll shadow react", "fade edges of scrollable div", "shadow when content overflows", "indicate more content horizontally", "horizontal scroll indicator for table", "responsive table overflow indicator mobile", "detect if an element is scrollable react", "check if a div has overflow javascript", "is content overflowing react hook", "scrollWidth vs clientWidth react", "useOverflow hook", "show a gradient only when scrollable", "fade out overflow tailwind", "mask-image scroll fade", "NextUI ScrollShadow alternative", "Mantine ScrollArea shadow alternative", "shadcn scroll area shadow", "shadcn horizontal scroll fade", "scroll hint react", "scrollable region focusable axe", "keyboard scroll a div", "スクロールできることを示す影". The thing being solved is that overflow: auto will not tell you whether it is overflowing. A scrollable box looks exactly like a box that ends there, and the browser offers no hook for the difference — there is no :overflowing selector, no event, nothing in CSS at all. So a cut-off table reads as a complete table, and people file bugs about missing columns that were on screen the whole time. The only way to know is to measure scrollWidth against clientWidth and keep re-measuring, which is why this is a component and not three utility classes. Official shadcn/ui has none of that machinery. Grepping all sixty-three of its registry entries (sixty-two fetchable; questionnaire is listed but 404s), scrollWidth, clientWidth, offsetWidth, scrollLeft, scrollTop, ResizeObserver and MutationObserver are every one of them a zero hit. The word overflow appears in twenty-two components and is a Tailwind class each time — something that clips or scrolls, never a measurement of whether anything is spilling. scroll-area itself is a forty-nine-line Radix wrapper that restyles the scrollbar: no overflow detection, no edge shading, and one tabIndex in the whole registry (in sidebar). The version everyone writes first listens for scroll alone, and that is precisely backwards. It means the fade is missing until you scroll, and the moment the fade earns its keep is the one before anybody has touched the box: a table that arrives from a fetch already too wide, a sidebar opening and squeezing the page, a filter that adds a column, a details row expanding, a panel inside a tab that was display:none when it mounted. It looks right in development, where you scroll the thing you just built, and it is wrong on arrival for everyone else. So the position is re-read from five other places besides the scroll event, each covering a way the answer changes without one: the box being resized (which also covers it becoming visible, since that is a resize from zero), a child changing its own size, nodes added or removed anywhere inside, an image or iframe finishing loading (caught in the capture phase, since load does not bubble), and a web font swapping in and re-flowing every line. All of them funnel through one requestAnimationFrame, so a burst of mutations costs a single measurement, and state changes only when one of six booleans does — scrolling a long table end to end re-renders twice, not once a frame. Two measurement bugs are fixed that survive most rewrites. The first is right-to-left: the CSSOM puts scrollLeft at 0 at the initial position of an RTL scroller and runs it negative going left, so a fresh Arabic or Hebrew table reports scrollLeft === 0 with half its columns hidden off to the left — and every implementation that reads 0 as "nothing behind us" paints the fade on the wrong side, on first paint, where nobody is looking for it. The writing direction is resolved once, and only when there is horizontal overflow to resolve it for. The second is sub-pixel: layout is fractional while scrollWidth and clientWidth are rounded integers, so a box scrolled fully to the end lands a few tenths of a pixel short and a `hidden > 0` test leaves the end fade painted over the last column forever, on exactly the screens the author does not own. A one-pixel threshold settles it, and it is a prop. The fade is a mask on the content, not a gradient laid over it. An overlay has to be painted in the page background colour to look like a fade, so it has to be told what that colour is — and it is then wrong inside a card, wrong on a striped table, wrong over an image, and wrong in dark mode the day someone adds one, because the gradient stop was hardcoded once and never re-checked. A mask makes the content itself fall away, which is correct on every background without being told about any of them. When a box fits, no mask is emitted at all rather than an all-opaque one, since masking costs a stacking context and a composited layer; and when both axes scroll the two gradients are combined with mask-composite: intersect, because the default is add and two layers each opaque down their own middle would union into a mask that fades nothing but the four corners. It also takes a tab stop only while it actually scrolls. A div with overflow: auto and no focusable content inside cannot be reached and therefore cannot be scrolled without a mouse — a plain table of text or a wide code block is simply unavailable to a keyboard, which is what axe reports as scrollable-region-focusable. The usual fix is a permanent tabIndex={0}, which buys that at the cost of a dead tab stop on every one of these boxes that happens to fit; since the overflow is already being measured, the stop can exist exactly when it is useful. Pass aria-label and the box is announced as a named region as well, so a screen reader user is told what they have landed in rather than an anonymous group. The focus ring is drawn on the wrapper, outside the mask, because a ring on the masked element would fade out along with the content at the very edges it is meant to trace. The API is orientation ("horizontal", "vertical" or the default "both", which fades whichever axis turns out to scroll), size for the fade length, threshold, focusable, viewportRef and viewportClassName for the scrolling element itself, onEdgesChange, and data-more-top / -right / -bottom / -left plus data-scrollable on the wrapper so you can hang an arrow button or a shadow of your own off CSS. The unused axis is set to hidden rather than left visible, because CSS promotes visible back to auto as soon as the other axis is not, and a horizontal scroller written as overflow-x-auto alone grows a vertical scrollbar the first time a cell wraps. The wrapper is a column flex box so that a max-height put on it actually reaches the scroller instead of spilling, and it shrinks to zero in both axes so the widest table cell cannot dictate the width of the page around it. useScrollEdges, scrollShadowMask and the pure readScrollEdges are exported for a scroller you lay out yourself. Within pulld it is the piece that goes around virtual-list, table-like content and code-block; it shares its measure-then-observe discipline with scroll-progress, which reads how far down a page a reader is rather than what is hidden past an edge, and it is distinct from infinite-scroll, which loads more rows when you reach the end where this only says that an end is not yet reached. One file, no dependencies at all, and no colour of its own — the fade is the absence of paint, so light and dark follow for free.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/scroll-shadow.json"