Sfoglia il codice sorgente

feat(ui): Smooth fading out on scroll, style fixes (#11683)

Aaron Iker 3 settimane fa
parent
commit
e445dc0746

+ 19 - 18
packages/app/src/components/settings-general.tsx

@@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select"
 import { Switch } from "@opencode-ai/ui/switch"
 import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
 import { showToast } from "@opencode-ai/ui/toast"
+import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { useSettings, monoFontFamily } from "@/context/settings"
@@ -60,24 +61,24 @@ export const SettingsGeneral: Component = () => {
         const actions =
           platform.update && platform.restart
             ? [
-                {
-                  label: language.t("toast.update.action.installRestart"),
-                  onClick: async () => {
-                    await platform.update!()
-                    await platform.restart!()
-                  },
+              {
+                label: language.t("toast.update.action.installRestart"),
+                onClick: async () => {
+                  await platform.update!()
+                  await platform.restart!()
                 },
-                {
-                  label: language.t("toast.update.action.notYet"),
-                  onClick: "dismiss" as const,
-                },
-              ]
+              },
+              {
+                label: language.t("toast.update.action.notYet"),
+                onClick: "dismiss" as const,
+              },
+            ]
             : [
-                {
-                  label: language.t("toast.update.action.notYet"),
-                  onClick: "dismiss" as const,
-                },
-              ]
+              {
+                label: language.t("toast.update.action.notYet"),
+                onClick: "dismiss" as const,
+              },
+            ]
 
         showToast({
           persistent: true,
@@ -130,7 +131,7 @@ export const SettingsGeneral: Component = () => {
   const soundOptions = [...SOUND_OPTIONS]
 
   return (
-    <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
+    <ScrollFade direction="vertical" fadeStartSize={0} fadeEndSize={16} class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
       <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
         <div class="flex flex-col gap-1 pt-6 pb-8">
           <h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
@@ -411,7 +412,7 @@ export const SettingsGeneral: Component = () => {
           </div>
         </div>
       </div>
-    </div>
+    </ScrollFade>
   )
 }
 

+ 8 - 2
packages/app/src/components/settings-keybinds.tsx

@@ -5,6 +5,7 @@ import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
+import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
 import fuzzysort from "fuzzysort"
 import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
@@ -352,7 +353,12 @@ export const SettingsKeybinds: Component = () => {
   })
 
   return (
-    <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
+    <ScrollFade
+      direction="vertical"
+      fadeStartSize={0}
+      fadeEndSize={16}
+      class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
+    >
       <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
         <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
           <div class="flex items-center justify-between gap-4">
@@ -430,6 +436,6 @@ export const SettingsKeybinds: Component = () => {
           </div>
         </Show>
       </div>
-    </div>
+    </ScrollFade>
   )
 }

+ 3 - 2
packages/app/src/components/settings-models.tsx

@@ -9,6 +9,7 @@ import { type Component, For, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { useModels } from "@/context/models"
 import { popularProviders } from "@/hooks/use-providers"
+import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
 
 type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
 
@@ -39,7 +40,7 @@ export const SettingsModels: Component = () => {
   })
 
   return (
-    <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
+    <ScrollFade direction="vertical" fadeStartSize={0} fadeEndSize={16} class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
       <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
         <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
           <h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
@@ -125,6 +126,6 @@ export const SettingsModels: Component = () => {
           </Show>
         </Show>
       </div>
-    </div>
+    </ScrollFade>
   )
 }

+ 3 - 2
packages/app/src/components/settings-providers.tsx

@@ -12,6 +12,7 @@ import { useGlobalSync } from "@/context/global-sync"
 import { DialogConnectProvider } from "./dialog-connect-provider"
 import { DialogSelectProvider } from "./dialog-select-provider"
 import { DialogCustomProvider } from "./dialog-custom-provider"
+import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
 
 type ProviderSource = "env" | "api" | "config" | "custom"
 type ProviderMeta = { source?: ProviderSource }
@@ -115,7 +116,7 @@ export const SettingsProviders: Component = () => {
   }
 
   return (
-    <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
+    <ScrollFade direction="vertical" fadeStartSize={0} fadeEndSize={16} class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
       <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
         <div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
           <h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
@@ -261,6 +262,6 @@ export const SettingsProviders: Component = () => {
           </Button>
         </div>
       </div>
-    </div>
+    </ScrollFade>
   )
 }

+ 17 - 33
packages/ui/src/components/list.css

@@ -1,25 +1,7 @@
-@property --bottom-fade {
-  syntax: "<length>";
-  inherits: false;
-  initial-value: 0px;
-}
-
-@keyframes scroll {
-  0% {
-    --bottom-fade: 20px;
-  }
-  90% {
-    --bottom-fade: 20px;
-  }
-  100% {
-    --bottom-fade: 0;
-  }
-}
-
 [data-component="list"] {
   display: flex;
   flex-direction: column;
-  gap: 12px;
+  gap: 8px;
   overflow: hidden;
   padding: 0 12px;
 
@@ -37,7 +19,9 @@
       flex-shrink: 0;
       background-color: transparent;
       opacity: 0.5;
-      transition: opacity 0.15s ease;
+      transition-property: opacity;
+      transition-duration: var(--transition-duration);
+      transition-timing-function: var(--transition-easing);
 
       &:hover:not(:disabled),
       &:focus-visible:not(:disabled),
@@ -88,7 +72,9 @@
       height: 20px;
       background-color: transparent;
       opacity: 0.5;
-      transition: opacity 0.15s ease;
+      transition-property: opacity;
+      transition-duration: var(--transition-duration);
+      transition-timing-function: var(--transition-easing);
 
       &:hover:not(:disabled),
       &:focus-visible:not(:disabled),
@@ -131,15 +117,6 @@
     gap: 12px;
     overflow-y: auto;
     overscroll-behavior: contain;
-    mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000);
-    animation: scroll;
-    animation-timeline: --scroll;
-    scroll-timeline: --scroll y;
-    scrollbar-width: none;
-    -ms-overflow-style: none;
-    &::-webkit-scrollbar {
-      display: none;
-    }
 
     [data-slot="list-empty-state"] {
       display: flex;
@@ -215,7 +192,9 @@
           background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent);
           pointer-events: none;
           opacity: 0;
-          transition: opacity 0.15s ease;
+          transition-property: opacity;
+          transition-duration: var(--transition-duration);
+          transition-timing-function: var(--transition-easing);
         }
 
         &[data-stuck="true"]::after {
@@ -251,17 +230,22 @@
             align-items: center;
             justify-content: center;
             flex-shrink: 0;
-            aspect-ratio: 1/1;
+            aspect-ratio: 1 / 1;
             [data-component="icon"] {
               color: var(--icon-strong-base);
             }
           }
+
+          [name="check"] {
+            color: var(--icon-strong-base);
+          }
+
           [data-slot="list-item-active-icon"] {
             display: none;
             align-items: center;
             justify-content: center;
             flex-shrink: 0;
-            aspect-ratio: 1/1;
+            aspect-ratio: 1 / 1;
             [data-component="icon"] {
               color: var(--icon-strong-base);
             }

+ 10 - 3
packages/ui/src/components/list.tsx

@@ -5,6 +5,7 @@ import { useI18n } from "../context/i18n"
 import { Icon, type IconProps } from "./icon"
 import { IconButton } from "./icon-button"
 import { TextField } from "./text-field"
+import { ScrollFade } from "./scroll-fade"
 
 function findByKey(container: HTMLElement, key: string) {
   const nodes = container.querySelectorAll<HTMLElement>('[data-slot="list-item"][data-key]')
@@ -267,7 +268,13 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
           {searchAction()}
         </div>
       </Show>
-      <div ref={setScrollRef} data-slot="list-scroll">
+      <ScrollFade
+        ref={setScrollRef}
+        direction="vertical"
+        fadeStartSize={0}
+        fadeEndSize={20}
+        data-slot="list-scroll"
+      >
         <Show
           when={flat().length > 0 || showAdd()}
           fallback={
@@ -339,7 +346,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
             </div>
           </Show>
         </Show>
-      </div>
+      </ScrollFade>
     </div>
   )
-}
+}

+ 82 - 0
packages/ui/src/components/scroll-fade.css

@@ -0,0 +1,82 @@
+[data-component="scroll-fade"] {
+  overflow: auto;
+  overscroll-behavior: contain;
+  scrollbar-width: none;
+  box-sizing: border-box;
+  color: inherit;
+  font: inherit;
+  -ms-overflow-style: none;
+
+  &::-webkit-scrollbar {
+    display: none;
+  }
+
+  &[data-direction="horizontal"] {
+    overflow-x: auto;
+    overflow-y: hidden;
+
+    /* Both fades */
+    &[data-fade-start][data-fade-end] {
+      mask-image: linear-gradient(
+        to right,
+        transparent,
+        black var(--scroll-fade-start),
+        black calc(100% - var(--scroll-fade-end)),
+        transparent
+      );
+      -webkit-mask-image: linear-gradient(
+        to right,
+        transparent,
+        black var(--scroll-fade-start),
+        black calc(100% - var(--scroll-fade-end)),
+        transparent
+      );
+    }
+
+    /* Only start fade */
+    &[data-fade-start]:not([data-fade-end]) {
+      mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%);
+      -webkit-mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%);
+    }
+
+    /* Only end fade */
+    &:not([data-fade-start])[data-fade-end] {
+      mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent);
+      -webkit-mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent);
+    }
+  }
+
+  &[data-direction="vertical"] {
+    overflow-y: auto;
+    overflow-x: hidden;
+
+    &[data-fade-start][data-fade-end] {
+      mask-image: linear-gradient(
+        to bottom,
+        transparent,
+        black var(--scroll-fade-start),
+        black calc(100% - var(--scroll-fade-end)),
+        transparent
+      );
+      -webkit-mask-image: linear-gradient(
+        to bottom,
+        transparent,
+        black var(--scroll-fade-start),
+        black calc(100% - var(--scroll-fade-end)),
+        transparent
+      );
+    }
+
+    /* Only start fade */
+    &[data-fade-start]:not([data-fade-end]) {
+      mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%);
+      -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%);
+    }
+
+    /* Only end fade */
+    &:not([data-fade-start])[data-fade-end] {
+      mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent);
+      -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent);
+    }
+  }
+}

+ 206 - 0
packages/ui/src/components/scroll-fade.tsx

@@ -0,0 +1,206 @@
+import { type JSX, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
+
+export interface ScrollFadeProps extends JSX.HTMLAttributes<HTMLDivElement> {
+  direction?: "horizontal" | "vertical"
+  fadeStartSize?: number
+  fadeEndSize?: number
+  trackTransformSelector?: string
+  ref?: (el: HTMLDivElement) => void
+}
+
+export function ScrollFade(props: ScrollFadeProps) {
+  const [local, others] = splitProps(props, [
+    "children",
+    "direction",
+    "fadeStartSize",
+    "fadeEndSize",
+    "trackTransformSelector",
+    "class",
+    "style",
+    "ref",
+  ])
+
+  const direction = () => local.direction ?? "vertical"
+  const fadeStartSize = () => local.fadeStartSize ?? 20
+  const fadeEndSize = () => local.fadeEndSize ?? 20
+
+  const getTransformOffset = (element: Element): number => {
+    const style = getComputedStyle(element)
+    const transform = style.transform
+    if (!transform || transform === "none") return 0
+
+    const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/)
+    if (!match) return 0
+
+    const values = match[1].split(",").map((v) => parseFloat(v.trim()))
+    const isHorizontal = direction() === "horizontal"
+
+    if (transform.startsWith("matrix3d")) {
+      return isHorizontal ? -(values[12] || 0) : -(values[13] || 0)
+    } else {
+      return isHorizontal ? -(values[4] || 0) : -(values[5] || 0)
+    }
+  }
+
+  let containerRef: HTMLDivElement | undefined
+
+  const [fadeStart, setFadeStart] = createSignal(0)
+  const [fadeEnd, setFadeEnd] = createSignal(0)
+  const [isScrollable, setIsScrollable] = createSignal(false)
+
+  let lastScrollPos = 0
+  let lastTransformPos = 0
+  let lastScrollSize = 0
+  let lastClientSize = 0
+
+  const updateFade = () => {
+    if (!containerRef) return
+
+    const isHorizontal = direction() === "horizontal"
+    const scrollPos = isHorizontal ? containerRef.scrollLeft : containerRef.scrollTop
+    const scrollSize = isHorizontal ? containerRef.scrollWidth : containerRef.scrollHeight
+    const clientSize = isHorizontal ? containerRef.clientWidth : containerRef.clientHeight
+
+    let transformPos = 0
+    if (local.trackTransformSelector) {
+      const transformElement = containerRef.querySelector(local.trackTransformSelector)
+      if (transformElement) {
+        transformPos = getTransformOffset(transformElement)
+      }
+    }
+
+    const effectiveScrollPos = Math.max(scrollPos, transformPos)
+
+    if (
+      effectiveScrollPos === lastScrollPos &&
+      transformPos === lastTransformPos &&
+      scrollSize === lastScrollSize &&
+      clientSize === lastClientSize
+    ) {
+      return
+    }
+
+    lastScrollPos = effectiveScrollPos
+    lastTransformPos = transformPos
+    lastScrollSize = scrollSize
+    lastClientSize = clientSize
+
+    const maxScroll = scrollSize - clientSize
+    const canScroll = maxScroll > 1
+
+    setIsScrollable(canScroll)
+
+    if (!canScroll) {
+      setFadeStart(0)
+      setFadeEnd(0)
+      return
+    }
+
+    const progress = maxScroll > 0 ? effectiveScrollPos / maxScroll : 0
+
+    const startProgress = Math.min(progress / 0.1, 1)
+    setFadeStart(startProgress * fadeStartSize())
+
+    const endProgress = progress > 0.9 ? (1 - progress) / 0.1 : 1
+    setFadeEnd(Math.max(0, endProgress) * fadeEndSize())
+  }
+
+  onMount(() => {
+    if (!containerRef) return
+
+    updateFade()
+
+    let rafId: number | undefined
+    let isPolling = false
+    let pollTimeout: ReturnType<typeof setTimeout> | undefined
+
+    const startPolling = () => {
+      if (isPolling) return
+      isPolling = true
+
+      const pollScroll = () => {
+        updateFade()
+        rafId = requestAnimationFrame(pollScroll)
+      }
+      rafId = requestAnimationFrame(pollScroll)
+    }
+
+    const stopPolling = () => {
+      if (!isPolling) return
+      isPolling = false
+      if (rafId !== undefined) {
+        cancelAnimationFrame(rafId)
+        rafId = undefined
+      }
+    }
+
+    const schedulePollingStop = () => {
+      if (pollTimeout !== undefined) clearTimeout(pollTimeout)
+      pollTimeout = setTimeout(stopPolling, 1000)
+    }
+
+    const onActivity = () => {
+      updateFade()
+      if (local.trackTransformSelector) {
+        startPolling()
+        schedulePollingStop()
+      }
+    }
+
+    containerRef.addEventListener("scroll", onActivity, { passive: true })
+
+    const resizeObserver = new ResizeObserver(() => {
+      lastScrollSize = 0
+      lastClientSize = 0
+      onActivity()
+    })
+    resizeObserver.observe(containerRef)
+
+    const mutationObserver = new MutationObserver(() => {
+      lastScrollSize = 0
+      lastClientSize = 0
+      requestAnimationFrame(onActivity)
+    })
+    mutationObserver.observe(containerRef, {
+      childList: true,
+      subtree: true,
+      characterData: true,
+    })
+
+    onCleanup(() => {
+      containerRef?.removeEventListener("scroll", onActivity)
+      resizeObserver.disconnect()
+      mutationObserver.disconnect()
+      stopPolling()
+      if (pollTimeout !== undefined) clearTimeout(pollTimeout)
+    })
+  })
+
+  createEffect(() => {
+    local.children
+    requestAnimationFrame(updateFade)
+  })
+
+  return (
+    <div
+      ref={(el) => {
+        containerRef = el
+        local.ref?.(el)
+      }}
+      data-component="scroll-fade"
+      data-direction={direction()}
+      data-scrollable={isScrollable() || undefined}
+      data-fade-start={fadeStart() > 0 || undefined}
+      data-fade-end={fadeEnd() > 0 || undefined}
+      class={local.class}
+      style={{
+        ...(typeof local.style === "object" ? local.style : {}),
+        "--scroll-fade-start": `${fadeStart()}px`,
+        "--scroll-fade-end": `${fadeEnd()}px`,
+      }}
+      {...others}
+    >
+      {local.children}
+    </div>
+  )
+}

+ 141 - 0
packages/ui/src/components/scroll-reveal.tsx

@@ -0,0 +1,141 @@
+import { type JSX, onCleanup, splitProps } from "solid-js"
+import { ScrollFade, type ScrollFadeProps } from './scroll-fade'
+
+const SCROLL_SPEED = 60
+const PAUSE_DURATION = 800
+
+type ScrollAnimationState = {
+  rafId: number | null
+  startTime: number
+  running: boolean
+}
+
+const startScrollAnimation = (containerEl: HTMLElement): ScrollAnimationState | null => {
+  containerEl.offsetHeight
+
+  const extraWidth = containerEl.scrollWidth - containerEl.clientWidth
+
+  if (extraWidth <= 0) {
+    return null
+  }
+
+  const scrollDuration = (extraWidth / SCROLL_SPEED) * 1000
+  const totalDuration = PAUSE_DURATION + scrollDuration + PAUSE_DURATION + scrollDuration + PAUSE_DURATION
+
+  const state: ScrollAnimationState = {
+    rafId: null,
+    startTime: performance.now(),
+    running: true,
+  }
+
+  const animate = (currentTime: number) => {
+    if (!state.running) return
+
+    const elapsed = currentTime - state.startTime
+    const progress = (elapsed % totalDuration) / totalDuration
+
+    const pausePercent = PAUSE_DURATION / totalDuration
+    const scrollPercent = scrollDuration / totalDuration
+
+    const pauseEnd1 = pausePercent
+    const scrollEnd1 = pauseEnd1 + scrollPercent
+    const pauseEnd2 = scrollEnd1 + pausePercent
+    const scrollEnd2 = pauseEnd2 + scrollPercent
+
+    let scrollPos = 0
+
+    if (progress < pauseEnd1) {
+      scrollPos = 0
+    } else if (progress < scrollEnd1) {
+      const scrollProgress = (progress - pauseEnd1) / scrollPercent
+      scrollPos = scrollProgress * extraWidth
+    } else if (progress < pauseEnd2) {
+      scrollPos = extraWidth
+    } else if (progress < scrollEnd2) {
+      const scrollProgress = (progress - pauseEnd2) / scrollPercent
+      scrollPos = extraWidth * (1 - scrollProgress)
+    } else {
+      scrollPos = 0
+    }
+
+    containerEl.scrollLeft = scrollPos
+    state.rafId = requestAnimationFrame(animate)
+  }
+
+  state.rafId = requestAnimationFrame(animate)
+  return state
+}
+
+const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: HTMLElement) => {
+  if (state) {
+    state.running = false
+    if (state.rafId !== null) {
+      cancelAnimationFrame(state.rafId)
+    }
+  }
+  if (containerEl) {
+    containerEl.scrollLeft = 0
+  }
+}
+
+export interface ScrollRevealProps extends Omit<ScrollFadeProps, "direction"> {
+  hoverDelay?: number
+}
+
+export function ScrollReveal(props: ScrollRevealProps) {
+  const [local, others] = splitProps(props, ["children", "hoverDelay", "ref"])
+
+  const hoverDelay = () => local.hoverDelay ?? 300
+
+  let containerRef: HTMLDivElement | undefined
+  let hoverTimeout: ReturnType<typeof setTimeout> | undefined
+  let scrollAnimationState: ScrollAnimationState | null = null
+
+  const handleMouseEnter: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
+    hoverTimeout = setTimeout(() => {
+      if (!containerRef) return
+
+      containerRef.offsetHeight
+
+      const isScrollable = containerRef.scrollWidth > containerRef.clientWidth + 1
+
+      if (isScrollable) {
+        stopScrollAnimation(scrollAnimationState, containerRef)
+        scrollAnimationState = startScrollAnimation(containerRef)
+      }
+    }, hoverDelay())
+  }
+
+  const handleMouseLeave: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
+    if (hoverTimeout) {
+      clearTimeout(hoverTimeout)
+      hoverTimeout = undefined
+    }
+    stopScrollAnimation(scrollAnimationState, containerRef)
+    scrollAnimationState = null
+  }
+
+  onCleanup(() => {
+    if (hoverTimeout) {
+      clearTimeout(hoverTimeout)
+    }
+    stopScrollAnimation(scrollAnimationState, containerRef)
+  })
+
+  return (
+    <ScrollFade
+      ref={(el) => {
+        containerRef = el
+        local.ref?.(el)
+      }}
+      fadeStartSize={8}
+      fadeEndSize={8}
+      direction="horizontal"
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+      {...others}
+    >
+      {local.children}
+    </ScrollFade>
+  )
+}

+ 1 - 0
packages/ui/src/styles/index.css

@@ -40,6 +40,7 @@
 @import "../components/select.css" layer(components);
 @import "../components/spinner.css" layer(components);
 @import "../components/switch.css" layer(components);
+@import "../components/scroll-fade.css" layer(components);
 @import "../components/session-review.css" layer(components);
 @import "../components/session-turn.css" layer(components);
 @import "../components/sticky-accordion-header.css" layer(components);