{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scroll-area","type":"registry:component","title":"Scroll Area","description":"Native-scrolling container that keeps wheel, touch momentum and keyboard scrolling but hides the browser bar behind an overlay thumb (vertical, horizontal or both) that fades in on hover or scroll and out when idle, drags, pages on track click, and pairs with distance-scaled edge fade masks, all sized by a ResizeObserver.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/scroll-area.tsx","type":"registry:component","target":"@components/motion/scroll-area.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/scroll-area\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n  type CSSProperties,\n  type HTMLAttributes,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n// ScrollArea keeps the browser's own scrolling (wheel, touch momentum,\n// keyboard, scroll anchoring, find-in-page) and only replaces the *painted*\n// scrollbar: the native bar is hidden and an overlay thumb is drawn on top.\n// The thumb is a visual affordance (aria-hidden); keyboard and assistive tech\n// scroll the focusable viewport itself.\n\nexport type ScrollAreaOrientation = \"vertical\" | \"horizontal\" | \"both\";\n/** `hover`: visible while hovered or scrolling. `scroll`: only while scrolling. `always`: whenever content overflows. */\nexport type ScrollAreaType = \"hover\" | \"scroll\" | \"always\";\n\nexport interface ScrollAreaProps extends Omit<HTMLAttributes<HTMLDivElement>, \"dir\"> {\n  children?: ReactNode;\n  orientation?: ScrollAreaOrientation;\n  type?: ScrollAreaType;\n  /** Milliseconds of idle before the thumb fades out. */\n  scrollHideDelay?: number;\n  /** Fade the content out at edges that have more content beyond them. */\n  fadeEdges?: boolean;\n  /** Maximum fade length in px; it grows with the distance from the edge. */\n  fadeSize?: number;\n  /** Class for the scrolling viewport (padding usually belongs here). */\n  viewportClassName?: string;\n  /** Ref to the native scroll container, for `scrollTo` or virtualizers. */\n  viewportRef?: Ref<HTMLElement | null>;\n  /** Accessible name for the focusable scroll region. */\n  \"aria-label\"?: string;\n}\n\nconst MIN_THUMB = 24;\nconst TRACK_INSET = 2;\n/** Fraction of the visible size a track click pages by (keeps a little context). */\nconst PAGE_FACTOR = 0.9;\n\ntype Axis = \"x\" | \"y\";\n\nexport function ScrollArea({\n  children,\n  orientation = \"vertical\",\n  type = \"hover\",\n  scrollHideDelay = 700,\n  fadeEdges = true,\n  fadeSize = 28,\n  className,\n  viewportClassName,\n  viewportRef,\n  \"aria-label\": ariaLabel,\n  onPointerEnter,\n  onPointerLeave,\n  ...rest\n}: ScrollAreaProps) {\n  const reduce = useReducedMotion();\n  const viewport = useRef<HTMLElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const thumbs = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });\n  const tracks = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });\n  const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const hovering = useRef(false);\n  const dragging = useRef<{\n    axis: Axis;\n    pointer: number;\n    scroll: number;\n    ratio: number;\n  } | null>(null);\n\n  const [overflow, setOverflow] = useState({ x: false, y: false });\n  const [visible, setVisible] = useState(false);\n\n  useImperativeHandle(viewportRef, () => viewport.current as HTMLElement, []);\n\n  const allowX = orientation !== \"vertical\";\n  const allowY = orientation !== \"horizontal\";\n\n  // Geometry is written straight to the DOM on every scroll frame so a long\n  // list never re-renders React while scrolling.\n  const update = useCallback(() => {\n    const el = viewport.current;\n    if (!el) return;\n    const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth } = el;\n    const hasY = allowY && scrollHeight - clientHeight > 1;\n    const hasX = allowX && scrollWidth - clientWidth > 1;\n    setOverflow((prev) => (prev.x === hasX && prev.y === hasY ? prev : { x: hasX, y: hasY }));\n\n    const place = (axis: Axis) => {\n      const thumb = thumbs.current[axis];\n      const track = tracks.current[axis];\n      if (!thumb || !track) return;\n      const trackLen = axis === \"y\" ? track.clientHeight : track.clientWidth;\n      const view = axis === \"y\" ? clientHeight : clientWidth;\n      const total = axis === \"y\" ? scrollHeight : scrollWidth;\n      const pos = axis === \"y\" ? scrollTop : scrollLeft;\n      const len = Math.max(MIN_THUMB, Math.min(trackLen, (view / total) * trackLen));\n      const max = total - view;\n      const offset = max > 0 ? (pos / max) * (trackLen - len) : 0;\n      // The thumb's length is set (not animated) whenever content resizes;\n      // its motion along the track is a transform.\n      if (axis === \"y\") {\n        thumb.style.height = `${len}px`;\n        thumb.style.transform = `translate3d(0, ${offset}px, 0)`;\n      } else {\n        thumb.style.width = `${len}px`;\n        thumb.style.transform = `translate3d(${offset}px, 0, 0)`;\n      }\n    };\n    if (hasY) place(\"y\");\n    if (hasX) place(\"x\");\n\n    if (fadeEdges) {\n      // Fade length tracks the distance to each edge, so the mask eases in\n      // and out as you leave or reach an end instead of popping.\n      const top = hasY ? Math.min(fadeSize, scrollTop) : 0;\n      const bottom = hasY ? Math.min(fadeSize, scrollHeight - clientHeight - scrollTop) : 0;\n      const left = hasX ? Math.min(fadeSize, scrollLeft) : 0;\n      const right = hasX ? Math.min(fadeSize, scrollWidth - clientWidth - scrollLeft) : 0;\n      el.style.setProperty(\"--sa-top\", `${Math.max(0, top)}px`);\n      el.style.setProperty(\"--sa-bottom\", `${Math.max(0, bottom)}px`);\n      el.style.setProperty(\"--sa-left\", `${Math.max(0, left)}px`);\n      el.style.setProperty(\"--sa-right\", `${Math.max(0, right)}px`);\n    }\n  }, [allowX, allowY, fadeEdges, fadeSize]);\n\n  const scheduleHide = useCallback(() => {\n    clearTimeout(hideTimer.current);\n    hideTimer.current = setTimeout(() => {\n      if (dragging.current) return;\n      if (type === \"hover\" && hovering.current) return;\n      setVisible(false);\n    }, scrollHideDelay);\n  }, [scrollHideDelay, type]);\n\n  const reveal = useCallback(() => {\n    setVisible(true);\n    scheduleHide();\n  }, [scheduleHide]);\n\n  useEffect(() => {\n    const el = viewport.current;\n    const inner = content.current;\n    if (!el || !inner) return;\n    update();\n    const observer = new ResizeObserver(() => update());\n    observer.observe(el);\n    observer.observe(inner);\n    return () => observer.disconnect();\n  }, [update]);\n\n  // Bars mount only once overflow is known; place their thumbs right after.\n  useEffect(() => {\n    if (overflow.x || overflow.y) update();\n  }, [overflow.x, overflow.y, update]);\n\n  useEffect(() => () => clearTimeout(hideTimer.current), []);\n\n  const onScroll = () => {\n    update();\n    if (type !== \"always\") reveal();\n  };\n\n  const startDrag = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {\n    const el = viewport.current;\n    const track = tracks.current[axis];\n    const thumb = thumbs.current[axis];\n    if (!el || !track || !thumb || event.button !== 0) return;\n    event.preventDefault();\n    event.stopPropagation();\n    const trackLen = axis === \"y\" ? track.clientHeight : track.clientWidth;\n    const thumbLen = axis === \"y\" ? thumb.offsetHeight : thumb.offsetWidth;\n    const max = axis === \"y\" ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth;\n    dragging.current = {\n      axis,\n      pointer: axis === \"y\" ? event.clientY : event.clientX,\n      scroll: axis === \"y\" ? el.scrollTop : el.scrollLeft,\n      ratio: trackLen - thumbLen > 0 ? max / (trackLen - thumbLen) : 0,\n    };\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setVisible(true);\n    clearTimeout(hideTimer.current);\n  };\n\n  const onDragMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const drag = dragging.current;\n    const el = viewport.current;\n    if (!drag || !el) return;\n    const delta = (drag.axis === \"y\" ? event.clientY : event.clientX) - drag.pointer;\n    if (drag.axis === \"y\") el.scrollTop = drag.scroll + delta * drag.ratio;\n    else el.scrollLeft = drag.scroll + delta * drag.ratio;\n  };\n\n  const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (!dragging.current) return;\n    dragging.current = null;\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n    if (type !== \"always\") scheduleHide();\n  };\n\n  // Clicking the empty track pages one viewport toward the click, like a\n  // native scrollbar track.\n  const pageTrack = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {\n    const el = viewport.current;\n    const thumb = thumbs.current[axis];\n    if (!el || !thumb || event.button !== 0 || event.target !== event.currentTarget) return;\n    event.preventDefault();\n    const rect = thumb.getBoundingClientRect();\n    const before = axis === \"y\" ? event.clientY < rect.top : event.clientX < rect.left;\n    const page = (axis === \"y\" ? el.clientHeight : el.clientWidth) * PAGE_FACTOR;\n    const amount = before ? -page : page;\n    el.scrollBy({\n      top: axis === \"y\" ? amount : 0,\n      left: axis === \"x\" ? amount : 0,\n      behavior: reduce ? \"auto\" : \"smooth\",\n    });\n  };\n\n  const fade = \"linear-gradient(to bottom, transparent 0, black var(--sa-top), black calc(100% - var(--sa-bottom)), transparent 100%)\";\n  const fadeX = \"linear-gradient(to right, transparent 0, black var(--sa-left), black calc(100% - var(--sa-right)), transparent 100%)\";\n  const mask = fadeEdges\n    ? allowX && allowY\n      ? `${fade}, ${fadeX}`\n      : allowX\n        ? fadeX\n        : fade\n    : undefined;\n  const viewportStyle: CSSProperties | undefined = mask\n    ? ({\n        \"--sa-top\": \"0px\",\n        \"--sa-bottom\": \"0px\",\n        \"--sa-left\": \"0px\",\n        \"--sa-right\": \"0px\",\n        maskImage: mask,\n        WebkitMaskImage: mask,\n        maskComposite: allowX && allowY ? \"intersect\" : undefined,\n        WebkitMaskComposite: allowX && allowY ? \"source-in\" : undefined,\n      } as CSSProperties)\n    : undefined;\n\n  const shown = type === \"always\" || visible;\n  const both = overflow.x && overflow.y;\n\n  const renderBar = (axis: Axis) => (\n    <motion.div\n      aria-hidden\n      ref={(node: HTMLDivElement | null) => {\n        tracks.current[axis] = node;\n      }}\n      data-orientation={axis === \"y\" ? \"vertical\" : \"horizontal\"}\n      initial={false}\n      animate={{ opacity: shown ? 1 : 0 }}\n      transition={{ duration: shown ? 0.15 : 0.3, ease: EASE_OUT }}\n      onPointerDown={pageTrack(axis)}\n      className={cn(\n        \"group/scroll-track absolute z-10 flex touch-none select-none\",\n        !shown && \"pointer-events-none\",\n        axis === \"y\" ? \"w-2.5 justify-center\" : \"h-2.5 flex-col justify-center\",\n      )}\n      style={\n        axis === \"y\"\n          ? { top: TRACK_INSET, right: TRACK_INSET, bottom: both ? 12 : TRACK_INSET }\n          : { left: TRACK_INSET, bottom: TRACK_INSET, right: both ? 12 : TRACK_INSET }\n      }\n    >\n      <div\n        ref={(node) => {\n          thumbs.current[axis] = node;\n        }}\n        onPointerDown={startDrag(axis)}\n        onPointerMove={onDragMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        className={cn(\n          \"absolute rounded-full bg-foreground/25 transition-colors hover:bg-foreground/45 active:bg-foreground/55 will-change-transform\",\n          axis === \"y\" ? \"top-0 w-1.5 group-hover/scroll-track:w-2\" : \"left-0 h-1.5 group-hover/scroll-track:h-2\",\n        )}\n      />\n    </motion.div>\n  );\n\n  return (\n    <div\n      className={cn(\"relative overflow-hidden\", className)}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        hovering.current = true;\n        if (type === \"hover\") {\n          setVisible(true);\n          clearTimeout(hideTimer.current);\n        }\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        hovering.current = false;\n        if (type === \"hover\") scheduleHide();\n      }}\n      {...rest}\n    >\n      {/* The viewport is focusable (tabIndex 0) so keyboard users can scroll\n          it (WCAG 2.1.1); pass `aria-label` to name the region. */}\n      <section\n        ref={viewport}\n        aria-label={ariaLabel}\n        // biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be focusable for keyboard scrolling (WCAG 2.1.1).\n        tabIndex={0}\n        onScroll={onScroll}\n        style={viewportStyle}\n        className={cn(\n          \"size-full outline-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n          \"focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset\",\n          allowY ? \"overflow-y-auto\" : \"overflow-y-hidden\",\n          allowX ? \"overflow-x-auto\" : \"overflow-x-hidden\",\n          viewportClassName,\n        )}\n      >\n        {/* Horizontal content is laid out at its own width so the observer\n            sees it grow; vertical content keeps the viewport's width. */}\n        <div ref={content} className={cn(allowX ? \"w-max min-w-full\" : \"min-w-full\")}>\n          {children}\n        </div>\n      </section>\n      {overflow.y ? renderBar(\"y\") : null}\n      {overflow.x ? renderBar(\"x\") : null}\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}