feat: scroll fade component
parent
0296ab2cee
commit
ecbda741d9
|
|
@ -0,0 +1,114 @@
|
|||
[data-component="scroll-fade"] {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&[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;
|
||||
|
||||
/* Both fades */
|
||||
&[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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue