{
  "$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"
  ],
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/components/scroll-area/scroll-area.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 styles from \"./scroll-area.module.css\";\n\ntype Axis = \"y\" | \"x\";\n\n/** Which edges still have content beyond them. */\nexport interface ScrollAreaEdges { top: boolean; bottom: boolean; left: boolean; right: boolean }\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\nexport const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(function ScrollArea({\n  children, orientation = \"vertical\", fade = 28, scrollbars = \"auto\", hideDelay = 900, maxHeight, snap, label,\n  wheelToHorizontal = true, viewportClassName, viewportStyle, viewportRef, onScroll, onEdgeChange, className, ...rest\n}, forwardedRef) {\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(() => { onEdgeRef.current = onEdgeChange; }, [onEdgeChange]);\n\n  const vertical = orientation !== \"horizontal\", 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, 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), 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 = { top: vertical && scrollTop > .5, bottom: vertical && maxY - scrollTop > .5, left: horizontal && left > .5, right: horizontal && maxX - left > .5 };\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], thumb = thumbs.current[axis];\n      if (!track || !thumb) return;\n      const overflowing = max > .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 > .5 || maxX > .5);\n\n    const key = `${edges.top}${edges.bottom}${edges.left}${edges.right}`;\n    if (key !== lastEdges.current) { lastEdges.current = key; onEdgeRef.current?.(edges); }\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(() => { observer.disconnect(); observer.observe(node); Array.from(node.children).forEach(child => observer.observe(child)); sync(); });\n    mutations.observe(node, { childList: true });\n    return () => { observer.disconnect(); mutations.disconnect(); };\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, at = Math.abs(node.scrollLeft);\n      if (max <= 0 || (event.deltaY < 0 && at <= 0) || (event.deltaY > 0 && at >= max - .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, track = tracks.current[axis], 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, size = axis === \"y\" ? thumb.offsetHeight : thumb.offsetWidth;\n    const max = axis === \"y\" ? node.scrollHeight - node.clientHeight : node.scrollWidth - node.clientWidth;\n    drag.current = { axis, start: axis === \"y\" ? event.clientY : event.clientX, scroll: axis === \"y\" ? node.scrollTop : node.scrollLeft, ratio: max / Math.max(1, length - size) };\n    rootRef.current?.setAttribute(\"data-dragging\", axis);\n    node.style.scrollSnapType = \"none\";\n  };\n  const onThumbMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const state = drag.current, 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; 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)) 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, 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) * .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) => <div ref={node => { tracks.current[axis] = node; }} className={styles.track} data-axis={axis} data-hidden=\"\" aria-hidden=\"true\"\n    onPointerDown={event => onTrackDown(event, axis)}>\n    <div ref={node => { thumbs.current[axis] = node; }} className={styles.thumb}\n      onPointerDown={event => onThumbDown(event, axis)} onPointerMove={onThumbMove} onPointerUp={onThumbUp} onPointerCancel={onThumbUp} />\n  </div>;\n\n  return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(\" \")} data-orientation={orientation} data-scrollbars={scrollbars} {...rest}>\n    <div ref={viewport} className={[styles.viewport, viewportClassName].filter(Boolean).join(\" \")} data-fade={fade > 0 ? orientation : undefined}\n      style={{ maxHeight, scrollSnapType: snap, ...viewportStyle }} tabIndex={0} role={label ? \"region\" : undefined} aria-label={label} onScroll={handleScroll}>\n      {children}\n    </div>\n    {vertical && bar(\"y\")}\n    {horizontal && bar(\"x\")}\n  </div>;\n});\n\nexport default ScrollArea;\n"
    },
    {
      "path": "registry/components/scroll-area/scroll-area.module.css",
      "type": "registry:component",
      "target": "@components/xzero/scroll-area/scroll-area.module.css",
      "content": ".root { position: relative; display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; }\n\n/* Native scrolling with the platform scrollbars hidden; the overlay bars below replace them. */\n.viewport {\n  --fade-top: 0px; --fade-bottom: 0px; --fade-left: 0px; --fade-right: 0px;\n  min-width: 0;\n  min-height: 0;\n  flex: 1 1 auto;\n  overscroll-behavior-x: contain;\n  scrollbar-width: none;\n  -webkit-overflow-scrolling: touch;\n}\n.viewport::-webkit-scrollbar { display: none; }\n.root[data-orientation=\"vertical\"] > .viewport { overflow-x: hidden; overflow-y: auto; }\n.root[data-orientation=\"horizontal\"] > .viewport { overflow-x: auto; overflow-y: hidden; }\n.root[data-orientation=\"both\"] > .viewport { overflow: auto; }\n\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. */\n.viewport[data-fade=\"vertical\"] {\n  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--fade-top), #000 calc(100% - var(--fade-bottom)), transparent);\n  mask-image: linear-gradient(to bottom, transparent, #000 var(--fade-top), #000 calc(100% - var(--fade-bottom)), transparent);\n}\n.viewport[data-fade=\"horizontal\"] {\n  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade-left), #000 calc(100% - var(--fade-right)), transparent);\n  mask-image: linear-gradient(to right, transparent, #000 var(--fade-left), #000 calc(100% - var(--fade-right)), transparent);\n}\n.viewport[data-fade=\"both\"] {\n  -webkit-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);\n  -webkit-mask-composite: source-in;\n  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);\n  mask-composite: intersect;\n}\n\n/* Overlay scrollbars: a generous hit area around a thin pill that thickens under the pointer. */\n.track {\n  position: absolute;\n  z-index: 1;\n  opacity: 0;\n  pointer-events: none;\n  touch-action: none;\n  transition: opacity var(--duration-considered) var(--ease-standard);\n}\n.track[data-axis=\"y\"] { top: 3px; right: 1px; bottom: 3px; width: 11px; }\n.track[data-axis=\"x\"] { right: 3px; bottom: 1px; left: 3px; height: 11px; }\n.root[data-orientation=\"both\"] > .track[data-axis=\"y\"] { bottom: 13px; }\n.root[data-orientation=\"both\"] > .track[data-axis=\"x\"] { right: 13px; }\n.track[data-hidden] { display: none; }\n\n.thumb { position: absolute; top: 0; left: 0; will-change: transform; }\n.track[data-axis=\"y\"] .thumb { width: 100%; }\n.track[data-axis=\"x\"] .thumb { height: 100%; }\n.thumb::after {\n  content: \"\";\n  position: absolute;\n  border-radius: var(--radius-pill);\n  background: color-mix(in oklab, var(--foreground) 30%, transparent);\n  transition: width var(--duration-fast) var(--ease-standard), height var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);\n}\n.track[data-axis=\"y\"] .thumb::after { top: 0; right: 3px; bottom: 0; width: 5px; }\n.track[data-axis=\"x\"] .thumb::after { right: 0; bottom: 3px; left: 0; height: 5px; }\n\n/* Visible while scrolling, while the pointer is over the area, and always when asked. Appearing is quick; leaving lingers. */\n.root[data-scrolling] > .track,\n.root[data-dragging] > .track,\n.root[data-scrollbars=\"always\"] > .track { opacity: 1; pointer-events: auto; transition-duration: var(--duration-fast); }\n\n@media (hover: hover) and (pointer: fine) {\n  .root:hover > .track { opacity: 1; pointer-events: auto; transition-duration: var(--duration-fast); }\n  .track:hover .thumb::after,\n  .root[data-dragging] .thumb::after { background: color-mix(in oklab, var(--foreground) 46%, transparent); }\n  .track[data-axis=\"y\"]:hover .thumb::after,\n  .root[data-dragging=\"y\"] .track[data-axis=\"y\"] .thumb::after { width: 7px; right: 2px; }\n  .track[data-axis=\"x\"]:hover .thumb::after,\n  .root[data-dragging=\"x\"] .track[data-axis=\"x\"] .thumb::after { height: 7px; bottom: 2px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .track, .thumb::after { transition: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/scroll-area",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "disclosure",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/scroll-area",
    "markdown": "https://ui.xzero.co.uk/components/scroll-area/markdown"
  }
}