summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAaron Iker <[email protected]>2026-02-02 01:18:06 +0100
committerGitHub <[email protected]>2026-02-02 01:18:06 +0100
commite445dc07464d75c893756f6e256c1755d9e2285e (patch)
tree8f852ac0200d113f378338305eae3296ba1a695f
parente84d441b823463162cada18e1a8b5383820c695a (diff)
downloadopencode-e445dc07464d75c893756f6e256c1755d9e2285e.tar.gz
opencode-e445dc07464d75c893756f6e256c1755d9e2285e.zip
feat(ui): Smooth fading out on scroll, style fixes (#11683)
-rw-r--r--packages/app/src/components/settings-general.tsx37
-rw-r--r--packages/app/src/components/settings-keybinds.tsx10
-rw-r--r--packages/app/src/components/settings-models.tsx5
-rw-r--r--packages/app/src/components/settings-providers.tsx5
-rw-r--r--packages/ui/src/components/list.css50
-rw-r--r--packages/ui/src/components/list.tsx13
-rw-r--r--packages/ui/src/components/scroll-fade.css82
-rw-r--r--packages/ui/src/components/scroll-fade.tsx206
-rw-r--r--packages/ui/src/components/scroll-reveal.tsx141
-rw-r--r--packages/ui/src/styles/index.css1
10 files changed, 490 insertions, 60 deletions
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx
index 94813871e..1d82a24c3 100644
--- a/packages/app/src/components/settings-general.tsx
+++ b/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>
)
}
diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx
index a24db13f5..8655bca34 100644
--- a/packages/app/src/components/settings-keybinds.tsx
+++ b/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>
)
}
diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx
index 1807d561e..c9453ddf1 100644
--- a/packages/app/src/components/settings-models.tsx
+++ b/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>
)
}
diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx
index dcc597139..71a0bb6ad 100644
--- a/packages/app/src/components/settings-providers.tsx
+++ b/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>
)
}
diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css
index b12d30415..7b365c288 100644
--- a/packages/ui/src/components/list.css
+++ b/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);
}
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx
index 2132897f7..950ad3d20 100644
--- a/packages/ui/src/components/list.tsx
+++ b/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>
)
-}
+} \ No newline at end of file
diff --git a/packages/ui/src/components/scroll-fade.css b/packages/ui/src/components/scroll-fade.css
new file mode 100644
index 000000000..ede5fabec
--- /dev/null
+++ b/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);
+ }
+ }
+}
diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx
new file mode 100644
index 000000000..d190d67c0
--- /dev/null
+++ b/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>
+ )
+} \ No newline at end of file
diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx
new file mode 100644
index 000000000..7eb7ff37f
--- /dev/null
+++ b/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>
+ )
+} \ No newline at end of file
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index cae48137b..2a8171f98 100644
--- a/packages/ui/src/styles/index.css
+++ b/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);