{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "scroll-area",
  "title": "Scroll Area",
  "type": "registry:ui",
  "description": "Custom scrollbar that always renders the same way across OSes (no flashing native scrollbars on Windows). Use for sidebars, dropdown content, and any overflow region you want to feel consistent.",
  "categories": [
    "layout"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/scroll-area/scroll-area.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nexport type ScrollAreaType = 'hover' | 'always' | 'scroll' | 'auto'\n\n/**\n * <uip-scroll-area> — the registry ScrollArea (Root + Viewport + ScrollBar +\n * Thumb) as ONE web component with native scrolling and custom overlay thumbs.\n *\n *   <uip-scroll-area class=\"h-48 max-w-xs rounded-md border\">\n *     …overflowing content…\n *   </uip-scroll-area>\n *\n * The viewport scrolls natively (keyboard, touch, wheel all work); the native\n * scrollbars are hidden and overlay tracks render instead, sized and\n * positioned from the viewport's scroll metrics. Class strings are React's\n * verbatim: the tracks carry the ScrollBar classes (vertical `h-full w-2.5`,\n * horizontal `h-2.5 flex-col`), the thumbs the ScrollAreaThumb classes\n * (`bg-border relative flex-1 rounded-full`), positioned with an explicit\n * height/top (vertical) or width/left (horizontal) like Radix does. Thumbs\n * drag to scroll; clicking a track jumps toward the pointer.\n *\n * `type` mirrors Radix: `hover` (default — thumbs fade in on hover, focus, or\n * while scrolling), `always` / `auto` (visible whenever the axis overflows),\n * `scroll` (visible only while scrolling). Each axis renders its track only\n * when it overflows, so single-axis content shows a single scrollbar — React's\n * vertical-always + opt-in horizontal falls out naturally.\n *\n * `scroll` events don't cross the shadow boundary, so viewport scrolling is\n * re-dispatched as a `scroll` event (bubbles, composed) on the host.\n * `scrollTo()` / `scrollBy()` forward to the viewport.\n *\n * Parts: `base` (the positioned root), `viewport`, `scrollbar` (both tracks),\n * `thumb` (both thumbs).\n */\nexport class UipScrollArea extends LitElement {\n  // React's root is a block <div>.\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    type: { reflect: true },\n    canV: { state: true },\n    canH: { state: true },\n    thumbV: { state: true },\n    thumbH: { state: true },\n    interacting: { state: true },\n  }\n\n  type: ScrollAreaType = 'hover'\n  private canV = false\n  private canH = false\n  private thumbV = { size: 0, offset: 0 }\n  private thumbH = { size: 0, offset: 0 }\n  /** Hover, focus-within, or mid-scroll — drives `hover`/`scroll` visibility. */\n  private interacting = false\n  private scrollEndTimer?: ReturnType<typeof setTimeout>\n  private resizeObserver?: ResizeObserver\n  private drag?: { axis: 'v' | 'h'; pointerId: number; grabOffset: number }\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'scroll-area')\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.resizeObserver?.disconnect()\n    clearTimeout(this.scrollEndTimer)\n  }\n\n  private get viewport() {\n    return this.renderRoot.querySelector<HTMLElement>('[data-slot=\"scroll-area-viewport\"]')\n  }\n\n  protected firstUpdated() {\n    this.sync()\n    this.resizeObserver = new ResizeObserver(() => this.sync())\n    const vp = this.viewport\n    if (vp) this.resizeObserver.observe(vp)\n  }\n\n  /** Forwarded viewport scrolling (same overloads as Element.scrollTo). */\n  scrollTo(options?: ScrollToOptions): void\n  scrollTo(x: number, y: number): void\n  scrollTo(xOrOptions?: number | ScrollToOptions, y?: number) {\n    const vp = this.viewport\n    if (!vp) return\n    if (typeof xOrOptions === 'number') vp.scrollTo(xOrOptions, y ?? 0)\n    else vp.scrollTo(xOrOptions)\n  }\n\n  /** Forwarded viewport scrolling (same overloads as Element.scrollBy). */\n  scrollBy(options?: ScrollToOptions): void\n  scrollBy(x: number, y: number): void\n  scrollBy(xOrOptions?: number | ScrollToOptions, y?: number) {\n    const vp = this.viewport\n    if (!vp) return\n    if (typeof xOrOptions === 'number') vp.scrollBy(xOrOptions, y ?? 0)\n    else vp.scrollBy(xOrOptions)\n  }\n\n  private sync() {\n    const vp = this.viewport\n    if (!vp) return\n    const canV = vp.scrollHeight - vp.clientHeight > 1\n    const canH = vp.scrollWidth - vp.clientWidth > 1\n    this.canV = canV\n    this.canH = canH\n    this.thumbV = this.metrics(vp.scrollTop, vp.scrollHeight, vp.clientHeight)\n    this.thumbH = this.metrics(vp.scrollLeft, vp.scrollWidth, vp.clientWidth)\n  }\n\n  /** Thumb size/offset as track percentages (Radix-style proportional thumb). */\n  private metrics(pos: number, total: number, visible: number) {\n    if (total <= visible || total <= 0) return { size: 0, offset: 0 }\n    const size = Math.max((visible / total) * 100, 4)\n    const max = total - visible\n    const offset = max > 0 ? (Math.min(Math.max(pos, 0), max) / max) * (100 - size) : 0\n    return { size, offset }\n  }\n\n  private onScroll = () => {\n    this.sync()\n    this.dispatchEvent(new Event('scroll', { bubbles: true, composed: true }))\n    // `scroll` (and the tail of `hover`) visibility: stay \"interacting\" until\n    // 300ms after the last scroll event.\n    this.interacting = true\n    clearTimeout(this.scrollEndTimer)\n    this.scrollEndTimer = setTimeout(() => (this.interacting = false), 300)\n  }\n\n  private onPointerEnter = () => (this.interacting = true)\n  private onPointerLeave = () => (this.interacting = false)\n  private onFocusIn = () => (this.interacting = true)\n  private onFocusOut = (e: FocusEvent) => {\n    if (e.relatedTarget && this.renderRoot.contains(e.relatedTarget as Node)) return\n    this.interacting = false\n  }\n\n  private trackVisible() {\n    if (this.type === 'always' || this.type === 'auto') return true\n    return this.interacting\n  }\n\n  // --- thumb drag + track jump -------------------------------------------------\n  private onThumbPointerDown(axis: 'v' | 'h', e: PointerEvent) {\n    const vp = this.viewport\n    const thumb = e.currentTarget as HTMLElement\n    const track = thumb.parentElement\n    if (!vp || !track) return\n    e.preventDefault()\n    e.stopPropagation()\n    const thumbRect = thumb.getBoundingClientRect()\n    this.drag = {\n      axis,\n      pointerId: e.pointerId,\n      grabOffset: axis === 'v' ? e.clientY - thumbRect.top : e.clientX - thumbRect.left,\n    }\n    this.interacting = true\n    thumb.setPointerCapture(e.pointerId)\n    const move = (ev: PointerEvent) => {\n      if (!this.drag || ev.pointerId !== this.drag.pointerId) return\n      const r = track.getBoundingClientRect()\n      if (axis === 'v') {\n        const ratio = (ev.clientY - r.top - this.drag.grabOffset) / (r.height - thumbRect.height || 1)\n        vp.scrollTop = ratio * (vp.scrollHeight - vp.clientHeight)\n      } else {\n        const ratio = (ev.clientX - r.left - this.drag.grabOffset) / (r.width - thumbRect.width || 1)\n        vp.scrollLeft = ratio * (vp.scrollWidth - vp.clientWidth)\n      }\n    }\n    const up = (ev: PointerEvent) => {\n      if (ev.pointerId !== this.drag?.pointerId) return\n      this.drag = undefined\n      thumb.removeEventListener('pointermove', move)\n      thumb.removeEventListener('pointerup', up)\n      thumb.removeEventListener('pointercancel', up)\n    }\n    thumb.addEventListener('pointermove', move)\n    thumb.addEventListener('pointerup', up)\n    thumb.addEventListener('pointercancel', up)\n  }\n\n  private onTrackPointerDown(axis: 'v' | 'h', e: PointerEvent) {\n    // Thumb drags stop propagation; anything reaching here is a track click.\n    const vp = this.viewport\n    const track = e.currentTarget as HTMLElement\n    if (!vp) return\n    const r = track.getBoundingClientRect()\n    if (axis === 'v') {\n      const ratio = (e.clientY - r.top) / (r.height || 1)\n      vp.scrollTo({ top: ratio * vp.scrollHeight - vp.clientHeight / 2 })\n    } else {\n      const ratio = (e.clientX - r.left) / (r.width || 1)\n      vp.scrollTo({ left: ratio * vp.scrollWidth - vp.clientWidth / 2 })\n    }\n  }\n\n  render() {\n    const show = this.trackVisible()\n    const fade = show ? 'opacity-100' : 'pointer-events-none opacity-0'\n    const scrollable = this.canV || this.canH\n    return html`<div\n      part=\"base\"\n      data-uipkge=\"\"\n      data-slot=\"scroll-area\"\n      class=\"relative size-full\"\n      @pointerenter=${this.onPointerEnter}\n      @pointerleave=${this.onPointerLeave}\n      @focusin=${this.onFocusIn}\n      @focusout=${this.onFocusOut}\n    >\n      <div\n        part=\"viewport\"\n        data-uipkge=\"\"\n        data-slot=\"scroll-area-viewport\"\n        tabindex=${scrollable ? '0' : nothing}\n        class=\"focus-visible:ring-ring/50 size-full overflow-auto rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n        @scroll=${this.onScroll}\n      >\n        <slot @slotchange=${() => this.sync()}></slot>\n      </div>\n      ${this.canV\n        ? html`<div\n            part=\"scrollbar\"\n            data-uipkge=\"\"\n            data-slot=\"scroll-area-scrollbar\"\n            orientation=\"vertical\"\n            data-state=${show ? 'visible' : 'hidden'}\n            class=${cn(\n              'absolute top-0 right-0 flex touch-none p-px transition-colors select-none',\n              'h-full w-2.5 border-l border-l-transparent',\n              fade,\n            )}\n            @pointerdown=${(e: PointerEvent) => this.onTrackPointerDown('v', e)}\n          >\n            <div\n              part=\"thumb\"\n              data-uipkge=\"\"\n              data-slot=\"scroll-area-thumb\"\n              class=\"bg-border relative flex-1 cursor-default rounded-full\"\n              style=${styleMap({ height: `${this.thumbV.size}%`, top: `${this.thumbV.offset}%` })}\n              @pointerdown=${(e: PointerEvent) => this.onThumbPointerDown('v', e)}\n            ></div>\n          </div>`\n        : nothing}\n      ${this.canH\n        ? html`<div\n            part=\"scrollbar\"\n            data-uipkge=\"\"\n            data-slot=\"scroll-area-scrollbar\"\n            orientation=\"horizontal\"\n            data-state=${show ? 'visible' : 'hidden'}\n            class=${cn(\n              'absolute bottom-0 left-0 flex touch-none p-px transition-colors select-none',\n              'h-2.5 w-full flex-col border-t border-t-transparent',\n              fade,\n            )}\n            @pointerdown=${(e: PointerEvent) => this.onTrackPointerDown('h', e)}\n          >\n            <div\n              part=\"thumb\"\n              data-uipkge=\"\"\n              data-slot=\"scroll-area-thumb\"\n              class=\"bg-border relative flex-1 cursor-default rounded-full\"\n              style=${styleMap({ width: `${this.thumbH.size}%`, left: `${this.thumbH.offset}%` })}\n              @pointerdown=${(e: PointerEvent) => this.onThumbPointerDown('h', e)}\n            ></div>\n          </div>`\n        : nothing}\n    </div>`\n  }\n}\n\ncustomElements.get('uip-scroll-area') || customElements.define('uip-scroll-area', UipScrollArea)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-scroll-area': UipScrollArea\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/scroll-area/scroll-area.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
