Browse Source

fix(app): home page scroll target (#36664)

Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Aarav Sareen 1 month ago
parent
commit
dc3ff4b7aa

+ 182 - 117
packages/app/src/pages/home.tsx

@@ -5,6 +5,7 @@ import {
   createMemo,
   createResource,
   createRoot,
+  createSignal,
   For,
   Match,
   on,
@@ -78,6 +79,23 @@ const HOME_SESSION_LIMIT = 64
 const HOME_SESSION_HEADER_STICKY_TOP = 12
 const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
 const HOME_SESSION_HEADER_FADE_DISTANCE = 16
+
+function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
+  if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
+  if (!(event.target instanceof Element)) return
+
+  const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
+  if (
+    scrollable !== viewport &&
+    scrollable &&
+    (event.deltaY < 0
+      ? scrollable.scrollTop > 0
+      : scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
+  )
+    return
+
+  event.preventDefault()
+}
 const SHOW_HOME_SESSION_ARCHIVE = false
 const HOME_ROW_LAYOUT =
   "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
@@ -150,6 +168,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
   let content: HTMLDivElement | undefined
   let positionFrame: number | undefined
   let resizeObserver: ResizeObserver | undefined
+  let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
   const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
   const headerOffsets = new Map<HomeSessionGroup["id"], number>()
   const [state, setState] = createStore({
@@ -208,6 +227,13 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
 
   function updatePositionCache() {
     if (!viewport) return
+    const header = groups()
+      .map((group) => headerRefs.get(group.id))
+      .find((el) => el !== undefined)
+    if (header && typeof getComputedStyle === "function") {
+      const top = Number.parseFloat(getComputedStyle(header).top)
+      if (Number.isFinite(top)) stickyTop = top
+    }
     groups().forEach((group) => {
       const el = headerRefs.get(group.id)
       if (!el) return
@@ -223,7 +249,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
         .slice(index + 1)
         .map((item) => headerOffsets.get(item.id))
         .find((offset) => offset !== undefined)
-      const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
+      const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
       const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
       const opacity =
         nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
@@ -277,6 +303,9 @@ export function NewHome() {
   const marked = useMarked()
   const openSettings = useSettingsCommand()
   let focusSessionSearch: (() => void) | undefined
+  let sessionViewport: HTMLDivElement | undefined
+  const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
+  const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
   const [state, setState] = createStore({
     search: "",
     searchFocused: false,
@@ -604,127 +633,158 @@ export function NewHome() {
   }
 
   return (
-    <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
-      <div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
-        <HomeProjectColumn
-          projects={projects()}
-          recentlyClosed={recentlyClosed()}
-          homedir={homedir()}
-          selected={selection()}
-          focusServer={focusServer}
-          selectProject={selectProject}
-          openNewSession={openProjectNewSession}
-          openRecentProject={(conn, directory) => addProjects(conn, [directory])}
-          chooseProject={(conn) => void chooseProject(conn)}
-          editProject={editProject}
-          closeProject={(conn, directory) => {
-            const next = closeHomeProject(
-              selection(),
-              ServerConnection.key(conn),
-              global.ensureServerCtx(conn).projects,
-              directory,
-            )
-            if (next) setSelection(next)
-          }}
-          clearNotifications={clearNotifications}
-          unseenCount={unseenCount}
-          openSettings={openSettings}
-          openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
-          language={language}
-        />
-
-        <section
-          class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
-          aria-label={language.t("sidebar.project.recentSessions")}
-        >
-          <HomeSessionSearch
-            value={state.search}
-            placeholder={searchPlaceholder()}
-            open={searchOpen()}
-            loading={sessionLoad.isLoading}
-            results={searchResults()}
-            showProjectName={!selectedProject()}
-            server={selection().server}
-            noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
-            bindFocus={(focus) => {
-              focusSessionSearch = focus
+    <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
+      <ScrollView
+        class="h-full [container-type:size]"
+        thumbContainer={sessionThumbTrack}
+        thumbHoverTarget={sessionHoverTarget}
+        viewportRef={(el) => {
+          sessionViewport = el
+          sessionHeaderOpacity.setViewport(el)
+        }}
+        onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
+        onWheel={(event) => {
+          if (!sessionViewport) return
+          if (event.target instanceof Node && sessionViewport.contains(event.target)) return
+          containHomeWheel(event, sessionViewport)
+        }}
+      >
+        <div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
+          <HomeProjectColumn
+            projects={projects()}
+            recentlyClosed={recentlyClosed()}
+            homedir={homedir()}
+            selected={selection()}
+            focusServer={focusServer}
+            selectProject={selectProject}
+            openNewSession={openProjectNewSession}
+            openRecentProject={(conn, directory) => addProjects(conn, [directory])}
+            chooseProject={(conn) => void chooseProject(conn)}
+            editProject={editProject}
+            closeProject={(conn, directory) => {
+              const next = closeHomeProject(
+                selection(),
+                ServerConnection.key(conn),
+                global.ensureServerCtx(conn).projects,
+                directory,
+              )
+              if (next) setSelection(next)
+            }}
+            clearNotifications={clearNotifications}
+            unseenCount={unseenCount}
+            openSettings={openSettings}
+            openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
+            language={language}
+            onWheel={(event) => {
+              if (sessionViewport) containHomeWheel(event, sessionViewport)
             }}
-            onInput={(value) => setState("search", value)}
-            onFocus={() => setState("searchFocused", true)}
-            onClose={closeSearch}
-            onSelect={selectSearchSession}
           />
-          <ScrollView
-            class="mt-3 -mr-3 min-h-0 flex-1 relative"
-            viewportRef={sessionHeaderOpacity.setViewport}
-            onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
+
+          <section
+            ref={setSessionHoverTarget}
+            class="min-h-0 min-w-0 flex-1 flex flex-col"
+            aria-label={language.t("sidebar.project.recentSessions")}
           >
-            <Show when={groups().length > 0 && newSessionProject()}>
-              <div class="pointer-events-none absolute top-3 right-3 z-20 flex">
-                <ButtonV2
-                  data-action="home-new-session"
-                  variant="ghost-muted"
-                  size="normal"
-                  icon="edit"
-                  class="pointer-events-auto h-7 px-2 [font-weight:530]"
-                  onClick={openNewSession}
-                >
-                  {language.t("command.session.new")}
-                </ButtonV2>
-              </div>
-            </Show>
-            <Show
-              when={!sessionLoad.isLoading}
-              fallback={
-                <div class="pt-3">
-                  <HomeSessionSkeleton label={language.t("common.loading")} />
-                </div>
-              }
+            <div
+              class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
+              onWheel={(event) => {
+                if (sessionViewport) containHomeWheel(event, sessionViewport)
+              }}
             >
+              <HomeSessionSearch
+                value={state.search}
+                placeholder={searchPlaceholder()}
+                open={searchOpen()}
+                loading={sessionLoad.isLoading}
+                results={searchResults()}
+                showProjectName={!selectedProject()}
+                server={selection().server}
+                noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
+                bindFocus={(focus) => {
+                  focusSessionSearch = focus
+                }}
+                onInput={(value) => setState("search", value)}
+                onFocus={() => setState("searchFocused", true)}
+                onClose={closeSearch}
+                onSelect={selectSearchSession}
+              />
+              <Show when={groups().length > 0 && newSessionProject()}>
+                <div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
+                  <ButtonV2
+                    data-action="home-new-session"
+                    variant="ghost-muted"
+                    size="normal"
+                    icon="edit"
+                    class="pointer-events-auto h-7 px-2 [font-weight:530]"
+                    onClick={openNewSession}
+                  >
+                    {language.t("command.session.new")}
+                  </ButtonV2>
+                </div>
+              </Show>
+            </div>
+            {/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
+            <div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
+              <div
+                ref={setSessionThumbTrack}
+                data-component="home-session-scroll-track"
+                class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
+              />
+            </div>
+            <div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
               <Show
-                when={groups().length > 0}
-                fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
+                when={!sessionLoad.isLoading}
+                fallback={
+                  <div class="pt-3">
+                    <HomeSessionSkeleton label={language.t("common.loading")} />
+                  </div>
+                }
               >
-                <div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
-                  <For each={groups()}>
-                    {(group, index) => (
-                      <>
-                        <HomeSessionGroupHeader
-                          title={group.title}
-                          titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
-                          ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
-                          elevated={index() === 0}
-                        />
-                        <div
-                          class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
-                        >
-                          <For each={group.sessions}>
-                            {(record) => (
-                              <HomeSessionRow
-                                record={record}
-                                showProjectName={!selectedProject()}
-                                server={selection().server}
-                                openSession={openSession}
-                                archiveSession={archiveSession}
-                              />
-                            )}
-                          </For>
-                        </div>
-                      </>
-                    )}
-                  </For>
-                </div>
+                <Show
+                  when={groups().length > 0}
+                  fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
+                >
+                  <div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
+                    <For each={groups()}>
+                      {(group, index) => (
+                        <>
+                          <HomeSessionGroupHeader
+                            title={group.title}
+                            titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
+                            ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
+                            elevated={index() === 0}
+                          />
+                          <div
+                            class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
+                          >
+                            <For each={group.sessions}>
+                              {(record) => (
+                                <HomeSessionRow
+                                  record={record}
+                                  showProjectName={!selectedProject()}
+                                  server={selection().server}
+                                  openSession={openSession}
+                                  archiveSession={archiveSession}
+                                />
+                              )}
+                            </For>
+                          </div>
+                        </>
+                      )}
+                    </For>
+                  </div>
+                </Show>
               </Show>
-            </Show>
-          </ScrollView>
-        </section>
-        <HomeUtilityNav
-          class="flex lg:hidden"
-          openSettings={openSettings}
-          openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
-          language={language}
-        />
-      </div>
+            </div>
+          </section>
+          <HomeUtilityNav
+            class="flex lg:hidden"
+            openSettings={openSettings}
+            openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
+            language={language}
+          />
+        </div>
+      </ScrollView>
     </div>
   )
 }
@@ -746,6 +806,7 @@ function HomeProjectColumn(props: {
   openSettings: () => void
   openHelp: () => void
   language: ReturnType<typeof useLanguage>
+  onWheel: (event: WheelEvent) => void
 }) {
   const global = useGlobal()
   const dialog = useDialog()
@@ -762,8 +823,12 @@ function HomeProjectColumn(props: {
 
   return (
     <aside
-      class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
+      class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
       aria-label={props.language.t("home.projects")}
+      onWheel={(event) => {
+        if (event.target === event.currentTarget) return
+        props.onWheel(event)
+      }}
     >
       <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
         <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
@@ -1489,7 +1554,7 @@ function HomeSessionGroupHeader(props: {
   return (
     <div
       ref={props.ref}
-      class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
+      class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
     >
       <div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
         {props.title}

+ 1 - 0
packages/ui/src/components/scroll-view.css

@@ -27,6 +27,7 @@
   transition: opacity 200ms ease;
   cursor: default;
   user-select: none;
+  pointer-events: auto;
   opacity: 0;
 }
 

+ 15 - 0
packages/ui/src/components/scroll-view.test.ts

@@ -72,4 +72,19 @@ describe("scrollTopFromThumbPointer", () => {
     expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0)
     expect(scrollTopFromThumbPointer({ ...input, pointer: 1_000 })).toBe(5_400)
   })
+
+  test("uses scrollClientHeight when the thumb track differs from the viewport", () => {
+    const input = {
+      pointer: 400,
+      viewportTop: 100,
+      grabOffset: 0,
+      clientHeight: 400,
+      scrollClientHeight: 800,
+      scrollHeight: 8_000,
+      thumbHeight: 40,
+    }
+    // track usable = 400 - 16 - 40 = 344; thumbTop = 400 - 100 - 8 = 292
+    // maxScroll = 8000 - 800 = 7200 → 292/344 * 7200
+    expect(scrollTopFromThumbPointer(input)).toBeCloseTo((292 / 344) * 7200)
+  })
 })

+ 103 - 25
packages/ui/src/components/scroll-view.tsx

@@ -1,4 +1,15 @@
-import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
+import {
+  createEffect,
+  createMemo,
+  mergeProps,
+  onCleanup,
+  onMount,
+  Show,
+  splitProps,
+  type Accessor,
+  type ComponentProps,
+} from "solid-js"
+import { Portal } from "solid-js/web"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { createStore } from "solid-js/store"
 import { useI18n } from "../context/i18n"
@@ -8,7 +19,17 @@ export type ScrollViewThumbVisibility = "hover" | "scroll"
 export interface ScrollViewProps extends ComponentProps<"div"> {
   viewportRef?: (el: HTMLDivElement) => void
   orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
+  /** 
+   * `hover`: show while hovered or scrolling. `scroll`: show only while scrolling. 
+   * 
+   * In most cases, scrolling a container = hovering over it, so this change has no effect. 
+   * This is a special case to account for the home page scroll, where scrolling a container != hovering over it
+   * */
   thumbVisibility?: ScrollViewThumbVisibility
+  /** Mount the thumb into an external track. Scroll metrics still come from this ScrollView. */
+  thumbContainer?: HTMLElement | Accessor<HTMLElement | undefined>
+  /** Element whose hover reveals the thumb. Defaults to the ScrollView root when unset. */
+  thumbHoverTarget?: HTMLElement | Accessor<HTMLElement | undefined>
 }
 
 export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => {
@@ -65,12 +86,14 @@ export function scrollTopFromThumbPointer(input: {
   clientHeight: number
   scrollHeight: number
   thumbHeight: number
+  /** Viewport height used for max scroll. Defaults to `clientHeight` (track == viewport). */
+  scrollClientHeight?: number
 }) {
   const padding = 8
   const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight
   if (maxThumbTop <= 0) return 0
   const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop))
-  return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - input.clientHeight)
+  return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - (input.scrollClientHeight ?? input.clientHeight))
 }
 
 export function ScrollView(props: ScrollViewProps) {
@@ -78,7 +101,16 @@ export function ScrollView(props: ScrollViewProps) {
   const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props)
   const [local, events, rest] = splitProps(
     merged,
-    ["class", "children", "viewportRef", "orientation", "thumbVisibility", "style"],
+    [
+      "class",
+      "children",
+      "viewportRef",
+      "orientation",
+      "thumbVisibility",
+      "thumbContainer",
+      "thumbHoverTarget",
+      "style",
+    ],
     [
       "onScroll",
       "onWheel",
@@ -96,6 +128,15 @@ export function ScrollView(props: ScrollViewProps) {
   let viewportRef!: HTMLDivElement
   let thumbRef!: HTMLDivElement
 
+  const resolveEl = (value: HTMLElement | Accessor<HTMLElement | undefined> | undefined) => {
+    if (typeof value === "function") return value()
+    return value
+  }
+
+  const thumbMount = createMemo(() => resolveEl(local.thumbContainer))
+  const thumbHover = createMemo(() => resolveEl(local.thumbHoverTarget))
+  const hoverRoot = () => !local.thumbHoverTarget && !local.thumbContainer
+
   const [state, setState] = createStore({
     isHovered: false,
     isDragging: false,
@@ -114,7 +155,6 @@ export function ScrollView(props: ScrollViewProps) {
   let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined
 
   const markScrolling = () => {
-    if (local.thumbVisibility !== "scroll") return
     setState("isScrolling", true)
     if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
     scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800)
@@ -122,8 +162,8 @@ export function ScrollView(props: ScrollViewProps) {
 
   const thumbVisible = () => {
     if (isDragging()) return true
-    if (local.thumbVisibility === "scroll") return isScrolling()
-    return isHovered()
+    if (isScrolling()) return true
+    return local.thumbVisibility === "hover" && isHovered()
   }
 
   onCleanup(() => {
@@ -141,7 +181,8 @@ export function ScrollView(props: ScrollViewProps) {
 
     setState("showThumb", true)
     const trackPadding = 8
-    const trackHeight = clientHeight - trackPadding * 2
+    const trackClientHeight = thumbMount()?.clientHeight || clientHeight
+    const trackHeight = trackClientHeight - trackPadding * 2
 
     const minThumbHeight = 32
     // Calculate raw thumb height based on ratio
@@ -165,16 +206,40 @@ export function ScrollView(props: ScrollViewProps) {
       local.viewportRef(viewportRef)
     }
 
-    createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb)
+    createResizeObserver(
+      () => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[],
+      updateThumb,
+    )
 
     updateThumb()
   })
 
+  createEffect(() => {
+    thumbMount()
+    updateThumb()
+  })
+
+  createEffect(() => {
+    const target = thumbHover()
+    if (!target) return
+
+    const enter = () => setState("isHovered", true)
+    const leave = () => setState("isHovered", false)
+    target.addEventListener("pointerenter", enter)
+    target.addEventListener("pointerleave", leave)
+    onCleanup(() => {
+      target.removeEventListener("pointerenter", enter)
+      target.removeEventListener("pointerleave", leave)
+      setState("isHovered", false)
+    })
+  })
+
   const onThumbPointerDown = (e: PointerEvent) => {
     e.preventDefault()
     e.stopPropagation()
     setState("isDragging", true)
     const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top
+    const track = thumbMount() ?? viewportRef
 
     thumbRef.setPointerCapture(e.pointerId)
 
@@ -182,9 +247,10 @@ export function ScrollView(props: ScrollViewProps) {
       const { scrollHeight, clientHeight } = viewportRef
       viewportRef.scrollTop = scrollTopFromThumbPointer({
         pointer: e.clientY,
-        viewportTop: viewportRef.getBoundingClientRect().top,
+        viewportTop: track.getBoundingClientRect().top,
         grabOffset,
-        clientHeight,
+        clientHeight: track.clientHeight,
+        scrollClientHeight: clientHeight,
         scrollHeight,
         thumbHeight: thumbHeight(),
       })
@@ -203,6 +269,23 @@ export function ScrollView(props: ScrollViewProps) {
     thumbRef.addEventListener("pointercancel", done)
   }
 
+  const renderThumb = () => (
+    <div
+      ref={(el) => {
+        thumbRef = el
+      }}
+      onPointerDown={onThumbPointerDown}
+      class="scroll-view__thumb"
+      data-visible={thumbVisible()}
+      data-dragging={isDragging()}
+      style={{
+        height: `${thumbHeight()}px`,
+        transform: `translateY(${thumbTop()}px)`,
+        "z-index": 100, // ensure it displays over content
+      }}
+    />
+  )
+
   // Keybinds implementation
   // We ensure the viewport has a tabindex so it can receive focus
   // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
@@ -253,8 +336,12 @@ export function ScrollView(props: ScrollViewProps) {
       ref={rootRef}
       class={`scroll-view ${local.class || ""}`}
       style={local.style}
-      onPointerEnter={() => setState("isHovered", true)}
-      onPointerLeave={() => setState("isHovered", false)}
+      onPointerEnter={() => {
+        if (hoverRoot()) setState("isHovered", true)
+      }}
+      onPointerLeave={() => {
+        if (hoverRoot()) setState("isHovered", false)
+      }}
       {...rest}
     >
       {/* Viewport */}
@@ -290,20 +377,11 @@ export function ScrollView(props: ScrollViewProps) {
         {local.children}
       </div>
 
-      {/* Thumb Overlay */}
+      {/* Thumb Overlay — optionally portaled into an external track */}
       <Show when={showThumb()}>
-        <div
-          ref={thumbRef}
-          onPointerDown={onThumbPointerDown}
-          class="scroll-view__thumb"
-          data-visible={thumbVisible()}
-          data-dragging={isDragging()}
-          style={{
-            height: `${thumbHeight()}px`,
-            transform: `translateY(${thumbTop()}px)`,
-            "z-index": 100, // ensure it displays over content
-          }}
-        />
+        <Show when={thumbMount()} fallback={renderThumb()}>
+          {(mount) => <Portal mount={mount()}>{renderThumb()}</Portal>}
+        </Show>
       </Show>
     </div>
   )