Get rid of ClampedProgress

This commit is contained in:
Sudev Kiyada 2026-01-09 11:54:55 +05:30
parent b354ce267b
commit 83ab2f9224
Failed to extract signature
5 changed files with 115 additions and 88 deletions

View file

@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { map } from './utils';
const { componentState } = $props(); const { componentState } = $props();
let isMoving = $state(false); let isMoving = $state(false);
@ -43,6 +45,36 @@
} }
isMoving = false; isMoving = false;
} }
let normalisedScrollProgress = $derived(
map(
componentState.scrollProgress,
componentState.clampStart ?? 0,
componentState.clampEnd ?? 1,
0,
1
)
);
let normalisedProgress = $derived(
map(
componentState.progress,
componentState.clampStart ?? 0,
componentState.clampEnd ?? 1,
0,
1
)
);
function mappedStop(stop: number): number {
return map(
stop,
componentState.clampStart ?? 0,
componentState.clampEnd ?? 1,
0,
1
);
}
</script> </script>
<svelte:window onmousemove={onMouseMove} /> <svelte:window onmousemove={onMouseMove} />
@ -58,11 +90,12 @@
> >
{/each} {/each}
{:else} {:else}
{#each componentState.triggerStops as stop, index} {@const stops = componentState.triggerStops.map((x) => mappedStop(x))}
{#if index < componentState.triggerStops.length - 1} {#each stops as stop, index}
{#if index < stops.length - 1}
<span <span
class="stops" class="stops"
style={`left: ${(stop + (componentState.triggerStops[index + 1] ?? componentState.triggerStops[componentState.triggerStops.length - 1])) * 0.5 * 100}%;`} style={`left: ${(stop + (stops[index + 1] ?? stops[stops.length - 1])) * 0.5 * 100}%;`}
>|</span >|</span
> >
{/if} {/if}
@ -86,6 +119,14 @@
CONSOLE CONSOLE
</summary> </summary>
<div class="state-debug"> <div class="state-debug">
<!-- -->
<p>Raw progress:</p>
<div style="display: flex; flex-direction: column; gap: 4px;">
<p class="state-value">
<span class="tag">{componentState.rawProgress}</span>
</p>
</div>
<!-- -->
<!-- --> <!-- -->
<p>Scroll progress:</p> <p>Scroll progress:</p>
<div style="display: flex; flex-direction: column; gap: 4px;"> <div style="display: flex; flex-direction: column; gap: 4px;">
@ -93,14 +134,14 @@
{@render triggerPoints()} {@render triggerPoints()}
<span <span
class="progress-stop" class="progress-stop"
style={`left: ${componentState.scrollProgress * 100}%; transform: translateX(-50%);`} style={`left: ${normalisedScrollProgress * 100}%; transform: translateX(-50%);`}
>{fmt.format(componentState.scrollProgress)}</span >{fmt.format(componentState.scrollProgress)}</span
> >
&nbsp; &nbsp;
</p> </p>
<div id="video-progress-bar"> <div id="video-progress-bar">
<div <div
style="width: {componentState.scrollProgress * 100}%; height: 100%;" style="width: {normalisedScrollProgress * 100}%; height: 100%;"
></div> ></div>
</div> </div>
</div> </div>
@ -110,20 +151,20 @@
<p class="state-value progress-value"> <p class="state-value progress-value">
{#if componentState.stops.length > 0} {#if componentState.stops.length > 0}
{#each componentState.stops as stop} {#each componentState.stops as stop}
<span class="stops" style={`left: ${stop * 100}%;`}>{stop}</span> <span class="stops" style={`left: ${mappedStop(stop) * 100}%;`}
>{stop}</span
>
{/each} {/each}
{/if} {/if}
<span <span
class="progress-stop" class="progress-stop"
style={`left: ${componentState.progress * 100}%; transform: translateX(-50%);`} style={`left: ${normalisedProgress * 100}%; transform: translateX(-50%);`}
>{fmt.format(componentState.progress)}</span >{fmt.format(componentState.progress)}</span
> >
&nbsp; &nbsp;
</p> </p>
<div id="video-progress-bar"> <div id="video-progress-bar">
<div <div style="width: {normalisedProgress * 100}%; height: 100%;"></div>
style="width: {componentState.progress * 100}%; height: 100%;"
></div>
</div> </div>
</div> </div>
<!-- --> <!-- -->

View file

@ -18,6 +18,8 @@ To use the `HorizontalScroller` component, import it and provide the children co
> 💡TIP: Use `lvh` or `svh` units instead of `vh` unit for the height, as [these units](https://www.w3.org/TR/css-values-4/#large-viewport-size) are more reliable on mobile or other devices where elements such as the address bar toggle between being shown and hidden. > 💡TIP: Use `lvh` or `svh` units instead of `vh` unit for the height, as [these units](https://www.w3.org/TR/css-values-4/#large-viewport-size) are more reliable on mobile or other devices where elements such as the address bar toggle between being shown and hidden.
Use `showDebugInfo` prop to visualize the scroll progress and other useful debug information. The `Raw progress` indicates the vertical progress with values in the range 0...1 indicating the content being locked. The `Scroll Progress` value indicates the vertical progress mapped to clampStart and clampEnd values. By default these are 0 and 1 respectively. Finally, the `Progress` value indicates the horizontal scroll progress after applying stops and easing (if any).
[Demo](?path=/story/components-graphics-horizontalscroller--demo) [Demo](?path=/story/components-graphics-horizontalscroller--demo)
```svelte ```svelte

View file

@ -45,14 +45,16 @@
</Story> </Story>
<Story <Story
name="Extended demo" name="Extended boundaries"
args={{ args={{
children: DemoSnippet, children: DemoSnippet,
height: '200lvh', height: '200lvh',
clampedProgress: true, clampStart: -0.5,
clampStart: -1, clampEnd: 1.5,
clampEnd: 2,
showDebugInfo: true, showDebugInfo: true,
scrubbed: true,
stops: [0, 1],
easing: quartInOut,
}} }}
> >
{#snippet children(args)} {#snippet children(args)}
@ -67,7 +69,7 @@
height: '200lvh', height: '200lvh',
stops: [0.2, 0.5, 0.6, 0.7], stops: [0.2, 0.5, 0.6, 0.7],
duration: 400, duration: 400,
scrubbed: false, scrubbed: true,
easing: quartInOut, easing: quartInOut,
showDebugInfo: true, showDebugInfo: true,
direction: 'left', direction: 'left',

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import { Tween } from 'svelte/motion'; import { Tween } from 'svelte/motion';
import type { Action } from 'svelte/action'; import type { Action } from 'svelte/action';
import { clamp, map } from './utils/index'; import { clamp, map } from './utils/index';
@ -30,31 +30,18 @@
duration?: number; duration?: number;
/** Whether to show debug info */ /** Whether to show debug info */
showDebugInfo?: boolean; showDebugInfo?: boolean;
} /** Modified starting scale. Default is 0 */
interface ClampedProps extends Props {
/** Whether the progress value should be clamped */
clampedProgress: true;
/** Start value for clamping. Only effective when clampedProgress is true. */
clampStart?: number; clampStart?: number;
/** End value for clamping. Only effective when clampedProgress is true. */ /** Modified ending scale. Default is 1 */
clampEnd?: number; clampEnd?: number;
} }
interface UnclampedProps extends Props {
/** Whether the progress value should be clamped */
clampedProgress?: false;
clampStart?: never;
clampEnd?: never;
}
let { let {
id = '', id = '',
class: cls = '', class: cls = '',
height = '200lvh', height = '200lvh',
direction = 'right', direction = 'right',
scrollProgress = $bindable(0), scrollProgress = $bindable(0),
clampedProgress = true,
clampStart = 0, clampStart = 0,
clampEnd = 1, clampEnd = 1,
children, children,
@ -64,24 +51,25 @@
easing: ease = (t) => t, easing: ease = (t) => t,
duration = 400, duration = 400,
showDebugInfo = false, showDebugInfo = false,
}: ClampedProps | UnclampedProps = $props(); }: Props = $props();
let componentState = $derived.by(() => ({ let componentState = $derived.by(() => ({
scrollProgress, scrollProgress,
progress: progressTween.current, progress: progressTween.current,
direction, direction,
clampedProgress,
clampStart, clampStart,
clampEnd, clampEnd,
triggerStops: scrubbed ? stops : unscrubbedStops, triggerStops: scrubbed ? stops : divisions,
stops: stops, stops: stops,
handleScroll, handleScroll,
scrubbed, scrubbed,
easing: ease, easing: ease,
duration, duration,
rawProgress,
})); }));
let progressTween: Tween<number> = $state( let progressTween: Tween<number> = $state(
new Tween(0, { duration, easing: ease }) new Tween(clampStart, { duration, easing: ease })
); );
let container: HTMLDivElement | undefined = $state(undefined); let container: HTMLDivElement | undefined = $state(undefined);
let containerHeight: number = $state(0); let containerHeight: number = $state(0);
@ -89,108 +77,101 @@
let content: HTMLDivElement | undefined = $state(undefined); let content: HTMLDivElement | undefined = $state(undefined);
let contentWidth: number = $state(0); let contentWidth: number = $state(0);
let screenHeight: number = $state(0); let screenHeight: number = $state(0);
let unscrubbedStops: number[] = $derived(
[...stops, 0, 1].sort((a, b) => a - b)
);
let divisions: number[] = $derived( let divisions: number[] = $derived(
[...stops, clampStart ?? 0, clampEnd ?? 1].sort((a, b) => a - b) [...stops, clampStart, clampEnd].sort((a, b) => a - b)
); );
let divisionsCount: number = $derived.by(() => divisions.length - 1); let divisionsCount: number = $derived.by(() => divisions.length - 1);
let translateX: number = $derived.by(() => { let rawProgress: number | 'user defined' = $state(0);
let processedProgress = progressTween.current;
let normalisedProgress = processedProgress;
if (clampedProgress) {
processedProgress = Math.min(
Math.max(progressTween.current, clampStart),
clampEnd
);
processedProgress = map(processedProgress, 0, 1, clampStart, clampEnd); // handles horizontal translation of the content
normalisedProgress = let translateX: number = $derived.by(() => {
direction === 'right' ? processedProgress : ( let processedProgress = clamp(progressTween.current, clampStart, clampEnd);
clampEnd - processedProgress let normalisedProgress = processedProgress;
);
} else { normalisedProgress =
normalisedProgress = direction === 'right' ? processedProgress : clampEnd - processedProgress;
direction === 'right' ? processedProgress : 1 - processedProgress;
}
const translate = -(contentWidth - containerWidth) * normalisedProgress; const translate = -(contentWidth - containerWidth) * normalisedProgress;
return translate; return translate;
}); });
onMount(() => {
// Initialize scrollProgress to clampStart on mount
scrollProgress = clampStart;
});
const scrollListener: Action = () => { const scrollListener: Action = () => {
if (handleScroll) { if (handleScroll) {
window.addEventListener('scroll', handleScrollFunction, { window.addEventListener('scroll', handleScrollFunction, {
passive: true, passive: true,
}); });
} else { } else {
// set rawProgress to user defined when handleScroll is false
rawProgress = 'user defined';
window.addEventListener('scroll', () => handleStops(scrollProgress), { window.addEventListener('scroll', () => handleStops(scrollProgress), {
passive: true, passive: true,
}); });
} }
}; };
// calculates distance scrolled inside the container
function handleScrollFunction() { function handleScrollFunction() {
if (!container) return; if (!container) return;
const rawProgress = rawProgress =
(-container?.offsetTop + window?.scrollY) / (-container?.offsetTop + window?.scrollY) /
(containerHeight - screenHeight); (containerHeight - screenHeight);
handleStops(rawProgress); handleStops(rawProgress);
} }
// updates progressTween based on stops and scrubbed settings
function handleStops(rawProgress: number) { function handleStops(rawProgress: number) {
scrollProgress = scrollProgress = map(rawProgress, 0, 1, clampStart, clampEnd);
clampedProgress ? clamp(rawProgress, clampStart, clampEnd) : rawProgress;
if (!stops || stops.length === 0) { if (!stops || stops.length === 0) {
progressTween.set(ease(clamp(rawProgress, 0, 1)), { progressTween.set(ease(map(rawProgress, 0, 1, clampStart, clampEnd)), {
duration: 0, duration: 0,
}); });
return; return;
} }
if (!scrubbed) { if (!scrubbed) {
for (let i = 0; i < unscrubbedStops.length; i++) { for (let i = 0; i < divisions.length; i++) {
if ( if (
rawProgress > unscrubbedStops[i] && scrollProgress > divisions[i] &&
rawProgress <= scrollProgress <=
(unscrubbedStops[i + 1] ?? (divisions[i + 1] ?? divisions[divisions.length - 1])
unscrubbedStops[unscrubbedStops.length - 1])
) { ) {
const midPoint = const midPoint =
unscrubbedStops[i] + divisions[i] +
((unscrubbedStops[i + 1] ?? ((divisions[i + 1] ?? divisions[divisions.length - 1]) -
unscrubbedStops[unscrubbedStops.length - 1]) - divisions[i]) *
unscrubbedStops[i]) *
0.5; 0.5;
if ( if (
rawProgress >= midPoint && scrollProgress >= midPoint &&
progressTween.target !== progressTween.target !==
(unscrubbedStops[i + 1] ?? (divisions[i + 1] ?? divisions[divisions.length - 1])
unscrubbedStops[unscrubbedStops.length - 1])
) { ) {
progressTween.set( progressTween.set(
unscrubbedStops[i + 1] ?? divisions[i + 1] ?? divisions[divisions.length - 1]
unscrubbedStops[unscrubbedStops.length - 1]
); );
return; return;
} else if ( } else if (
rawProgress < midPoint && scrollProgress < midPoint &&
progressTween.target !== unscrubbedStops[i] progressTween.target !== divisions[i]
) { ) {
progressTween.set(unscrubbedStops[i]); progressTween.set(divisions[i]);
return; return;
} }
} else if ( } else if (
rawProgress < scrollProgress <
unscrubbedStops[0] + (unscrubbedStops[1] ?? 0) * 0.5 divisions[0] + (divisions[1] ?? clampStart) * 0.5
) { ) {
if (progressTween.target !== unscrubbedStops[0]) { if (progressTween.target !== divisions[0]) {
progressTween.set(unscrubbedStops[0]); progressTween.set(divisions[0]);
return; return;
} }
} else { } else {
@ -201,15 +182,16 @@
for (let i = 0; i < divisions.length; i++) { for (let i = 0; i < divisions.length; i++) {
let oneByDivCount = 1 / divisionsCount; let oneByDivCount = 1 / divisionsCount;
let normalStart = i == 0 ? 0 : oneByDivCount * i; let normalStart = i == 0 ? clampStart : oneByDivCount * i;
let normalEnd = i == divisionsCount - 1 ? 1 : oneByDivCount * (i + 1); let normalEnd =
i == divisionsCount - 1 ? clampEnd : oneByDivCount * (i + 1);
if (rawProgress >= normalStart && rawProgress < normalEnd) { if (scrollProgress >= normalStart && scrollProgress < normalEnd) {
let stopStart = divisions[i]; let stopStart = divisions[i];
let stopEnd = divisions[i + 1] ?? 1; let stopEnd = divisions[i + 1] ?? clampEnd;
let newProgressVal = let newProgressVal =
stopStart + stopStart +
ease(map(rawProgress, normalStart, normalEnd, 0, 1)) * ease(map(scrollProgress, normalStart, normalEnd, 0, 1)) *
(stopEnd - stopStart); (stopEnd - stopStart);
progressTween.set(newProgressVal, { duration: 0 }); progressTween.set(newProgressVal, { duration: 0 });

View file

@ -1,7 +1,7 @@
<div style="width: 400vw; height: 100lvh; border: 2px solid red;"> <div style="width: 400vw; height: 100lvh;">
<img <img
src="https://picsum.photos/1200/640?t=1" src="https://picsum.photos/1200/640?t=1"
alt="Sample" alt="Sample"
style="width: 100%; height: 100%; object-fit: cover; padding: 0; margin: 0;" style="width: 100%; height: 100%; object-fit: cover; padding: 0; margin: 0; background-color: #ccc;"
/> />
</div> </div>