feat: scroll helper components

This commit is contained in:
Aaron Iker
2026-01-26 03:42:29 +01:00
parent e3c1861a3e
commit 81f4c7a8d5
3 changed files with 446 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
[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
);
}
}
}

View File

@@ -0,0 +1,183 @@
import {
type JSX,
createEffect,
createSignal,
onCleanup,
onMount,
splitProps,
} from "solid-js"
import "./scroll-fade.css"
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()
containerRef.addEventListener("scroll", updateFade, { passive: true })
const resizeObserver = new ResizeObserver(() => {
lastScrollSize = 0
lastClientSize = 0
updateFade()
})
resizeObserver.observe(containerRef)
const mutationObserver = new MutationObserver(() => {
lastScrollSize = 0
lastClientSize = 0
requestAnimationFrame(updateFade)
})
mutationObserver.observe(containerRef, {
childList: true,
subtree: true,
characterData: true,
})
let rafId: number
const pollScroll = () => {
updateFade()
rafId = requestAnimationFrame(pollScroll)
}
rafId = requestAnimationFrame(pollScroll)
onCleanup(() => {
containerRef?.removeEventListener("scroll", updateFade)
resizeObserver.disconnect()
mutationObserver.disconnect()
cancelAnimationFrame(rafId)
})
})
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>
)
}

View File

@@ -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>
)
}