{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-area",
  "type": "registry:ui",
  "title": "Scroll area",
  "description": "A native scroll container with thin overlay scrollbars and edge fades that appear only when content overflows.",
  "categories": [
    "disclosure"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/scroll-area/scroll-area.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef } from \"react\";\nimport type { CSSProperties, HTMLAttributes, PointerEvent as ReactPointerEvent, ReactNode, Ref, UIEvent } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Axis = \"y\" | \"x\";\n\n/** Which edges still have content beyond them. */\nexport interface ScrollAreaEdges {\n  top: boolean;\n  bottom: boolean;\n  left: boolean;\n  right: boolean;\n}\n\n/**\n * A native scroll container with thin overlay scrollbars that appear while scrolling or on hover and fade away at rest.\n * Each edge fades out only when there is more content beyond it, in proportion to how much. Scrolling stays native, so\n * keyboard, touch momentum, and scroll snap all behave as the platform expects.\n */\nexport interface ScrollAreaProps extends Omit<HTMLAttributes<HTMLDivElement>, \"onScroll\"> {\n  children?: ReactNode;\n  /** Axes that scroll. Defaults to vertical. */\n  orientation?: \"vertical\" | \"horizontal\" | \"both\";\n  /** Length of the edge fade in px. 0 turns the fades off. */\n  fade?: number;\n  /** \"auto\" shows scrollbars while scrolling or hovering; \"always\" keeps them visible when content overflows. */\n  scrollbars?: \"auto\" | \"always\";\n  /** How long scrollbars linger after scrolling stops, in ms. */\n  hideDelay?: number;\n  /** Maximum height of the viewport, for vertical areas that should grow with their content. */\n  maxHeight?: CSSProperties[\"maxHeight\"];\n  /** Passed to the viewport's `scroll-snap-type`, for example \"x mandatory\". Children set their own `scroll-snap-align`. */\n  snap?: CSSProperties[\"scrollSnapType\"];\n  /** Accessible name. The viewport becomes a labelled, focusable region that arrow and page keys scroll. */\n  label?: string;\n  /** Turns vertical wheel movement into horizontal scrolling for horizontal areas. Defaults to true. */\n  wheelToHorizontal?: boolean;\n  viewportClassName?: string;\n  viewportStyle?: CSSProperties;\n  viewportRef?: Ref<HTMLDivElement>;\n  onScroll?: (event: UIEvent<HTMLDivElement>) => void;\n  /** Called when content starts or stops extending past an edge. */\n  onEdgeChange?: (edges: ScrollAreaEdges) => void;\n}\n\nconst MIN_THUMB = 28;\n\n/** Native scrolling with the platform scrollbars hidden; the overlay bars replace them. */\nconst overflow = {\n  vertical: \"overflow-x-hidden overflow-y-auto\",\n  horizontal: \"overflow-x-auto overflow-y-hidden\",\n  both: \"overflow-auto\",\n} as const;\n/** Each fade is exactly as long as the content hidden past that edge, up to the fade size, so it grows in with the first pixels of scroll. */\nconst fades = {\n  vertical:\n    \"[mask-image:linear-gradient(to_bottom,transparent,#000_var(--fade-top),#000_calc(100%_-_var(--fade-bottom)),transparent)]\",\n  horizontal:\n    \"[mask-image:linear-gradient(to_right,transparent,#000_var(--fade-left),#000_calc(100%_-_var(--fade-right)),transparent)]\",\n  both: \"[mask-image:linear-gradient(to_bottom,transparent,#000_var(--fade-top),#000_calc(100%_-_var(--fade-bottom)),transparent),linear-gradient(to_right,transparent,#000_var(--fade-left),#000_calc(100%_-_var(--fade-right)),transparent)] [mask-composite:intersect]\",\n} as const;\n/** Bars show while scrolling, while dragging, while the pointer is over the area, and always when asked. Appearing is quick; leaving lingers. */\nconst bars =\n  \"[&[data-scrolling]>[data-axis]]:pointer-events-auto [&[data-scrolling]>[data-axis]]:opacity-100 [&[data-scrolling]>[data-axis]]:duration-fast \" +\n  \"[&[data-dragging]>[data-axis]]:pointer-events-auto [&[data-dragging]>[data-axis]]:opacity-100 [&[data-dragging]>[data-axis]]:duration-fast \" +\n  \"[&[data-scrollbars=always]>[data-axis]]:pointer-events-auto [&[data-scrollbars=always]>[data-axis]]:opacity-100 [&[data-scrollbars=always]>[data-axis]]:duration-fast \" +\n  \"hover:[&>[data-axis]]:pointer-events-auto hover:[&>[data-axis]]:opacity-100 hover:[&>[data-axis]]:duration-fast\";\n/** Overlay scrollbars: a generous hit area around a thin pill that thickens under the pointer or while its thumb is dragged. */\nconst track = {\n  y: \"top-[3px] right-px bottom-[3px] w-[11px]\",\n  x: \"right-[3px] bottom-px left-[3px] h-[11px]\",\n} as const;\nconst thumb = {\n  y: \"w-full after:inset-y-0 after:right-[3px] after:w-[5px] group-hover/track:after:right-[2px] group-hover/track:after:w-[7px] group-data-[dragging=y]/area:after:right-[2px] group-data-[dragging=y]/area:after:w-[7px]\",\n  x: \"h-full after:inset-x-0 after:bottom-[3px] after:h-[5px] group-hover/track:after:bottom-[2px] group-hover/track:after:h-[7px] group-data-[dragging=x]/area:after:bottom-[2px] group-data-[dragging=x]/area:after:h-[7px]\",\n} as const;\n\nexport const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(function ScrollArea(\n  {\n    children,\n    orientation = \"vertical\",\n    fade = 28,\n    scrollbars = \"auto\",\n    hideDelay = 900,\n    maxHeight,\n    snap,\n    label,\n    wheelToHorizontal = true,\n    viewportClassName,\n    viewportStyle,\n    viewportRef,\n    onScroll,\n    onEdgeChange,\n    className,\n    ...rest\n  },\n  forwardedRef,\n) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const viewport = useRef<HTMLDivElement>(null);\n  const tracks = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });\n  const thumbs = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });\n  const hideTimer = useRef<number | undefined>(undefined);\n  const lastEdges = useRef(\"\");\n  const onEdgeRef = useRef(onEdgeChange);\n  useImperativeHandle(forwardedRef, () => rootRef.current as HTMLDivElement);\n  useImperativeHandle(viewportRef, () => viewport.current as HTMLDivElement);\n  useEffect(() => {\n    onEdgeRef.current = onEdgeChange;\n  }, [onEdgeChange]);\n\n  const vertical = orientation !== \"horizontal\",\n    horizontal = orientation !== \"vertical\";\n\n  /* Everything visual is written straight to the DOM on each scroll frame, so scrolling never re-renders React. */\n  const sync = useCallback(() => {\n    const node = viewport.current,\n      root = rootRef.current;\n    if (!node || !root) return;\n    const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth } = node;\n    const maxY = Math.max(0, scrollHeight - clientHeight),\n      maxX = Math.max(0, scrollWidth - clientWidth);\n    // RTL layouts report negative scrollLeft; distance from the start edge is its magnitude.\n    const left = Math.abs(scrollLeft);\n    const edges: ScrollAreaEdges = {\n      top: vertical && scrollTop > 0.5,\n      bottom: vertical && maxY - scrollTop > 0.5,\n      left: horizontal && left > 0.5,\n      right: horizontal && maxX - left > 0.5,\n    };\n\n    if (fade > 0) {\n      node.style.setProperty(\"--fade-top\", `${vertical ? Math.min(fade, scrollTop) : 0}px`);\n      node.style.setProperty(\"--fade-bottom\", `${vertical ? Math.min(fade, maxY - scrollTop) : 0}px`);\n      node.style.setProperty(\"--fade-left\", `${horizontal ? Math.min(fade, left) : 0}px`);\n      node.style.setProperty(\"--fade-right\", `${horizontal ? Math.min(fade, maxX - left) : 0}px`);\n    }\n\n    const place = (axis: Axis, scroll: number, max: number, client: number, total: number) => {\n      const track = tracks.current[axis],\n        thumb = thumbs.current[axis];\n      if (!track || !thumb) return;\n      const overflowing = max > 0.5;\n      track.toggleAttribute(\"data-hidden\", !overflowing);\n      if (!overflowing) return;\n      const length = axis === \"y\" ? track.clientHeight : track.clientWidth;\n      const size = Math.max(MIN_THUMB, (length * client) / total);\n      const offset = (length - size) * (scroll / max);\n      thumb.style[axis === \"y\" ? \"height\" : \"width\"] = `${size}px`;\n      thumb.style.transform = axis === \"y\" ? `translate3d(0, ${offset}px, 0)` : `translate3d(${offset}px, 0, 0)`;\n    };\n    if (vertical) place(\"y\", scrollTop, maxY, clientHeight, scrollHeight);\n    if (horizontal) place(\"x\", left, maxX, clientWidth, scrollWidth);\n    root.toggleAttribute(\"data-overflow\", maxY > 0.5 || maxX > 0.5);\n\n    const key = `${edges.top}${edges.bottom}${edges.left}${edges.right}`;\n    if (key !== lastEdges.current) {\n      lastEdges.current = key;\n      onEdgeRef.current?.(edges);\n    }\n  }, [fade, horizontal, vertical]);\n\n  useLayoutEffect(() => {\n    const node = viewport.current;\n    if (!node) return;\n    sync();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => sync());\n    observer.observe(node);\n    Array.from(node.children).forEach((child) => observer.observe(child));\n    const mutations = new MutationObserver(() => {\n      observer.disconnect();\n      observer.observe(node);\n      Array.from(node.children).forEach((child) => observer.observe(child));\n      sync();\n    });\n    mutations.observe(node, { childList: true });\n    return () => {\n      observer.disconnect();\n      mutations.disconnect();\n    };\n  }, [sync]);\n\n  useEffect(() => () => window.clearTimeout(hideTimer.current), []);\n\n  const wake = () => {\n    const root = rootRef.current;\n    if (!root) return;\n    root.setAttribute(\"data-scrolling\", \"\");\n    window.clearTimeout(hideTimer.current);\n    hideTimer.current = window.setTimeout(() => root.removeAttribute(\"data-scrolling\"), hideDelay);\n  };\n\n  const handleScroll = (event: UIEvent<HTMLDivElement>) => {\n    sync();\n    wake();\n    onScroll?.(event);\n  };\n\n  // A mouse wheel over a horizontal strip moves it sideways, but only while there is room, so the page still scrolls at the ends.\n  useEffect(() => {\n    const node = viewport.current;\n    if (!node || orientation !== \"horizontal\" || !wheelToHorizontal) return;\n    const wheel = (event: WheelEvent) => {\n      if (event.ctrlKey || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return;\n      const max = node.scrollWidth - node.clientWidth,\n        at = Math.abs(node.scrollLeft);\n      if (max <= 0 || (event.deltaY < 0 && at <= 0) || (event.deltaY > 0 && at >= max - 0.5)) return;\n      event.preventDefault();\n      node.scrollLeft += event.deltaY * (getComputedStyle(node).direction === \"rtl\" ? -1 : 1);\n    };\n    node.addEventListener(\"wheel\", wheel, { passive: false });\n    return () => node.removeEventListener(\"wheel\", wheel);\n  }, [orientation, wheelToHorizontal]);\n\n  /* Dragging a thumb maps pointer travel to scroll distance; pressing the track pages toward the pointer. */\n  const drag = useRef<{ axis: Axis; start: number; scroll: number; ratio: number } | null>(null);\n  const onThumbDown = (event: ReactPointerEvent<HTMLDivElement>, axis: Axis) => {\n    const node = viewport.current,\n      track = tracks.current[axis],\n      thumb = thumbs.current[axis];\n    if (!node || !track || !thumb || event.button !== 0) return;\n    event.preventDefault();\n    event.stopPropagation();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const length = axis === \"y\" ? track.clientHeight : track.clientWidth,\n      size = axis === \"y\" ? thumb.offsetHeight : thumb.offsetWidth;\n    const max = axis === \"y\" ? node.scrollHeight - node.clientHeight : node.scrollWidth - node.clientWidth;\n    drag.current = {\n      axis,\n      start: axis === \"y\" ? event.clientY : event.clientX,\n      scroll: axis === \"y\" ? node.scrollTop : node.scrollLeft,\n      ratio: max / Math.max(1, length - size),\n    };\n    rootRef.current?.setAttribute(\"data-dragging\", axis);\n    node.style.scrollSnapType = \"none\";\n  };\n  const onThumbMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const state = drag.current,\n      node = viewport.current;\n    if (!state || !node) return;\n    const delta = ((state.axis === \"y\" ? event.clientY : event.clientX) - state.start) * state.ratio;\n    if (state.axis === \"y\") node.scrollTop = state.scroll + delta;\n    else node.scrollLeft = state.scroll + delta;\n  };\n  const onThumbUp = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (!drag.current) return;\n    drag.current = null;\n    if (event.currentTarget.hasPointerCapture(event.pointerId))\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    rootRef.current?.removeAttribute(\"data-dragging\");\n    if (viewport.current) viewport.current.style.scrollSnapType = \"\";\n    wake();\n  };\n  const onTrackDown = (event: ReactPointerEvent<HTMLDivElement>, axis: Axis) => {\n    const node = viewport.current,\n      thumb = thumbs.current[axis];\n    if (!node || !thumb || event.button !== 0 || event.target !== event.currentTarget) return;\n    const rect = thumb.getBoundingClientRect();\n    const before = axis === \"y\" ? event.clientY < rect.top : event.clientX < rect.left;\n    const page = (axis === \"y\" ? node.clientHeight : node.clientWidth) * 0.9 * (before ? -1 : 1);\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    node.scrollBy({ [axis === \"y\" ? \"top\" : \"left\"]: page, behavior: reduce ? \"auto\" : \"smooth\" });\n  };\n\n  const bar = (axis: Axis) => (\n    <div\n      ref={(node) => {\n        tracks.current[axis] = node;\n      }}\n      className={cn(\n        \"group/track pointer-events-none absolute z-1 touch-none opacity-0 transition-opacity duration-considered ease-standard data-hidden:hidden motion-reduce:transition-none\",\n        track[axis],\n        orientation === \"both\" && (axis === \"y\" ? \"bottom-[13px]\" : \"right-[13px]\"),\n      )}\n      data-axis={axis}\n      data-hidden=\"\"\n      aria-hidden=\"true\"\n      onPointerDown={(event) => onTrackDown(event, axis)}\n    >\n      <div\n        ref={(node) => {\n          thumbs.current[axis] = node;\n        }}\n        className={cn(\n          \"absolute top-0 left-0 will-change-transform after:absolute after:rounded-full after:bg-[color-mix(in_oklab,var(--ink)_30%,transparent)] after:transition-[width,height,background-color] after:duration-fast after:ease-standard after:content-[''] group-hover/track:after:bg-[color-mix(in_oklab,var(--ink)_46%,transparent)] group-data-dragging/area:after:bg-[color-mix(in_oklab,var(--ink)_46%,transparent)] motion-reduce:after:transition-none\",\n          thumb[axis],\n        )}\n        onPointerDown={(event) => onThumbDown(event, axis)}\n        onPointerMove={onThumbMove}\n        onPointerUp={onThumbUp}\n        onPointerCancel={onThumbUp}\n      />\n    </div>\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"group/area relative flex min-h-0 min-w-0 flex-col overflow-hidden\", bars, className)}\n      data-orientation={orientation}\n      data-scrollbars={scrollbars}\n      {...rest}\n    >\n      <div\n        ref={viewport}\n        className={cn(\n          \"min-h-0 min-w-0 flex-auto overscroll-x-contain [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [--fade-bottom:0px] [--fade-left:0px] [--fade-right:0px] [--fade-top:0px] [&::-webkit-scrollbar]:hidden\",\n          overflow[orientation],\n          fade > 0 && fades[orientation],\n          viewportClassName,\n        )}\n        data-fade={fade > 0 ? orientation : undefined}\n        style={{ maxHeight, scrollSnapType: snap, ...viewportStyle }}\n        tabIndex={0}\n        role={label ? \"region\" : undefined}\n        aria-label={label}\n        onScroll={handleScroll}\n      >\n        {children}\n      </div>\n      {vertical && bar(\"y\")}\n      {horizontal && bar(\"x\")}\n    </div>\n  );\n});\n\nexport default ScrollArea;\n",
      "type": "registry:component",
      "target": "@components/iris/scroll-area/scroll-area.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/scroll-area",
  "meta": {
    "kind": "component",
    "group": "Overlays & disclosure",
    "export": "ScrollArea",
    "tags": [
      "disclosure",
      "new"
    ],
    "docs": "https://irisui.dev/components/scroll-area"
  }
}
