{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-shadow",
  "title": "Scroll Shadow",
  "description": "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.",
  "files": [
    {
      "path": "registry/ui/scroll-shadow.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** Which axis the fades are drawn on. The other one is never faded, even if it scrolls. */\nexport type ScrollShadowOrientation = \"horizontal\" | \"vertical\" | \"both\"\n\n/**\n * Everything about a scroller's position that the edges are derived from, in one plain object so\n * the derivation is a pure function you can test without a browser.\n */\nexport interface ScrollMetrics {\n  scrollLeft: number\n  scrollTop: number\n  scrollWidth: number\n  clientWidth: number\n  scrollHeight: number\n  clientHeight: number\n  /**\n   * Whether the element is laid out right-to-left, which changes what `scrollLeft` means.\n   *\n   * In an LTR box `scrollLeft` runs from 0 at the left to `scrollWidth - clientWidth` at the right.\n   * In an RTL box the CSSOM spec puts 0 at the *initial* position — the right-hand end — and runs\n   * negative going left, so a fresh RTL table reports `scrollLeft === 0` while half its columns are\n   * hidden off to the left. Every implementation that treats `scrollLeft === 0` as \"nothing behind\n   * us\" therefore draws the fade on the wrong side of an Arabic or Hebrew page, and does it on first\n   * paint, when nobody is looking for it.\n   */\n  rtl: boolean\n}\n\n/** Which sides have content beyond the visible box, and whether each axis scrolls at all. */\nexport interface ScrollEdges {\n  top: boolean\n  right: boolean\n  bottom: boolean\n  left: boolean\n  scrollableX: boolean\n  scrollableY: boolean\n}\n\n/**\n * What the server renders and what a scroller reports before it has been measured.\n *\n * Nothing is known about overflow until an element exists and has been laid out, and the honest\n * answer to \"is there more to the right?\" before then is \"no fade\", not a guess — a fade painted on\n * the server would have to be unpainted on hydration on every box that turned out to fit.\n */\nexport const NO_SCROLL_EDGES: ScrollEdges = {\n  top: false,\n  right: false,\n  bottom: false,\n  left: false,\n  scrollableX: false,\n  scrollableY: false,\n}\n\n/**\n * How many pixels of hidden content still counts as \"at the end\".\n *\n * Not zero, and this is the bug that outlives every rewrite. Layout is fractional — a flex gap of\n * 0.5px, a browser at 110% zoom, a `min-w-0` column that resolves to 240.5 — while `scrollWidth`\n * and `clientWidth` are rounded integers. Scroll a table fully to the right and\n * `scrollWidth - clientWidth - scrollLeft` lands on 0.4 rather than 0, so a `> 0` test leaves the\n * end fade painted forever, over the last column, on exactly the screens the author does not have.\n */\nconst DEFAULT_THRESHOLD = 1\n\n/** Default length of a fade, in pixels. Long enough to read as a soft edge, short enough to hide little. */\nconst DEFAULT_SIZE = 32\n\n/**\n * Which sides of a scroller have content past the edge.\n *\n * Pure, so the RTL and sub-pixel cases can be asserted directly rather than reasoned about.\n * Distances are physical — `left` means \"there is content off the left of the screen\" — because\n * that is what a fade is drawn against; the writing direction is resolved here, once.\n */\nexport function readScrollEdges(\n  metrics: ScrollMetrics,\n  threshold: number = DEFAULT_THRESHOLD\n): ScrollEdges {\n  const maxX = metrics.scrollWidth - metrics.clientWidth\n  const maxY = metrics.scrollHeight - metrics.clientHeight\n  const scrollableX = maxX > threshold\n  const scrollableY = maxY > threshold\n\n  // In RTL, scrollLeft is 0 at the right-hand end and -maxX at the left, so the two distances swap\n  // and one of them needs the sign flipped. Overscroll — the rubber band at the end of a trackpad\n  // flick on macOS and iOS — pushes these negative for a few frames, which the comparison absorbs.\n  const hiddenLeft = metrics.rtl ? maxX + metrics.scrollLeft : metrics.scrollLeft\n  const hiddenRight = metrics.rtl ? -metrics.scrollLeft : maxX - metrics.scrollLeft\n\n  return {\n    top: scrollableY && metrics.scrollTop > threshold,\n    right: scrollableX && hiddenRight > threshold,\n    bottom: scrollableY && maxY - metrics.scrollTop > threshold,\n    left: scrollableX && hiddenLeft > threshold,\n    scrollableX,\n    scrollableY,\n  }\n}\n\nfunction sameEdges(a: ScrollEdges, b: ScrollEdges): boolean {\n  return (\n    a.top === b.top &&\n    a.right === b.right &&\n    a.bottom === b.bottom &&\n    a.left === b.left &&\n    a.scrollableX === b.scrollableX &&\n    a.scrollableY === b.scrollableY\n  )\n}\n\nfunction readMetrics(el: HTMLElement): ScrollMetrics {\n  const scrollWidth = el.scrollWidth\n  const clientWidth = el.clientWidth\n  return {\n    scrollLeft: el.scrollLeft,\n    scrollTop: el.scrollTop,\n    scrollWidth,\n    clientWidth,\n    scrollHeight: el.scrollHeight,\n    clientHeight: el.clientHeight,\n    // Only asked when it can change the answer. getComputedStyle flushes style, and the common case\n    // by far is a vertical scroller with no horizontal overflow at all, which never needs to know.\n    rtl: scrollWidth > clientWidth && getComputedStyle(el).direction === \"rtl\",\n  }\n}\n\n// Resolves the first measurement before paint, so a box that is already overflowing is never shown\n// for one frame without its fades. It warns during SSR, so fall back to useEffect on the server.\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\nexport interface UseScrollEdgesOptions {\n  /** Pixels of hidden content that still count as \"at the end\". Defaults to 1. See {@link DEFAULT_THRESHOLD}. */\n  threshold?: number\n}\n\n/**\n * Which sides of `ref` have more content, kept correct as the box, its contents and the scroll\n * position change. Exported so an indicator this component does not draw — arrow buttons on a tab\n * strip, a \"more →\" caption, a shadow of your own — reads the same measurement.\n *\n * The reason this is not four lines around a `scroll` listener is that **the scroll event is the\n * one signal that is never missing when the fade is missing.** A table that arrives from a fetch\n * already too wide, a sidebar opening and squeezing the page, a filter that adds a column, a\n * details row expanding: in every one of those the content starts out overflowing and nobody has\n * scrolled yet, so a scroll-only implementation shows nothing at the exact moment the user needs\n * telling that there is more. It looks fine in development, where you scroll the thing you just\n * built, and it is wrong on arrival for everybody else.\n *\n * So the position is re-read from five other places as well, each covering a way content or the box\n * can change without a scroll: the box being resized (which also covers it starting out\n * `display: none` inside a closed tab panel, since becoming visible is a resize from zero), a\n * child's own size changing, nodes being added or removed anywhere inside, an image or iframe\n * finishing loading, and a web font swapping in and re-flowing the text. All of them funnel through\n * one requestAnimationFrame, so a burst of mutations costs a single measurement, and state only\n * changes when one of the six booleans does — a scroll from one end of a long table to the other\n * re-renders twice, not once a frame.\n */\nexport function useScrollEdges(\n  ref: React.RefObject<HTMLElement | null>,\n  { threshold = DEFAULT_THRESHOLD }: UseScrollEdgesOptions = {}\n): ScrollEdges {\n  const [edges, setEdges] = React.useState<ScrollEdges>(NO_SCROLL_EDGES)\n\n  const measure = React.useCallback(() => {\n    const el = ref.current\n    if (!el) return\n    const next = readScrollEdges(readMetrics(el), threshold)\n    setEdges((prev) => (sameEdges(prev, next) ? prev : next))\n  }, [ref, threshold])\n\n  useIsomorphicLayoutEffect(measure, [measure])\n\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el) return\n\n    let frame: number | null = null\n    const schedule = () => {\n      if (frame !== null) return\n      frame = requestAnimationFrame(() => {\n        frame = null\n        measure()\n      })\n    }\n\n    // Passive: this never calls preventDefault, and saying so keeps it off the critical path of the\n    // scroll itself. Listened for on the element rather than the window because a scroll event does\n    // not bubble.\n    el.addEventListener(\"scroll\", schedule, { passive: true })\n\n    // An image or iframe that finishes loading changes how wide the content is without touching the\n    // DOM and often without resizing any child that is being watched. `load` does not bubble but it\n    // does capture, so one listener covers every one of them, however deep.\n    el.addEventListener(\"load\", schedule, true)\n\n    let resizeObserver: ResizeObserver | null = null\n    let mutationObserver: MutationObserver | null = null\n    const observedChildren = new Set<Element>()\n\n    if (typeof ResizeObserver !== \"undefined\") {\n      resizeObserver = new ResizeObserver(schedule)\n      // The box itself: a window resize, a sidebar opening, a flex sibling growing, or the panel\n      // this lives in being shown for the first time.\n      resizeObserver.observe(el)\n\n      // And the contents, which is the half a container-only observer misses: a row of chips whose\n      // labels change, a table whose columns re-measure, a cell that wraps to two lines. Direct\n      // children are watched rather than the whole subtree because a deep node growing pushes its\n      // ancestors wider, and the outermost one of those is a direct child. The set is kept in step\n      // with the DOM so a list that swaps its rows does not accumulate observations on nodes that\n      // are gone.\n      const syncChildren = () => {\n        const current = new Set<Element>(el.children)\n        for (const node of observedChildren) {\n          if (!current.has(node)) {\n            resizeObserver?.unobserve(node)\n            observedChildren.delete(node)\n          }\n        }\n        for (const node of current) {\n          if (!observedChildren.has(node)) {\n            resizeObserver?.observe(node)\n            observedChildren.add(node)\n          }\n        }\n      }\n      syncChildren()\n\n      if (typeof MutationObserver !== \"undefined\") {\n        mutationObserver = new MutationObserver(() => {\n          syncChildren()\n          schedule()\n        })\n        // characterData as well as childList: text replaced in place — a cell going from \"3\" to\n        // \"3,481,002\" — widens a table without adding a single node.\n        mutationObserver.observe(el, { childList: true, subtree: true, characterData: true })\n      }\n    }\n\n    // A web font swapping in re-flows every line without resizing anything that is being watched.\n    // Asked of the element's own document rather than the global one, so a scroller portalled into\n    // an iframe or a popped-out window waits for the fonts that will actually lay it out.\n    let cancelled = false\n    const fonts = el.ownerDocument.fonts\n    if (fonts) {\n      fonts.ready.then(() => {\n        if (!cancelled) measure()\n      })\n    }\n\n    return () => {\n      cancelled = true\n      el.removeEventListener(\"scroll\", schedule)\n      el.removeEventListener(\"load\", schedule, true)\n      resizeObserver?.disconnect()\n      mutationObserver?.disconnect()\n      if (frame !== null) cancelAnimationFrame(frame)\n    }\n  }, [ref, measure])\n\n  return edges\n}\n\n/**\n * One axis of the fade, as a mask image.\n *\n * `#000` is opaque and `transparent` is not, which under the default alpha mask mode means shown\n * and hidden — the colour itself never appears, which is the whole point of masking rather than\n * overlaying: nothing here has to know what is behind the scroller.\n *\n * A side with nothing hidden past it gets a hard opaque stop instead of a fade, so the mask is only\n * ever soft where there is genuinely something more to see.\n */\nfunction fadeGradient(to: \"right\" | \"bottom\", start: number, end: number): string {\n  const stops: string[] = []\n  stops.push(start > 0 ? `transparent 0, #000 ${start}px` : \"#000 0\")\n  stops.push(end > 0 ? `#000 calc(100% - ${end}px), transparent 100%` : \"#000 100%\")\n  return `linear-gradient(to ${to}, ${stops.join(\", \")})`\n}\n\nexport interface ScrollShadowMaskOptions {\n  orientation?: ScrollShadowOrientation\n  /** Length of each fade in pixels. Defaults to 32. */\n  size?: number\n}\n\n/**\n * The mask that fades whichever edges have more content behind them, as inline style.\n *\n * Exported for the same reason as the hook: so a scroller you lay out yourself can take the fade\n * without taking this component's markup.\n *\n * Two things are deliberate. It returns an empty object when nothing is hidden, rather than a\n * fully opaque mask — a mask makes the element a stacking context and costs a composited layer,\n * and a box that fits should pay neither. And when both axes need fading the two gradients are\n * combined with `mask-composite: intersect`, because the default is `add`: two layers that are each\n * opaque down their own middle would union into an almost entirely opaque mask, fading nothing but\n * the four corners — a bug that only appears in the one case where both axes scroll.\n */\nexport function scrollShadowMask(\n  edges: ScrollEdges,\n  { orientation = \"both\", size = DEFAULT_SIZE }: ScrollShadowMaskOptions = {}\n): React.CSSProperties {\n  const layers: string[] = []\n  if (orientation !== \"vertical\" && (edges.left || edges.right)) {\n    layers.push(fadeGradient(\"right\", edges.left ? size : 0, edges.right ? size : 0))\n  }\n  if (orientation !== \"horizontal\" && (edges.top || edges.bottom)) {\n    layers.push(fadeGradient(\"bottom\", edges.top ? size : 0, edges.bottom ? size : 0))\n  }\n  if (layers.length === 0) return {}\n  const maskImage = layers.join(\", \")\n  return layers.length > 1 ? { maskImage, maskComposite: \"intersect\" } : { maskImage }\n}\n\nconst OVERFLOW_CLASS: Record<ScrollShadowOrientation, string> = {\n  // The unused axis is hidden rather than left visible: CSS promotes `visible` to `auto` as soon as\n  // the other axis is not visible, so a horizontal scroller written as `overflow-x-auto` alone ends\n  // up with a vertical scrollbar the first time a cell wraps.\n  horizontal: \"overflow-x-auto overflow-y-hidden\",\n  vertical: \"overflow-y-auto overflow-x-hidden\",\n  both: \"overflow-auto\",\n}\n\nexport interface ScrollShadowProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onScroll\"> {\n  /** Which axis to fade. Defaults to `\"both\"`, which fades whichever axis turns out to scroll. */\n  orientation?: ScrollShadowOrientation\n  /** Length of each fade in pixels. Defaults to 32. */\n  size?: number\n  /** Pixels of hidden content that still count as \"at the end\". Defaults to 1. */\n  threshold?: number\n  /**\n   * Give the scrolling box a tab stop while it overflows. Defaults to true; pass false only if you\n   * are providing keyboard access another way. See {@link ScrollShadow} on why this is not static.\n   */\n  focusable?: boolean\n  /** Ref to the scrolling element itself — the one to call `scrollTo` on. */\n  viewportRef?: React.Ref<HTMLDivElement>\n  /** Classes for the scrolling element: `max-h-72`, padding, `scroll-smooth`. */\n  viewportClassName?: string\n  /** Fires when a side gains or loses hidden content — for arrow buttons, or a \"more\" caption. */\n  onEdgesChange?: (edges: ScrollEdges) => void\n  /** Scroll handler, attached to the scrolling element rather than the wrapper. */\n  onViewportScroll?: React.UIEventHandler<HTMLDivElement>\n}\n\n/**\n * A scrolling box whose edges fade out while there is more content past them, and stop fading the\n * moment there is not.\n *\n * ```tsx\n * <ScrollShadow orientation=\"horizontal\" aria-label=\"Releases\">\n *   <table className=\"w-max\">…</table>\n * </ScrollShadow>\n *\n * <ScrollShadow orientation=\"vertical\" viewportClassName=\"max-h-72 p-4\" className=\"rounded-lg border\">\n *   <p>…long terms and conditions…</p>\n * </ScrollShadow>\n * ```\n *\n * **The thing being solved is that `overflow: auto` will not tell you whether it is overflowing.**\n * A scrollable box looks exactly like a box that ends there, and the browser gives no hook for the\n * difference — no `:overflowing` selector, no event, nothing in CSS. So a cut-off table reads as a\n * complete table, and people file bugs about missing columns that were there the whole time. The\n * only way to know is to measure `scrollWidth` against `clientWidth` and keep re-measuring, which\n * is what {@link useScrollEdges} does and why this is a component rather than three utility\n * classes. The version everyone writes first listens for `scroll` alone, and that is precisely\n * backwards: it means the fade is missing until you scroll, and the state where the fade earns its\n * keep is the one before anyone has touched it.\n *\n * The fade is a **mask on the content**, not a gradient overlaid on top of it. An overlay has to be\n * painted in the page's background colour to look like a fade, so it has to be told what that\n * colour is — and it is then wrong inside a card, wrong on a striped table, wrong over an image,\n * and wrong in dark mode the day someone adds it, because the gradient stop was hardcoded once and\n * nobody re-checked. Masking makes the content itself fall away, which is correct on every\n * background without being told about any of them. Two consequences worth knowing: the scroller\n * becomes a stacking context while a fade is showing, and a popover rendered inside it will be\n * clipped — though `overflow: auto` was already clipping it, so portal it out as usual.\n *\n * **It takes a tab stop only while it actually scrolls.** A `div` with `overflow: auto` and no\n * focusable content inside cannot be reached, and therefore cannot be scrolled, without a mouse —\n * a plain table of text or a wide code block is simply unreachable by keyboard, which is what axe\n * reports as `scrollable-region-focusable`. The usual fix is a permanent `tabIndex={0}`, which buys\n * that at the price of a dead tab stop on every one of these boxes that fits. Since the overflow is\n * already being measured, the stop can exist exactly when it is useful. Pass `aria-label` and the\n * box is announced as a named region as well, so a screen reader user is told what they have\n * arrived in rather than landing on an anonymous group.\n *\n * The focus ring is drawn on the wrapper, outside the mask — a ring on the masked element would\n * fade out along with the content at the very edges it is meant to trace, which is a focus\n * indicator that disappears exactly when the box is scrollable.\n */\nexport const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(\n  function ScrollShadow(\n    {\n      orientation = \"both\",\n      size = DEFAULT_SIZE,\n      threshold = DEFAULT_THRESHOLD,\n      focusable = true,\n      viewportRef,\n      viewportClassName,\n      onEdgesChange,\n      onViewportScroll,\n      className,\n      children,\n      role,\n      tabIndex,\n      \"aria-label\": ariaLabel,\n      \"aria-labelledby\": ariaLabelledBy,\n      \"aria-describedby\": ariaDescribedBy,\n      ...props\n    },\n    ref\n  ) {\n    const innerRef = React.useRef<HTMLDivElement>(null)\n    React.useImperativeHandle(viewportRef, () => innerRef.current as HTMLDivElement)\n\n    const edges = useScrollEdges(innerRef, { threshold })\n\n    // A ref, so a caller passing an inline arrow does not re-run the effect on every render.\n    const onEdgesChangeRef = React.useRef(onEdgesChange)\n    React.useEffect(() => {\n      onEdgesChangeRef.current = onEdgesChange\n    })\n    React.useEffect(() => {\n      onEdgesChangeRef.current?.(edges)\n    }, [edges])\n\n    // Focus is tracked here rather than with a `has-[:focus-visible]` variant on the wrapper,\n    // because `:has()` cannot tell the box's own focus from a link's inside it, and ringing the\n    // whole region every time someone tabs through a row of buttons is worse than no ring at all.\n    const [focusRing, setFocusRing] = React.useState(false)\n\n    const scrollable = edges.scrollableX || edges.scrollableY\n    const keyboardScrollable = focusable && scrollable\n    const named = Boolean(ariaLabel || ariaLabelledBy)\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          // A column flex box rather than a plain block, so that a height put on the wrapper\n          // reaches the scroller: a `max-h-72` on a block wrapper caps nothing, the content spills\n          // out of it in full, and the box the author asked to scroll simply does not. Shrinking to\n          // zero in both axes is what lets it be dropped into a flex row or a grid cell without\n          // the widest table cell dictating the width of the whole page.\n          \"relative flex min-h-0 min-w-0 flex-col\",\n          focusRing && \"ring-2 ring-ring ring-offset-2 ring-offset-background\",\n          className\n        )}\n        // Left for CSS to hang off — a shadow of your own, an arrow button that hides at the end.\n        data-more-top={edges.top ? \"\" : undefined}\n        data-more-right={edges.right ? \"\" : undefined}\n        data-more-bottom={edges.bottom ? \"\" : undefined}\n        data-more-left={edges.left ? \"\" : undefined}\n        data-scrollable={scrollable ? \"\" : undefined}\n        {...props}\n      >\n        <div\n          ref={innerRef}\n          // Inherited so that a border radius put on the wrapper clips the content too, instead of\n          // square corners of table showing through a rounded card.\n          className={cn(\n            \"min-h-0 min-w-0 rounded-[inherit] focus-visible:outline-none\",\n            OVERFLOW_CLASS[orientation],\n            viewportClassName\n          )}\n          style={scrollShadowMask(edges, { orientation, size })}\n          tabIndex={tabIndex ?? (keyboardScrollable ? 0 : undefined)}\n          // An unnamed `region` is not exposed as a landmark and only adds noise, and a landmark\n          // for a box that currently fits is noise of a second kind, so both conditions are asked.\n          role={role ?? (named && keyboardScrollable ? \"region\" : undefined)}\n          aria-label={ariaLabel}\n          aria-labelledby={ariaLabelledBy}\n          aria-describedby={ariaDescribedBy}\n          onScroll={onViewportScroll}\n          onFocus={(event) => {\n            // Only the box itself, not a button inside it that bubbled its focus up here.\n            if (event.target !== event.currentTarget) return\n            setFocusRing(event.currentTarget.matches(\":focus-visible\"))\n          }}\n          onBlur={(event) => {\n            if (event.target !== event.currentTarget) return\n            setFocusRing(false)\n          }}\n        >\n          {children}\n        </div>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
