From 7740f697962de89b133d18cad79f17918d57e541 Mon Sep 17 00:00:00 2001 From: Simon Jockers <449739+sjockers@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:19:43 +0200 Subject: [PATCH 01/13] Initialize with code from https://github.com/martgnz/svelte-scroller/tree/position-sticky --- components/src/Scroller/Scroller.svelte | 212 ++++++++++++++++++++++++ components/src/Scroller/index.ts | 2 + 2 files changed, 214 insertions(+) create mode 100644 components/src/Scroller/Scroller.svelte create mode 100644 components/src/Scroller/index.ts diff --git a/components/src/Scroller/Scroller.svelte b/components/src/Scroller/Scroller.svelte new file mode 100644 index 00000000..ece43980 --- /dev/null +++ b/components/src/Scroller/Scroller.svelte @@ -0,0 +1,212 @@ + + + + + + + + + + + + + + + + + + + diff --git a/components/src/Scroller/index.ts b/components/src/Scroller/index.ts new file mode 100644 index 00000000..7603e1f7 --- /dev/null +++ b/components/src/Scroller/index.ts @@ -0,0 +1,2 @@ +import Scroller from './Scroller.svelte'; +export default Scroller; From 55eca3103cdbd4736b46d02c4408e69d42d2292a Mon Sep 17 00:00:00 2001 From: Simon Jockers <449739+sjockers@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:19:59 +0200 Subject: [PATCH 02/13] Add basic example --- .../src/Scroller/Scroller.stories.svelte | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 components/src/Scroller/Scroller.stories.svelte diff --git a/components/src/Scroller/Scroller.stories.svelte b/components/src/Scroller/Scroller.stories.svelte new file mode 100644 index 00000000..327cb6d0 --- /dev/null +++ b/components/src/Scroller/Scroller.stories.svelte @@ -0,0 +1,67 @@ + + + +
+ Scroll down to see the scroller in action. + +
+

+ This is the background content. It will stay fixed in place while the foreground scrolls + over the top. +

+

+ Section {index + 1} is currently active . +

+ +
+ index + {index} + {index} +
+ +
+ offset + {offset} + {offset} +
+ +
+ progress + {progress} + {progress} +
+
+
+
This is the first section.
+
This is the second section.
+
This is the third section.
+
This is the fourth section.
+
This is the fifth section.
+
+
+
+
+ + From e03c8e6ddc334d8fd3d4d2b944fce0ebab5aeaad Mon Sep 17 00:00:00 2001 From: Simon Jockers <449739+sjockers@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:18:12 +0200 Subject: [PATCH 03/13] Add explicit docs page (default canvas won't work with scroller) --- components/src/Scroller/Scroller.mdx | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 components/src/Scroller/Scroller.mdx diff --git a/components/src/Scroller/Scroller.mdx b/components/src/Scroller/Scroller.mdx new file mode 100644 index 00000000..1a67f485 --- /dev/null +++ b/components/src/Scroller/Scroller.mdx @@ -0,0 +1,9 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; + +import * as ScrollerStories from './Scroller.stories.svelte'; + + + +# Scroller + +A scroller component for Svelte apps. From 35b4b07f4c3951b2b7a4963f58dd980603895ec6 Mon Sep 17 00:00:00 2001 From: Simon Jockers <449739+sjockers@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:34:24 +0200 Subject: [PATCH 04/13] Refactor to match code style --- components/src/Scroller/Scroller.svelte | 237 ++++++++++++++---------- 1 file changed, 144 insertions(+), 93 deletions(-) diff --git a/components/src/Scroller/Scroller.svelte b/components/src/Scroller/Scroller.svelte index ece43980..7c52a8e8 100644 --- a/components/src/Scroller/Scroller.svelte +++ b/components/src/Scroller/Scroller.svelte @@ -1,28 +1,52 @@ - - - + - - + + - + @@ -188,7 +245,7 @@ } svelte-scroller-foreground::after { - content: ' '; + content: ''; display: block; clear: both; } @@ -201,12 +258,6 @@ height: 100%; max-width: 100%; pointer-events: none; - /* height: 100%; */ - - /* in theory this helps prevent jumping */ will-change: transform; - /* -webkit-transform: translate3d(0, 0, 0); - -moz-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); */ } From 0592aac71a5bd1da1af4a1403362f80e09834129 Mon Sep 17 00:00:00 2001 From: Simon Jockers <449739+sjockers@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:35:33 +0200 Subject: [PATCH 05/13] Use passive handlers (potentially smoother scrolling) --- components/src/Scroller/Scroller.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/src/Scroller/Scroller.svelte b/components/src/Scroller/Scroller.svelte index 7c52a8e8..42088711 100644 --- a/components/src/Scroller/Scroller.svelte +++ b/components/src/Scroller/Scroller.svelte @@ -22,8 +22,8 @@ handlers.forEach((handler) => handler()); }; - window.addEventListener('scroll', runAllHandlers); - window.addEventListener('resize', runAllHandlers); + window.addEventListener('scroll', runAllHandlers, { passive: true }); + window.addEventListener('resize', runAllHandlers, { passive: true }); } // Intersection Observer for performance optimization From 06766889f162a4a14310192a69ecf7eea7d1a84f Mon Sep 17 00:00:00 2001 From: Simon Jockers <449739+sjockers@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:48:48 +0200 Subject: [PATCH 06/13] Use Svelte 5 (runes) --- components/src/Scroller/Scroller.svelte | 80 +++++++++++++++---------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/components/src/Scroller/Scroller.svelte b/components/src/Scroller/Scroller.svelte index 42088711..068596af 100644 --- a/components/src/Scroller/Scroller.svelte +++ b/components/src/Scroller/Scroller.svelte @@ -1,4 +1,4 @@ - -
- Scroll down to see the scroller in action. +
+

Scroller-Demo

+

(Scroll down to see the scroller in action)

+

@@ -23,26 +31,12 @@ over the top.

- Section {index + 1} is currently active . + Section {index + 1} is currently active.

-
- index - {index} - {index} -
- -
- offset - {offset} - {offset} -
- -
- progress - {progress} - {progress} -
+

index / {index}

+

offset / {offset}

+

progress / {progress}

This is the first section.
@@ -57,11 +51,13 @@ diff --git a/components/src/Scroller/Scroller.svelte b/components/src/Scroller/Scroller.svelte index 068596af..bead0b37 100644 --- a/components/src/Scroller/Scroller.svelte +++ b/components/src/Scroller/Scroller.svelte @@ -98,6 +98,76 @@ - -
-

Scroller-Demo

-

(Scroll down to see the scroller in action)

+ { + await step('Verify state values are set', async () => { + expect(index).toBeGreaterThanOrEqual(0); + expect(offset).toBeGreaterThanOrEqual(0); + expect(progress).toBeGreaterThanOrEqual(0); + }); + + await step('Foreground content is rendered', async () => { + const sections = canvasElement.querySelectorAll('[slot="foreground"] section'); + expect(sections).toHaveLength(5); + expect(sections[0]).toHaveTextContent('This is the first section.'); + }); + + await step('Background content is rendered', async () => { + const background = canvasElement.querySelector('[slot="background"]'); + expect(background).toBeDefined(); + expect(background).toHaveTextContent('This is the background content'); + }); + await step('Index is updated when scrolling', async () => { + // Store the event handler function separately + const scrollEndHandler = () => { + expect(index).toBe(3); + // Clean up the event listener after test has run + window.removeEventListener('scrollend', scrollEndHandler); + // Reset scroll position to top after test + window.scrollTo(0, 0); + }; + + // Add the event listener with the stored function reference + window.addEventListener('scrollend', scrollEndHandler); + + const sections = canvasElement.querySelectorAll('[slot="foreground"] section'); + sections[3].scrollIntoView(); + }); + }} +> +

From 212ecc161514fdf4d209929ff0d1fee8197da542 Mon Sep 17 00:00:00 2001 From: Simon Jockers <449739+sjockers@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:03:42 +0200 Subject: [PATCH 11/13] Use `#snippets`/`@render` instead of (deprecated) `slot` --- .../src/Scroller/Scroller.stories.svelte | 15 +++--- components/src/Scroller/Scroller.svelte | 52 ++++++++++--------- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/components/src/Scroller/Scroller.stories.svelte b/components/src/Scroller/Scroller.stories.svelte index ae057e85..7015ad72 100644 --- a/components/src/Scroller/Scroller.stories.svelte +++ b/components/src/Scroller/Scroller.stories.svelte @@ -31,13 +31,13 @@ }); await step('Foreground content is rendered', async () => { - const sections = canvasElement.querySelectorAll('[slot="foreground"] section'); + const sections = canvasElement.querySelectorAll('svelte-scroller-foreground section'); expect(sections).toHaveLength(5); expect(sections[0]).toHaveTextContent('This is the first section.'); }); await step('Background content is rendered', async () => { - const background = canvasElement.querySelector('[slot="background"]'); + const background = canvasElement.querySelector('svelte-scroller-background'); expect(background).toBeDefined(); expect(background).toHaveTextContent('This is the background content'); }); @@ -55,14 +55,14 @@ // Add the event listener with the stored function reference window.addEventListener('scrollend', scrollEndHandler); - const sections = canvasElement.querySelectorAll('[slot="foreground"] section'); + const sections = canvasElement.querySelectorAll('svelte-scroller-foreground section'); sections[3].scrollIntoView(); }); }} >

-
+ {#snippet background()}

This is the background content. It will stay fixed in place while the foreground scrolls over the top. @@ -74,14 +74,15 @@

index / {index}

offset / {offset}

progress / {progress}

-
-
+ {/snippet} + + {#snippet foreground()}
This is the first section.
This is the second section.
This is the third section.
This is the fourth section.
This is the fifth section.
-
+ {/snippet}
diff --git a/components/src/Scroller/Scroller.svelte b/components/src/Scroller/Scroller.svelte index 256c9295..55bdb7d4 100644 --- a/components/src/Scroller/Scroller.svelte +++ b/components/src/Scroller/Scroller.svelte @@ -4,7 +4,7 @@ } interface ScrollerInstance { - outer: HTMLElement; + outerWrapper: HTMLElement; update: ScrollHandler; } @@ -56,26 +56,26 @@ ); manager = { - add: ({ outer, update }: ScrollerInstance): void => { - const { top, bottom } = outer.getBoundingClientRect(); + add: ({ outerWrapper, update }: ScrollerInstance): void => { + const { top, bottom } = outerWrapper.getBoundingClientRect(); // Add handler if element is initially visible if (top < window.innerHeight && bottom > 0) { handlers.push(update); } - handlerMap.set(outer, update); - observer.observe(outer); + handlerMap.set(outerWrapper, update); + observer.observe(outerWrapper); }, - remove: ({ outer, update }: ScrollerInstance): void => { + remove: ({ outerWrapper, update }: ScrollerInstance): void => { const handlerIndex = handlers.indexOf(update); if (handlerIndex !== -1) { handlers.splice(handlerIndex, 1); } - handlerMap.delete(outer); - observer.unobserve(outer); + handlerMap.delete(outerWrapper); + observer.unobserve(outerWrapper); } }; } else { @@ -180,13 +180,15 @@ count = $bindable(0), offset = $bindable(0), progress = $bindable(0), - visible = $bindable(false) + visible = $bindable(false), + foreground = null, + background = null }: ScrollerProps = $props(); // Element bindings - let outer = $state(); - let foreground = $state(); - let background = $state(); + let outerWrapper = $state(); + let foregroundWrapper = $state(); + let backgroundWrapper = $state(); // Internal state let sections = $state>(); @@ -213,14 +215,14 @@ }); onMount(() => { - if (!foreground) return; + if (!foregroundWrapper) return; - sections = foreground.querySelectorAll(query); + sections = foregroundWrapper.querySelectorAll(query); count = sections.length; update(); - const scrollerInstance: ScrollerInstance = { outer, update }; + const scrollerInstance: ScrollerInstance = { outerWrapper, update }; manager.add(scrollerInstance); return () => { @@ -229,7 +231,7 @@ }); function update(): void { - if (!foreground || !background || !outer) return; + if (!foregroundWrapper || !backgroundWrapper || !outerWrapper) return; updateContainerMeasurements(); updateScrollProgress(); @@ -237,14 +239,14 @@ } function updateContainerMeasurements(): void { - const outerRect = outer.getBoundingClientRect(); + const outerRect = outerWrapper.getBoundingClientRect(); containerLeft = outerRect.left; containerWidth = outerRect.right - outerRect.left; } function updateScrollProgress(): void { - const foregroundRect = foreground.getBoundingClientRect(); - const backgroundRect = background.getBoundingClientRect(); + const foregroundRect = foregroundWrapper.getBoundingClientRect(); + const backgroundRect = backgroundWrapper.getBoundingClientRect(); visible = foregroundRect.top < windowHeight && foregroundRect.bottom > 0; @@ -262,7 +264,7 @@ function updateActiveSection(): void { if (!sections?.length) return; - const foregroundRect = foreground.getBoundingClientRect(); + const foregroundRect = foregroundWrapper.getBoundingClientRect(); for (let i = 0; i < sections.length; i++) { const section = sections[i]; @@ -286,15 +288,15 @@ - + - - + + {@render background()} - - + + {@render foreground()} From 27103fe5fefdac6d372e28d6769662fbfdbe0881 Mon Sep 17 00:00:00 2001 From: Simon Jockers <449739+sjockers@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:22:44 +0200 Subject: [PATCH 12/13] Update docs --- components/src/Scroller/Scroller.svelte | 29 +++++++++++++------------ 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/components/src/Scroller/Scroller.svelte b/components/src/Scroller/Scroller.svelte index 55bdb7d4..f6ed8daa 100644 --- a/components/src/Scroller/Scroller.svelte +++ b/components/src/Scroller/Scroller.svelte @@ -102,87 +102,88 @@ * Props interface for the Scroller component */ interface ScrollerProps { + /** + * The content to show in the foreground, to be set via `{#snippet foreground()}`. This should contain multiple sections, each matching the `query` selector. + */ + foreground?: Snippet; + + /** + * The content to show in the background, to be set via `{#snippet background()}`. This will be fixed in place while the foreground scrolls over it. + */ + background?: Snippet; + /** * The vertical position that the top of the foreground must scroll past before the background becomes fixed, * as a proportion of window height (0 = top of viewport, 1 = bottom of viewport) - * @default 0 */ top?: number; /** * The inverse of top — once the bottom of the foreground passes this point, the background becomes unfixed. * As a proportion of window height (0 = top of viewport, 1 = bottom of viewport) - * @default 1 */ bottom?: number; /** * Once a section crosses this point, it becomes 'active'. * As a proportion of window height (0 = top of viewport, 1 = bottom of viewport) - * @default 0.5 */ threshold?: number; /** * A CSS selector that describes the individual sections of your foreground - * @default 'section' */ query?: string; /** * If true, the background will scroll such that the bottom edge reaches the bottom at the same time as the foreground. * This effect can be unpleasant for people with high motion sensitivity, so use it advisedly. - * @default false */ parallax?: boolean; /** * The index of the currently active section (bindable) - * @default 0 */ index?: number; /** * The total number of sections (bindable) - * @default 0 */ count?: number; /** * How far the section has scrolled past the threshold, as a value between 0 and 1 (bindable) - * @default 0 */ offset?: number; /** * How far the foreground has travelled, where 0 is the top of the foreground crossing top, * and 1 is the bottom crossing bottom (bindable) - * @default 0 */ progress?: number; /** * Whether the scroller is currently visible in the viewport (bindable) - * @default false */ visible?: boolean; } - // Configuration props (read-only) let { + // Configuration props (read-only) + foreground = null, + background = null, top = 0, bottom = 1, threshold = 0.5, query = 'section', parallax = false, + // Binding props (two-way binding) index = $bindable(0), count = $bindable(0), offset = $bindable(0), progress = $bindable(0), - visible = $bindable(false), - foreground = null, - background = null + visible = $bindable(false) }: ScrollerProps = $props(); // Element bindings From 7676813760e31b3dd545f0c5e741e849559526fb Mon Sep 17 00:00:00 2001 From: Simon Jockers <449739+sjockers@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:40:42 +0200 Subject: [PATCH 13/13] Add component export --- components/src/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/components/src/index.js b/components/src/index.js index 347844e9..b93bed42 100644 --- a/components/src/index.js +++ b/components/src/index.js @@ -11,6 +11,7 @@ export { default as Note } from './Note/Note.svelte'; // Display export { default as Card } from './Card/Card.svelte'; +export { default as Scroller } from './Scroller/Scroller.svelte'; // Chart export { default as ChartHeader } from './ChartHeader/ChartHeader.svelte';