{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-shadow",
  "title": "Scroll Shadow",
  "description": "A wrapper for any scrolling box that shows edge shadows, fades or hairlines from the real scroll position, so people can see there is more.",
  "dependencies": [
    "radix-ui"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/scroll-shadow.tsx",
      "content": "import type * as React from \"react\"\nimport { useImperativeHandle, useState } from \"react\"\nimport { Slot } from \"radix-ui\"\nimport { fadeMask, isOverflowing, type EdgeDistances, type ScrollAxis } from \"@/registry/miseui/ui/scroll-shadow-math\"\nimport { useScrollEdges } from \"@/registry/miseui/ui/scroll-shadow-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype ScrollShadowVariant = \"shadow\" | \"fade\" | \"line\" | \"none-at-rest\"\ntype Edge = keyof EdgeDistances\n\nconst DEFAULT_FADE_PX = 32\n\nconst SCROLLER_CLASS: Record<ScrollAxis, string> = {\n  y: \"overflow-x-hidden overflow-y-auto\",\n  x: \"overflow-x-auto overflow-y-hidden\",\n  both: \"overflow-auto\",\n}\n\nconst EDGES: readonly Edge[] = [\"top\", \"bottom\", \"left\", \"right\"]\n\n/**\n * Black, not a colour built from foreground: on a dark theme foreground is near white and the shadow turns into a glow.\n * The stronger dark value is because black barely shows on a near-black surface.\n */\nconst SHADOW_CLASS: Record<Edge, string> = {\n  top: \"inset-x-0 top-0 h-4 bg-linear-to-b from-black/10 to-transparent dark:from-black/50\",\n  bottom: \"inset-x-0 bottom-0 h-4 bg-linear-to-t from-black/10 to-transparent dark:from-black/50\",\n  left: \"inset-y-0 left-0 w-4 bg-linear-to-r from-black/10 to-transparent dark:from-black/50\",\n  right: \"inset-y-0 right-0 w-4 bg-linear-to-l from-black/10 to-transparent dark:from-black/50\",\n}\n\nconst LINE_CLASS: Record<Edge, string> = {\n  top: \"inset-x-0 top-0 h-px bg-border\",\n  bottom: \"inset-x-0 bottom-0 h-px bg-border\",\n  left: \"inset-y-0 left-0 w-px bg-border\",\n  right: \"inset-y-0 right-0 w-px bg-border\",\n}\n\ntype ScrollShadowProps = Omit<React.ComponentProps<\"div\">, \"ref\"> & {\n  /** `shadow` soft shadows, `fade` the content itself dissolves, `line` a hairline, `none-at-rest` shadows only while scrolling. */\n  variant?: ScrollShadowVariant\n  /** Which way it scrolls. */\n  axis?: ScrollAxis\n  /** Uses your own scrolling child instead of a built-in div, such as a ul or a table wrapper. */\n  asChild?: boolean\n  /** Longest fade in `fade`, in px. */\n  fadeSize?: number\n  /** The element that scrolls. */\n  scrollerRef?: React.Ref<HTMLElement | null>\n  /** Classes for the scrolling element. className goes on the outer box, which is what you size. */\n  scrollerClassName?: string\n}\n\ninterface EdgeMarkProps {\n  edge: Edge\n  shown: boolean\n  variant: ScrollShadowVariant\n  awake: boolean\n}\n\nfunction EdgeMark({ edge, shown, variant, awake }: EdgeMarkProps) {\n  const visible = shown && (variant !== \"none-at-rest\" || awake)\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-slot=\"scroll-shadow-edge\"\n      data-edge={edge}\n      data-visible={visible ? \"true\" : \"false\"}\n      className={cn(\n        \"pointer-events-none absolute z-10 transition-opacity duration-(--duration-fast) ease-(--ease-mise) motion-reduce:transition-none\",\n        variant === \"line\" ? LINE_CLASS[edge] : SHADOW_CLASS[edge],\n        visible ? \"opacity-100\" : \"opacity-0\",\n      )}\n    />\n  )\n}\n\nfunction ScrollShadow({ variant = \"shadow\", axis = \"y\", asChild = false, fadeSize = DEFAULT_FADE_PX, scrollerRef, scrollerClassName, className, children, tabIndex, role, \"aria-label\": ariaLabel, \"aria-labelledby\": ariaLabelledBy, ...props }: ScrollShadowProps) {\n  const [node, setNode] = useState<HTMLElement | null>(null)\n  useImperativeHandle<HTMLElement | null, HTMLElement | null>(scrollerRef, () => node, [node])\n  const { edges, scrolling } = useScrollEdges(node, axis)\n  const scrollable = isOverflowing(edges)\n  const named = Boolean(ariaLabel || ariaLabelledBy)\n  const Scroller = asChild ? Slot.Root : \"div\"\n  const mask = variant === \"fade\" ? fadeMask(edges, fadeSize) : undefined\n  return (\n    <div\n      data-slot=\"scroll-shadow\"\n      data-variant={variant}\n      data-axis={axis}\n      data-scrolling={scrolling ? \"true\" : \"false\"}\n      // The tracks take the height and width set on this box, including a max-height, so the scroller inside has something to overflow.\n      className={cn(\"relative grid min-h-0 min-w-0 grid-cols-[minmax(0,1fr)] grid-rows-[minmax(0,1fr)]\", className)}\n      {...props}\n    >\n      <Scroller\n        ref={setNode}\n        data-slot=\"scroll-shadow-scroller\"\n        // A scroller nothing can focus cannot be moved with the keyboard, unless it holds something focusable of its own.\n        tabIndex={tabIndex ?? (scrollable ? 0 : undefined)}\n        role={role ?? (named ? \"region\" : undefined)}\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledBy}\n        className={cn(\"min-h-0 min-w-0 outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50\", SCROLLER_CLASS[axis], scrollerClassName)}\n        style={mask ? { maskImage: mask, WebkitMaskImage: mask, maskComposite: \"intersect\", WebkitMaskComposite: \"source-in\" } : undefined}\n      >\n        {children}\n      </Scroller>\n      {variant === \"fade\"\n        ? null\n        : EDGES.map((edge) => <EdgeMark key={edge} edge={edge} shown={edges[edge] > 0} variant={variant} awake={scrolling} />)}\n    </div>\n  )\n}\n\nexport { ScrollShadow, type ScrollShadowProps, type ScrollShadowVariant, type ScrollAxis }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/scroll-shadow-math.ts",
      "content": "interface ScrollMetrics {\n  scrollTop: number\n  scrollLeft: number\n  scrollHeight: number\n  scrollWidth: number\n  clientHeight: number\n  clientWidth: number\n}\n\n/** How far the content runs past each edge, in px. */\ninterface EdgeDistances {\n  top: number\n  bottom: number\n  left: number\n  right: number\n}\n\ntype ScrollAxis = \"y\" | \"x\" | \"both\"\n\n/** Zoomed pages report fractional scroll positions, so a list at its end can be 0.5 px short of it. */\nconst EDGE_TOLERANCE_PX = 1\n\nconst NO_EDGES: EdgeDistances = { top: 0, bottom: 0, left: 0, right: 0 }\n\nfunction snap(distance: number) {\n  return distance <= EDGE_TOLERANCE_PX ? 0 : Math.round(distance)\n}\n\nfunction edgeDistances(metrics: ScrollMetrics, axis: ScrollAxis, rtl = false): EdgeDistances {\n  const vertical = axis !== \"x\"\n  const horizontal = axis !== \"y\"\n  const maxTop = metrics.scrollHeight - metrics.clientHeight\n  const maxLeft = metrics.scrollWidth - metrics.clientWidth\n  // Right to left, scrollLeft starts at 0 on the right edge and goes negative.\n  const fromStart = Math.abs(metrics.scrollLeft)\n  const fromEnd = maxLeft - fromStart\n  return {\n    top: vertical ? snap(metrics.scrollTop) : 0,\n    bottom: vertical ? snap(maxTop - metrics.scrollTop) : 0,\n    left: horizontal ? snap(rtl ? fromEnd : fromStart) : 0,\n    right: horizontal ? snap(rtl ? fromStart : fromEnd) : 0,\n  }\n}\n\nfunction sameEdges(a: EdgeDistances, b: EdgeDistances) {\n  return a.top === b.top && a.bottom === b.bottom && a.left === b.left && a.right === b.right\n}\n\nfunction isOverflowing(edges: EdgeDistances) {\n  return edges.top > 0 || edges.bottom > 0 || edges.left > 0 || edges.right > 0\n}\n\n/**\n * The fade grows with the distance from the edge, up to `size`. It follows the finger directly and needs no animation:\n * the last pixel of scroll takes the last pixel of fade.\n */\nfunction fadeMask(edges: EdgeDistances, size: number) {\n  const top = Math.min(edges.top, size)\n  const bottom = Math.min(edges.bottom, size)\n  const left = Math.min(edges.left, size)\n  const right = Math.min(edges.right, size)\n  const layers = []\n  if (top > 0 || bottom > 0) layers.push(`linear-gradient(to bottom, transparent, black ${top}px, black calc(100% - ${bottom}px), transparent)`)\n  if (left > 0 || right > 0) layers.push(`linear-gradient(to right, transparent, black ${left}px, black calc(100% - ${right}px), transparent)`)\n  return layers.length > 0 ? layers.join(\", \") : undefined\n}\n\nexport { edgeDistances, fadeMask, isOverflowing, sameEdges, EDGE_TOLERANCE_PX, NO_EDGES, type EdgeDistances, type ScrollAxis, type ScrollMetrics }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/scroll-shadow-state.ts",
      "content": "import { useEffect, useRef, useState } from \"react\"\nimport { edgeDistances, sameEdges, NO_EDGES, type EdgeDistances, type ScrollAxis } from \"@/registry/miseui/ui/scroll-shadow-math\"\n\n/** How long `none-at-rest` keeps its shadows after the last scroll event. */\nconst REST_AFTER_MS = 700\n\ninterface ScrollEdges {\n  edges: EdgeDistances\n  /** True during a scroll and shortly after it. */\n  scrolling: boolean\n}\n\n/** The element arrives through a callback ref held in state, so this works for a child passed with asChild too. */\nfunction useScrollEdges(node: HTMLElement | null, axis: ScrollAxis): ScrollEdges {\n  const [edges, setEdges] = useState<EdgeDistances>(NO_EDGES)\n  const [scrolling, setScrolling] = useState(false)\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)\n  useEffect(() => {\n    if (!node) return\n    const measure = () => {\n      const rtl = getComputedStyle(node).direction === \"rtl\"\n      const next = edgeDistances(node, axis, rtl)\n      setEdges((previous) => (sameEdges(previous, next) ? previous : next))\n    }\n    const onScroll = () => {\n      measure()\n      setScrolling(true)\n      if (timer.current !== null) clearTimeout(timer.current)\n      timer.current = setTimeout(() => setScrolling(false), REST_AFTER_MS)\n    }\n    node.addEventListener(\"scroll\", onScroll, { passive: true })\n    // A resize of the box or of what is inside changes the edges without a single scroll event.\n    const resize = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(measure)\n    resize?.observe(node)\n    const watchChildren = () => Array.from(node.children).forEach((child) => resize?.observe(child))\n    watchChildren()\n    const mutation =\n      typeof MutationObserver === \"undefined\"\n        ? null\n        : new MutationObserver(() => {\n            watchChildren()\n            measure()\n          })\n    mutation?.observe(node, { childList: true })\n    queueMicrotask(measure)\n    return () => {\n      node.removeEventListener(\"scroll\", onScroll)\n      resize?.disconnect()\n      mutation?.disconnect()\n      if (timer.current !== null) clearTimeout(timer.current)\n    }\n  }, [node, axis])\n  return { edges, scrolling }\n}\n\nexport { useScrollEdges, REST_AFTER_MS }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "display"
  ],
  "type": "registry:ui"
}