{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scroll-area","type":"registry:ui","title":"Scroll Area","description":"A box that scrolls the browser's own way with a slim scrollbar of its own – it shows on hover and while scrolling, a draggable thumb, up and down or sideways, soft fades where there is more","dependencies":[],"files":[{"path":"registry/scroll-area/scroll-area.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type CSSProperties, type PointerEvent as ReactPointerEvent, type Ref, type UIEventHandler } from \"react\";\n\n/*\n * Scroll Area – a single self-contained file, classic style.\n * A box that scrolls the browser's own way – wheel, trackpad, touch, keyboard, find-in-page – with a slim scrollbar\n * of its own drawn over the content: it shows while you hover or scroll and fades out after, its thumb can be dragged\n * and a press on the track pages. Soft fades at the edges can hint that there is more. The box is a Tab stop only\n * while it actually scrolls, so the arrow keys and Page Up / Down reach it.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type ScrollAreaProps = Omit<ComponentPropsWithRef<\"div\">, \"onScroll\"> & {\n  /** Which way it scrolls – vertical, horizontal or both */\n  orientation?: \"vertical\" | \"horizontal\" | \"both\";\n  /** When the scrollbar shows: on hover and while scrolling · only while scrolling · always (when there is more) */\n  type?: \"hover\" | \"scroll\" | \"always\";\n  /** Soft fades at the edges where there is more to scroll */\n  fade?: boolean;\n  /** The most it grows before it scrolls – or give it a height with className / style */\n  maxHeight?: number | string;\n  /** The element that scrolls – for scrollTo, scrollTop or an observer */\n  viewportRef?: Ref<HTMLDivElement>;\n  /** Fires on the element that scrolls */\n  onScroll?: UIEventHandler<HTMLDivElement>;\n};\n\ntype Axis = \"y\" | \"x\";\n\nexport function ScrollArea({\n  orientation = \"vertical\",\n  type = \"hover\",\n  fade = false,\n  maxHeight,\n  viewportRef,\n  onScroll,\n  className,\n  style,\n  children,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n  ref,\n  ...rest\n}: ScrollAreaProps) {\n  const root = useRef<HTMLDivElement | null>(null);\n  const view = useRef<HTMLDivElement | null>(null);\n  const content = useRef<HTMLDivElement | null>(null);\n  const thumbs = useRef<Record<Axis, HTMLDivElement | null>>({ y: null, x: null });\n  const idle = useRef(0);\n  const drag = useRef<{ axis: Axis; from: number; start: number; per: number } | null>(null);\n  // Whether it overflows – the only thing that re-renders: the box becomes a Tab stop only then\n  const [scrolls, setScrolls] = useState(false);\n  const wantY = orientation !== \"horizontal\";\n  const wantX = orientation !== \"vertical\";\n\n  /** Sizes and moves the thumbs, sets the fades – straight on the elements, no re-render */\n  const update = useCallback(() => {\n    const v = view.current;\n    const r = root.current;\n    if (!v || !r) return;\n    const cs = getComputedStyle(r);\n    const rtl = cs.direction === \"rtl\";\n    const cap = parseFloat(cs.getPropertyValue(\"--ui-sa-fade\")) || 28;\n    const has = { y: wantY && v.scrollHeight - v.clientHeight > 1, x: wantX && v.scrollWidth - v.clientWidth > 1 };\n    for (const axis of [\"y\", \"x\"] as const) {\n      r.toggleAttribute(`data-scroll-${axis}`, has[axis]);\n      const t = thumbs.current[axis];\n      const bar = t?.parentElement;\n      if (!has[axis] || !t || !bar) continue;\n      const size = axis === \"y\" ? v.clientHeight : v.clientWidth;\n      const full = axis === \"y\" ? v.scrollHeight : v.scrollWidth;\n      const pos = Math.abs(axis === \"y\" ? v.scrollTop : v.scrollLeft);\n      const track = axis === \"y\" ? bar.clientHeight : bar.clientWidth;\n      const len = Math.max(Math.min(24, track), Math.round((track * size) / full));\n      const at = full - size > 0 ? ((track - len) * Math.min(pos, full - size)) / (full - size) : 0;\n      if (axis === \"y\") {\n        t.style.height = `${len}px`;\n        t.style.transform = `translate3d(0, ${at}px, 0)`;\n      } else {\n        t.style.width = `${len}px`;\n        t.style.transform = `translate3d(${rtl ? -at : at}px, 0, 0)`;\n      }\n      // How far from each end, capped – the fades shrink as you reach an end\n      const end = full - size - pos;\n      r.style.setProperty(`--ui-sa-${axis}-start`, `${Math.min(pos, cap)}px`);\n      r.style.setProperty(`--ui-sa-${axis}-end`, `${Math.min(Math.max(end, 0), cap)}px`);\n    }\n    if (!has.y) r.style.setProperty(\"--ui-sa-y-start\", \"0px\"), r.style.setProperty(\"--ui-sa-y-end\", \"0px\");\n    if (!has.x) r.style.setProperty(\"--ui-sa-x-start\", \"0px\"), r.style.setProperty(\"--ui-sa-x-end\", \"0px\");\n    setScrolls(has.x || has.y);\n  }, [wantX, wantY]);\n\n  // Follows the box and its content as either changes size – new rows, a wider window, fonts loading\n  useLayoutEffect(() => {\n    update();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(() => update());\n    if (view.current) ro.observe(view.current);\n    if (content.current) ro.observe(content.current);\n    return () => ro.disconnect();\n  }, [update]);\n\n  useEffect(() => () => window.clearTimeout(idle.current), []);\n\n  const scrolled: UIEventHandler<HTMLDivElement> = (e) => {\n    update();\n    // Shown while it moves, then it fades out a moment after the last scroll\n    const r = root.current;\n    if (r) {\n      r.setAttribute(\"data-scrolling\", \"\");\n      window.clearTimeout(idle.current);\n      idle.current = window.setTimeout(() => r.removeAttribute(\"data-scrolling\"), 800);\n    }\n    onScroll?.(e);\n  };\n\n  // Dragging the thumb: the pointer's way along the track, turned into the content's way\n  const grab = (axis: Axis) => (e: ReactPointerEvent<HTMLDivElement>) => {\n    const v = view.current;\n    const t = thumbs.current[axis];\n    const bar = t?.parentElement;\n    if (e.button !== 0 || !v || !t || !bar) return;\n    e.preventDefault();\n    e.stopPropagation();\n    // A zoomed or scaled card: pointer pixels vs. layout pixels\n    const rect = bar.getBoundingClientRect();\n    const scale = axis === \"y\" ? rect.height / (bar.offsetHeight || 1) : rect.width / (bar.offsetWidth || 1);\n    const track = axis === \"y\" ? bar.clientHeight : bar.clientWidth;\n    const len = axis === \"y\" ? t.offsetHeight : t.offsetWidth;\n    const room = axis === \"y\" ? v.scrollHeight - v.clientHeight : v.scrollWidth - v.clientWidth;\n    drag.current = { axis, from: axis === \"y\" ? e.clientY : e.clientX, start: axis === \"y\" ? v.scrollTop : v.scrollLeft, per: room / Math.max(1, track - len) / (scale || 1) };\n    t.setPointerCapture(e.pointerId);\n    root.current?.setAttribute(\"data-dragging\", \"\");\n  };\n  const move = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    const v = view.current;\n    if (!d || !v) return;\n    const by = ((d.axis === \"y\" ? e.clientY : e.clientX) - d.from) * d.per;\n    if (d.axis === \"y\") v.scrollTop = d.start + by;\n    else v.scrollLeft = d.start + by;\n  };\n  const drop = () => {\n    drag.current = null;\n    root.current?.removeAttribute(\"data-dragging\");\n  };\n\n  // A press on the track pages towards it, like the browser's own bar\n  const page = (axis: Axis) => (e: ReactPointerEvent<HTMLDivElement>) => {\n    const v = view.current;\n    const t = thumbs.current[axis];\n    if (e.button !== 0 || !v || !t || e.target === t) return;\n    e.preventDefault();\n    const tr = t.getBoundingClientRect();\n    const before = axis === \"y\" ? e.clientY < tr.top : getComputedStyle(v).direction === \"rtl\" ? e.clientX > tr.right : e.clientX < tr.left;\n    const step = (axis === \"y\" ? v.clientHeight : v.clientWidth) * 0.9 * (before ? -1 : 1);\n    const rtl = axis === \"x\" && getComputedStyle(v).direction === \"rtl\" ? -1 : 1;\n    const smooth = !window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n    v.scrollBy({ [axis === \"y\" ? \"top\" : \"left\"]: step * rtl, behavior: smooth ? \"smooth\" : \"auto\" });\n  };\n\n  // Stable, so your ref functions run once per element and not on every render\n  const setView = useCallback((el: HTMLDivElement | null) => {\n    view.current = el;\n    assign(viewportRef, el);\n  }, [viewportRef]);\n  const setRoot = useCallback((el: HTMLDivElement | null) => {\n    root.current = el;\n    assign(ref, el);\n  }, [ref]);\n\n  const bar = (axis: Axis) => (\n    <div className=\"ui-sa__bar\" data-axis={axis} aria-hidden=\"true\" onPointerDown={page(axis)}>\n      <div\n        className=\"ui-sa__thumb\"\n        ref={(el) => {\n          thumbs.current[axis] = el;\n        }}\n        onPointerDown={grab(axis)}\n        onPointerMove={move}\n        onPointerUp={drop}\n        onPointerCancel={drop}\n        onLostPointerCapture={drop}\n      />\n    </div>\n  );\n\n  return (\n    <div\n      ref={setRoot}\n      className={[\"ui-sa\", className].filter(Boolean).join(\" \")}\n      data-orientation={orientation}\n      data-type={type}\n      data-fade={fade ? \"\" : undefined}\n      style={maxHeight != null ? ({ maxHeight, ...style } as CSSProperties) : style}\n      {...rest}\n    >\n      {/* React 19 hoists this into <head> once, no matter how many scroll areas render */}\n      <style href=\"ui-scroll-area\" precedence=\"default\">\n        {css}\n      </style>\n      <div\n        ref={setView}\n        className=\"ui-sa__viewport\"\n        // A Tab stop only while there is something to scroll – the arrow keys and Page Up / Down then move it\n        tabIndex={scrolls ? 0 : undefined}\n        role={ariaLabel || ariaLabelledby ? \"region\" : undefined}\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledby}\n        onScroll={scrolled}\n      >\n        <div ref={content} className=\"ui-sa__content\">\n          {children}\n        </div>\n      </div>\n      {wantY && bar(\"y\")}\n      {wantX && bar(\"x\")}\n    </div>\n  );\n}\n\n/* ---------- Helpers ---------- */\n\n/** Hands an element to a ref of yours, a function or an object */\nfunction assign<T>(ref: Ref<T> | undefined, el: T | null) {\n  if (typeof ref === \"function\") ref(el);\n  else if (ref) (ref as { current: T | null }).current = el;\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* The fades' lengths animate as you near an end */\n@property --ui-sa-y-start { syntax: \"<length>\"; inherits: true; initial-value: 0px; }\n@property --ui-sa-y-end { syntax: \"<length>\"; inherits: true; initial-value: 0px; }\n@property --ui-sa-x-start { syntax: \"<length>\"; inherits: true; initial-value: 0px; }\n@property --ui-sa-x-end { syntax: \"<length>\"; inherits: true; initial-value: 0px; }\n\n/* Colors and sizes as variables – override them on .ui-sa (see Theming in the docs) */\n.ui-sa {\n  --ui-sa-thumb: rgba(0, 0, 0, 0.22);\n  --ui-sa-thumb-hover: rgba(0, 0, 0, 0.36);\n  --ui-sa-thumb-active: rgba(0, 0, 0, 0.48);\n  --ui-sa-size: 6px;\n  --ui-sa-size-hover: 8px;\n  --ui-sa-inset: 2px;\n  --ui-sa-fade: 28px;\n  --ui-sa-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-sa-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n\n  /* A column, so a height or max-height on the box is what the viewport fills */\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  box-sizing: border-box;\n  min-width: 0;\n  min-height: 0;\n  overflow: hidden;\n}\n.ui-sa *, .ui-sa *::before, .ui-sa *::after { box-sizing: border-box; }\n\n/* The element that scrolls: the browser's own scrolling, its bar hidden – ours is drawn on top */\n.ui-sa__viewport {\n  flex: 1 1 auto;\n  min-width: 0;\n  min-height: 0;\n  border-radius: inherit;\n  overflow: hidden;\n  overscroll-behavior: contain;\n  scrollbar-width: none;\n  -webkit-overflow-scrolling: touch;\n}\n.ui-sa__viewport::-webkit-scrollbar { display: none; }\n.ui-sa[data-orientation=\"vertical\"] > .ui-sa__viewport { overflow-y: auto; }\n.ui-sa[data-orientation=\"horizontal\"] > .ui-sa__viewport { overflow-x: auto; }\n.ui-sa[data-orientation=\"both\"] > .ui-sa__viewport { overflow: auto; }\n.ui-sa__viewport:focus { outline: none; }\n/* Inside the box, so no card edge or overflow cuts it */\n.ui-sa__viewport:focus-visible { outline: 2px solid var(--ui-sa-focus); outline-offset: -2px; }\n\n/* Sideways the content keeps its natural width, so a wide row really overflows; text still wraps at the box */\n.ui-sa:not([data-orientation=\"vertical\"]) .ui-sa__content { min-width: fit-content; }\n\n/* Soft fades where there is more – a mask, so it works on any background */\n.ui-sa[data-fade][data-scroll-y] > .ui-sa__viewport {\n  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--ui-sa-y-start), #000 calc(100% - var(--ui-sa-y-end)), transparent);\n  mask-image: linear-gradient(to bottom, transparent, #000 var(--ui-sa-y-start), #000 calc(100% - var(--ui-sa-y-end)), transparent);\n}\n.ui-sa[data-fade][data-scroll-x]:not([data-scroll-y]) > .ui-sa__viewport {\n  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--ui-sa-x-start), #000 calc(100% - var(--ui-sa-x-end)), transparent);\n  mask-image: linear-gradient(to right, transparent, #000 var(--ui-sa-x-start), #000 calc(100% - var(--ui-sa-x-end)), transparent);\n}\n.ui-sa[data-fade][dir=\"rtl\"][data-scroll-x]:not([data-scroll-y]) > .ui-sa__viewport,\n[dir=\"rtl\"] .ui-sa[data-fade][data-scroll-x]:not([data-scroll-y]) > .ui-sa__viewport {\n  -webkit-mask-image: linear-gradient(to left, transparent, #000 var(--ui-sa-x-start), #000 calc(100% - var(--ui-sa-x-end)), transparent);\n  mask-image: linear-gradient(to left, transparent, #000 var(--ui-sa-x-start), #000 calc(100% - var(--ui-sa-x-end)), transparent);\n}\n.ui-sa[data-fade] {\n  transition: --ui-sa-y-start 160ms ease, --ui-sa-y-end 160ms ease, --ui-sa-x-start 160ms ease, --ui-sa-x-end 160ms ease;\n}\n\n/* The bar: a 10px strip over the content's edge, the thumb in it – hidden until there is something to scroll */\n.ui-sa__bar {\n  position: absolute;\n  z-index: 1;\n  display: none;\n  opacity: 0;\n  touch-action: none;\n  user-select: none;\n  transition: opacity 280ms var(--ui-sa-ease);\n}\n.ui-sa__bar[data-axis=\"y\"] { top: var(--ui-sa-inset); bottom: var(--ui-sa-inset); inset-inline-end: 0; width: 10px; }\n.ui-sa__bar[data-axis=\"x\"] { left: var(--ui-sa-inset); right: var(--ui-sa-inset); bottom: 0; height: 10px; }\n/* Both bars: each stops short of the corner the other one runs into */\n.ui-sa[data-scroll-y][data-scroll-x] > .ui-sa__bar[data-axis=\"y\"] { bottom: 10px; }\n.ui-sa[data-scroll-y][data-scroll-x] > .ui-sa__bar[data-axis=\"x\"] { inset-inline-end: 10px; }\n.ui-sa[data-scroll-y] > .ui-sa__bar[data-axis=\"y\"], .ui-sa[data-scroll-x] > .ui-sa__bar[data-axis=\"x\"] { display: block; }\n\n.ui-sa__thumb {\n  position: absolute;\n  border-radius: 999px;\n  background: var(--ui-sa-thumb);\n  cursor: default;\n  transition: background-color 150ms ease, width 150ms var(--ui-sa-ease), height 150ms var(--ui-sa-ease);\n  will-change: transform;\n}\n/* A wider, invisible grab area around the thin thumb */\n.ui-sa__thumb::before { content: \"\"; position: absolute; inset: -4px; }\n.ui-sa__bar[data-axis=\"y\"] > .ui-sa__thumb { top: 0; inset-inline-end: var(--ui-sa-inset); width: var(--ui-sa-size); }\n.ui-sa__bar[data-axis=\"x\"] > .ui-sa__thumb { inset-inline-start: 0; bottom: var(--ui-sa-inset); height: var(--ui-sa-size); }\n.ui-sa__bar[data-axis=\"y\"]:hover > .ui-sa__thumb, .ui-sa[data-dragging] > .ui-sa__bar[data-axis=\"y\"] > .ui-sa__thumb { width: var(--ui-sa-size-hover); }\n.ui-sa__bar[data-axis=\"x\"]:hover > .ui-sa__thumb, .ui-sa[data-dragging] > .ui-sa__bar[data-axis=\"x\"] > .ui-sa__thumb { height: var(--ui-sa-size-hover); }\n.ui-sa__bar:hover > .ui-sa__thumb { background: var(--ui-sa-thumb-hover); }\n.ui-sa[data-dragging] .ui-sa__thumb { background: var(--ui-sa-thumb-active); }\n.ui-sa[data-dragging] { user-select: none; }\n\n/* When it shows: hover – while the pointer is over the box or it scrolls · scroll – only while it scrolls · always */\n.ui-sa[data-type=\"always\"] > .ui-sa__bar,\n.ui-sa[data-scrolling] > .ui-sa__bar,\n.ui-sa[data-dragging] > .ui-sa__bar { opacity: 1; transition-duration: 120ms; }\n@media (hover: hover) {\n  .ui-sa[data-type=\"hover\"]:hover > .ui-sa__bar { opacity: 1; transition-duration: 120ms; }\n}\n/* Faded out it lets the pointer through to the content under it */\n.ui-sa > .ui-sa__bar { pointer-events: none; }\n.ui-sa:is([data-type=\"always\"], [data-scrolling], [data-dragging]) > .ui-sa__bar { pointer-events: auto; }\n@media (hover: hover) {\n  .ui-sa[data-type=\"hover\"]:hover > .ui-sa__bar { pointer-events: auto; }\n}\n\n/* Touch screens: the finger scrolls the content – the bar only shows where you are, a little thicker */\n@media (pointer: coarse) {\n  .ui-sa { --ui-sa-size: 4px; --ui-sa-size-hover: 4px; }\n  .ui-sa > .ui-sa__bar { pointer-events: none; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-sa__bar, .ui-sa__thumb, .ui-sa[data-fade] { transition-duration: 1ms !important; }\n}\n\n/* Windows high contrast: the thumb keeps a system color */\n@media (forced-colors: active) {\n  .ui-sa__thumb { background: CanvasText; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-sa {\n  --ui-sa-thumb: rgba(255, 255, 255, 0.22);\n  --ui-sa-thumb-hover: rgba(255, 255, 255, 0.36);\n  --ui-sa-thumb-active: rgba(255, 255, 255, 0.5);\n  --ui-sa-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n}\n`;\n"}]}