diff --git a/src/components/HorizontalScroller/Debug.svelte b/src/components/HorizontalScroller/Debug.svelte new file mode 100644 index 000000000..f3da8a3ee --- /dev/null +++ b/src/components/HorizontalScroller/Debug.svelte @@ -0,0 +1,355 @@ + + + + +{#snippet triggerPoints()} + {#if componentState.triggerStops.length > 0} + {#if componentState.scrubbed} + {@const totalStops = componentState.triggerStops.length} + {#each Array(totalStops) as _, index} + | + {/each} + {:else} + {@const stops = componentState.triggerStops.map((x: number) => + mappedStop(x) + )} + {#each stops as stop, index} + {#if index < stops.length - 1} + | + {/if} + {/each} + {/if} + {/if} +{/snippet} + +
+
+ + CONSOLE + +
+ +

Progress:

+
+

+ {componentState.progress} +

+
+ +

Mapped progress:

+
+

+ {@render triggerPoints()} + {fmt.format(componentState.mappedProgress)} +   +

+
+
+
+
+ +

Eased Progress:

+
+

+ {#if componentState.stops.length > 0} + {#each componentState.stops as stop} + {stop} + {/each} + {/if} + {fmt.format(componentState.easedProgress)} +   +

+
+
+
+
+ +

Direction:

+
+

+ {componentState.direction} +

+
+ + {#if componentState.stops.length > 0} +

Stops:

+
+

+ {#each componentState.stops as stop} + {stop} + {/each} +

+
+ {/if} + +

Handle scroll:

+
+

+ {componentState.handleScroll} +

+
+ +

Scrubbed:

+
+

+ {componentState.scrubbed} +

+
+ +

Easing:

+
+

+ {componentState.easing} +

+
+ +

+ Duration: + {#if componentState.scrubbed} + NA + {/if} +

+
+

+ {componentState.duration} +

+
+ +
+
+
+ + diff --git a/src/components/HorizontalScroller/HorizontalScroller.mdx b/src/components/HorizontalScroller/HorizontalScroller.mdx new file mode 100644 index 000000000..1fae94079 --- /dev/null +++ b/src/components/HorizontalScroller/HorizontalScroller.mdx @@ -0,0 +1,447 @@ +import { Meta } from '@storybook/blocks'; + +import * as HorizontalScrollerStories from './HorizontalScroller.stories.svelte'; + +import IllustratorScreenshot from './assets/illustrator.png'; + + + +# HorizontalScroller + +The `HorizontalScroller` component creates a horizontal scrolling section that scrolls through any child content wider than `100vw`. + +To use `HorizontalScroller`, wrap it around the content that you want to horizontally scroll through. The scroll length is controlled by the height of the `HorizontalScroller` container, which is set by the prop `height`. `height` defaults to `200lvh`, but you can adjust this to any valid CSS height value such as `1200px` or `400lvh`. + +The child content inside the `HorizontalScroller` must be wider than `100vw` so that there is overflow to horizontal scroll through. By default, only the top `100lvh` of the child content is visible. You can use CSS `transform: translate()` on the child content to adjust its vertical positioning within the visible area. + +> 💡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: Set the `showDebugInfo` prop to `true` to visualise the scroll progress and other useful information. + +See the full list of available props under the `Controls` tab in the [demo](?path=/story/components-graphics-horizontalscroller--demo). + +```svelte + + + + + + + +
+ alt text +
+
+
+``` + +## Controlling scroll behaviour with stops and easing + +The `HorizontalScroller` allows you to control the horizontal scroll behaviour and pacing with various props. + +**`stops`:** + +`stops` is an optional prop that accepts an array of numbers between `0` and `1`. At these points, which corresponds to the scroll `progress` values, the scrolling stops or slows down. This is useful for adding custom pauses based on progress. + +For example, as shown in the demo below, if you define `stops` as `[0.2, 0.5, 0.9]`, the scrolling will pause or slow down at these `progress` values as the user scrolls through the `HorizontalScroller` section. + +**`scrubbed`:** + +The `scrubbed` prop controls whether the scrolling is tied exactly to the scroll position (`scrubbed: true`) or is smoothed out (`scrubbed: false`). This prop defaults to `true`. + +If `scrubbed` is set to `false` and `stops` are defined, the scrolling transitions smoothly between the stop values. + +**`easing`** and **`duration`**: + +`easing` accepts any easing function from `svelte/easing` or a custom easing function, while `duration` sets the time, in milliseconds, for each transition between stops. + +So, if the stops are at irregular intervals — for example, `[0.2, 0.9]` — the scroll to the first stop will be much quicker than the scroll to the second stop since the distance to travel is different but the duration of the transition is the same. + +By default, `duration` is set to `400` milliseconds. + +[Demo](?path=/story/components-graphics-horizontalscroller--with-stops) + +```svelte + + + + + + +
+ alt text +
+
+
+``` + +## Extended boundaries + +`HorizontalScroller` has `mappedStart` and `mappedEnd` props, which extend the horizontal scroll boundaries beyond the default 0 to 1 range. This is useful when you want to create an overscroll effect or have more control over the horizontal scroll range. By default, these values are set to 0 and 1 respectively. + +If using custom `mappedStart` and `mappedEnd` values, you must also set `stops` values that are within the mapped range. + +> 💡TIP: In the debugging info box, `Progress` indicates the raw scroll progress value between `0` and `1`. `Mapped Progress` indicates the vertical progress mapped to `mappedStart` and `mappedEnd`. If they are not set, `Mapped Progress` is bound between 0 and 1 and matches `Progress`. `Eased Progress` indicates the scroll progress with any stops and easing applied. `Eased Progress` is what reflects the actual transition of the horizontal scroll position. + +[Demo](?path=/story/components-graphics-horizontalscroller--extended-boundaries) + +```svelte + + + + + + +
+ alt text +
+
+
+``` + +## With ai2svelte components + +With [ai2svelte](https://reuters-graphics.github.io/ai2svelte/) v1.0.3 onwards, you can export your ai2svelte graphic with a wider-than-viewport layout and use it directly inside `HorizontalScroller` to create horizontally scrolling graphics. + +To do that, follow these steps: + +1. In Illustrator, rename your artboard with the breakpoint at which you want that artboard to be visible on the page. For example, to make the XL artboard visible on viewports wider than 1200px, rename it to `xl:1200`. You can have multiple artboards with different breakpoints. +2. Add these properties to the ai2svelte settings and run the script to export the component. + +```yaml +include_resizer_css: false +respect_height: true +allow_overflow: true +``` + +Screenshot showing Illustrator document with artboard panel + +[Demo](?path=/story/components-graphics-horizontalscroller--scrollable-ai-2-svelte) + +```svelte + + + + + + + + +``` + +## With ai2svelte components: advanced + +You can use the bound prop `progress` to create advanced interactivity with an ai2svelte graphic. + +The demo below has 2 advanced interactions: fade in/out of caption boxes based on scroll position and parallax movement of a `png` layer. + +### Captions fading in/out + +Caption boxes are exported as `htext` [tagged layers](https://reuters-graphics.github.io/ai2svelte/users/tagged-layers/) in ai2svelte. In this example, we use the `handleScroll()` function to check the position of each caption box relative to the viewport width and set its opacity to `1` (visible) or `0` (hidden) based on whether the caption box is within the `threshold` of the viewport. In Adobe Illustrator, set `override_text: true` in the ai2svelte export settings to allow custom HTML content in tagged text layers. + +### Parallax effect with png layer + +This demo has a tagged `png` [layer](https://reuters-graphics.github.io/ai2svelte/users/tagged-layers/), which contains the foreground overlay image. The `handleScroll()` function uses the bound `progress` value to calculate a horizontal translation for the `png` layer, creating a parallax effect as the user scrolls through the `HorizontalScroller`. + +[Demo](?path=/story/components-graphics-horizontalscroller--scrollable-ai-2-svelte-advanced) + +```svelte + + + + + + Caption 1!
Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + caption2: + '
Caption 2!
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
', + caption3: + '
Caption 3!
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
', + caption4: + '
Caption 4!
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
', + }, + }, + }} + /> +
+
+ + +``` + +## With custom child components + +You can create a custom horizontal layout with any component and pass it as a child to the `HorizontalScroller`. Here's an example with `DatawrapperChart`, `Headline` and ai2svelte components laid out in a horizontal scroll. + +[Demo](?path=/story/components-graphics-horizontalscroller--custom-children) + +```svelte + + + + + +
+
+ +
+
+ +
+
+ + + +
+
+
+
+ + +``` + +## With ScrollerBase + +You can also integrate HorizontalScroller with `ScrollerBase` for a horizontal scroll with vertical captions. + +When using `HorizontalScroller` with `ScrollerBase` or other scrollers, you must: + +- Create a `progress` state variable and bind it to both `ScrollerBase` and `HorizontalScroller` +- Set `HorizontalScroller`'s `height` to `100lvh` +- Set `handleScroll` to `false` + +> **⚠️ Warning:** It is not recommended to use HorizontalScroller with vertical ScrollerBase. This example is only to serve the purpose of demonstrating how to control the HorizontalScroller with an external progress value (ScrollerBase's progress in this case). + +[Demo](?path=/story/components-graphics-horizontalscroller--with-scroller-base) + +```svelte + + + + {#snippet backgroundSnippet()} + + + + + + + {/snippet} + {#snippet foregroundSnippet()} + +

Step 1

+

Step 2

+

Step 3

+

Step 4

+

Step 5

+ {/snippet} +
+ + +``` diff --git a/src/components/HorizontalScroller/HorizontalScroller.stories.svelte b/src/components/HorizontalScroller/HorizontalScroller.stories.svelte new file mode 100644 index 000000000..c17dfbf31 --- /dev/null +++ b/src/components/HorizontalScroller/HorizontalScroller.stories.svelte @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/HorizontalScroller/HorizontalScroller.svelte b/src/components/HorizontalScroller/HorizontalScroller.svelte new file mode 100644 index 000000000..7a66e6dc0 --- /dev/null +++ b/src/components/HorizontalScroller/HorizontalScroller.svelte @@ -0,0 +1,254 @@ + + + + +
+
+ {#if children} + {@render children()} + {/if} + {#if showDebugInfo} +
+ +
+ {/if} +
+
+ + diff --git a/src/components/HorizontalScroller/assets/illustrator.png b/src/components/HorizontalScroller/assets/illustrator.png new file mode 100644 index 000000000..7de9355d6 Binary files /dev/null and b/src/components/HorizontalScroller/assets/illustrator.png differ diff --git a/src/components/HorizontalScroller/demo/AdvancedScrollableGraphic.svelte b/src/components/HorizontalScroller/demo/AdvancedScrollableGraphic.svelte new file mode 100644 index 000000000..c3f51e40f --- /dev/null +++ b/src/components/HorizontalScroller/demo/AdvancedScrollableGraphic.svelte @@ -0,0 +1,103 @@ + + + + + + + + + Caption 1!
Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + caption2: + '
Caption 2!
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
', + caption3: + '
Caption 3!
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
', + caption4: + '
Caption 4!
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
', + }, + }, + }} + /> +
+
+ + + + diff --git a/src/components/HorizontalScroller/demo/CustomChildrenSnippet.svelte b/src/components/HorizontalScroller/demo/CustomChildrenSnippet.svelte new file mode 100644 index 000000000..fb2749c73 --- /dev/null +++ b/src/components/HorizontalScroller/demo/CustomChildrenSnippet.svelte @@ -0,0 +1,44 @@ + + +
+
+ +
+
+ +
+
+ + + +
+
+ + diff --git a/src/components/HorizontalScroller/demo/Demo.svelte b/src/components/HorizontalScroller/demo/Demo.svelte new file mode 100644 index 000000000..c0fbb8de5 --- /dev/null +++ b/src/components/HorizontalScroller/demo/Demo.svelte @@ -0,0 +1,42 @@ + + + + +{#if args.toggleScrub} + + + +{/if} + + + + + + + + diff --git a/src/components/HorizontalScroller/demo/DemoSnippet.svelte b/src/components/HorizontalScroller/demo/DemoSnippet.svelte new file mode 100644 index 000000000..cda252db1 --- /dev/null +++ b/src/components/HorizontalScroller/demo/DemoSnippet.svelte @@ -0,0 +1,7 @@ +
+ An ultra wide scenic view of cityscape +
diff --git a/src/components/HorizontalScroller/demo/ScrollableGraphic.svelte b/src/components/HorizontalScroller/demo/ScrollableGraphic.svelte new file mode 100644 index 000000000..7b03f2663 --- /dev/null +++ b/src/components/HorizontalScroller/demo/ScrollableGraphic.svelte @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/src/components/HorizontalScroller/demo/graphic/ai2svelte/ai-chart.svelte b/src/components/HorizontalScroller/demo/graphic/ai2svelte/ai-chart.svelte new file mode 100644 index 000000000..704b6eaf4 --- /dev/null +++ b/src/components/HorizontalScroller/demo/graphic/ai2svelte/ai-chart.svelte @@ -0,0 +1,630 @@ + + + + +
+ + {#if width && width >= 0 && width < 510} +
+
+
+
+

Shake intensity

+
+
+

Light

+
+
+

Moderate

+
+
+

Cap-Haitien

+
+
+

Strong

+
+
+

Very strong

+
+
+

Gonaïves

+
+
+

Caribbean

+

Sea

+
+
+

HAITI

+
+
+

Jeremie

+
+
+

Port-au-Prince

+
+
+

Epicenter

+
+
+

Jacmel

+
+
+

Les Cayes

+
+
+

50 mi

+
+
+

Dominican

+

Republic

+
+
+

50 km

+
+
+ {/if} + + {#if width && width >= 510 && width < 660} +
+
+
+
+

Shake intensity

+
+
+

Light

+
+
+

Moderate

+
+
+

Cap-Haitien

+
+
+

Strong

+
+
+

Very strong

+
+
+

Gonaïves

+
+
+

Caribbean

+

Sea

+
+
+

HAITI

+
+
+

Jeremie

+
+
+

Port-au-Prince

+
+
+

Epicenter

+
+
+

Dominican

+

Republic

+
+
+

Jacmel

+
+
+

Les Cayes

+
+
+

50 mi

+
+
+

50 km

+
+
+ {/if} + + {#if width && width >= 660} +
+
+
+
+

Shake intensity

+
+
+

Light

+
+
+

Moderate

+
+
+

Cap-Haitien

+
+
+

Strong

+
+
+

Very strong

+
+
+

Gonaïves

+
+
+

Caribbean

+

Sea

+
+
+

HAITI

+
+
+

Dominican

+

Republic

+
+
+

Jeremie

+
+
+

Epicenter

+
+
+

Port-au-Prince

+
+
+

Jacmel

+
+
+

Les Cayes

+
+
+

50 mi

+
+
+

50 km

+
+
+ {/if} +
+ + + + + diff --git a/src/components/HorizontalScroller/demo/graphic/ai2svelte/demo.svelte b/src/components/HorizontalScroller/demo/graphic/ai2svelte/demo.svelte new file mode 100644 index 000000000..de14038b6 --- /dev/null +++ b/src/components/HorizontalScroller/demo/graphic/ai2svelte/demo.svelte @@ -0,0 +1,280 @@ + + + + +
+ + {#if aiBoxWidth && aiBoxWidth >= 0 && aiBoxWidth < 1200} +
+
+
+
+ {/if} + + {#if aiBoxWidth && aiBoxWidth >= 1200} +
+
+
+
+

+ {@html taggedText?.htext?.captions?.caption2 || ''} +

+
+
+

+ {@html taggedText?.htext?.captions?.caption3 || ''} +

+
+
+

+ {@html taggedText?.htext?.captions?.caption4 || ''} +

+
+
+

+ {@html taggedText?.htext?.captions?.caption1 || ''} +

+
+
+ {/if} +
+ + + + + + + diff --git a/src/components/HorizontalScroller/demo/graphic/imgs/ai-chart-md.png b/src/components/HorizontalScroller/demo/graphic/imgs/ai-chart-md.png new file mode 100644 index 000000000..7f62d44c6 Binary files /dev/null and b/src/components/HorizontalScroller/demo/graphic/imgs/ai-chart-md.png differ diff --git a/src/components/HorizontalScroller/demo/graphic/imgs/ai-chart-sm.png b/src/components/HorizontalScroller/demo/graphic/imgs/ai-chart-sm.png new file mode 100644 index 000000000..49c06f65a Binary files /dev/null and b/src/components/HorizontalScroller/demo/graphic/imgs/ai-chart-sm.png differ diff --git a/src/components/HorizontalScroller/demo/graphic/imgs/ai-chart-xs.png b/src/components/HorizontalScroller/demo/graphic/imgs/ai-chart-xs.png new file mode 100644 index 000000000..15d640df3 Binary files /dev/null and b/src/components/HorizontalScroller/demo/graphic/imgs/ai-chart-xs.png differ diff --git a/src/components/HorizontalScroller/demo/graphic/imgs/demo-lg.jpg b/src/components/HorizontalScroller/demo/graphic/imgs/demo-lg.jpg new file mode 100644 index 000000000..7a22ffb1f Binary files /dev/null and b/src/components/HorizontalScroller/demo/graphic/imgs/demo-lg.jpg differ diff --git a/src/components/HorizontalScroller/demo/graphic/imgs/demo-xl.jpg b/src/components/HorizontalScroller/demo/graphic/imgs/demo-xl.jpg new file mode 100644 index 000000000..342cf78f1 Binary files /dev/null and b/src/components/HorizontalScroller/demo/graphic/imgs/demo-xl.jpg differ diff --git a/src/components/HorizontalScroller/demo/graphic/imgs/layer-overlay-lg.png b/src/components/HorizontalScroller/demo/graphic/imgs/layer-overlay-lg.png new file mode 100644 index 000000000..93b1bb013 Binary files /dev/null and b/src/components/HorizontalScroller/demo/graphic/imgs/layer-overlay-lg.png differ diff --git a/src/components/HorizontalScroller/demo/graphic/imgs/layer-overlay-xl.png b/src/components/HorizontalScroller/demo/graphic/imgs/layer-overlay-xl.png new file mode 100644 index 000000000..2a601fb8b Binary files /dev/null and b/src/components/HorizontalScroller/demo/graphic/imgs/layer-overlay-xl.png differ diff --git a/src/components/HorizontalScroller/demo/graphic/placeholder.png b/src/components/HorizontalScroller/demo/graphic/placeholder.png new file mode 100644 index 000000000..ca5216495 Binary files /dev/null and b/src/components/HorizontalScroller/demo/graphic/placeholder.png differ diff --git a/src/components/HorizontalScroller/demo/withScrollerBase.svelte b/src/components/HorizontalScroller/demo/withScrollerBase.svelte new file mode 100644 index 000000000..fe3a26790 --- /dev/null +++ b/src/components/HorizontalScroller/demo/withScrollerBase.svelte @@ -0,0 +1,63 @@ + + + + + + {#snippet backgroundSnippet()} + + + + + + {/snippet} + {#snippet foregroundSnippet()} + +

Step 1

+

Step 2

+

Step 3

+

Step 4

+

Step 5

+ {/snippet} +
+ + diff --git a/src/components/HorizontalScroller/utils/index.ts b/src/components/HorizontalScroller/utils/index.ts new file mode 100644 index 000000000..06807f9b0 --- /dev/null +++ b/src/components/HorizontalScroller/utils/index.ts @@ -0,0 +1,40 @@ +/** + * Clamp a number `n` to the inclusive range [low, high]. + */ +export function clamp(n: number, low: number, high: number): number { + // Ensure low <= high even if caller swaps them + const min = Math.min(low, high); + const max = Math.max(low, high); + return Math.max(min, Math.min(n, max)); +} + +/** + * Linearly maps a value `n` from range [inStart, inEnd] to [outStart, outEnd]. + * + * @param {number} n - The input value to map. + * @param {number} inStart - Input range start. + * @param {number} inEnd - Input range end. + * @param {number} outStart - Output range start. + * @param {number} outEnd - Output range end. + * @param {boolean} withinBounds - If true, clamp the mapped value to [outStart, outEnd]. + * @returns {number} - Mapped (and optionally clamped) value. + */ +export function map( + n: number, + inStart: number, + inEnd: number, + outStart: number, + outEnd: number, + withinBounds: boolean = true +): number { + // Avoid division by zero: when input range is degenerate, return outStart + const inSpan = inEnd - inStart; + if (inSpan === 0) { + return withinBounds ? clamp(outStart, outStart, outEnd) : outStart; + } + + const t = (n - inStart) / inSpan; // normalized 0..1 in input space (or beyond) + const out = t * (outEnd - outStart) + outStart; + + return withinBounds ? clamp(out, outStart, outEnd) : out; +} diff --git a/src/index.ts b/src/index.ts index 790e93061..5dd53bd7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ export { default as GraphicBlock } from './components/GraphicBlock/GraphicBlock. export { default as Headline } from './components/Headline/Headline.svelte'; export { default as Headpile } from './components/Headpile/Headpile.svelte'; export { default as HeroHeadline } from './components/HeroHeadline/HeroHeadline.svelte'; +export { default as HorizontalScroller } from './components/HorizontalScroller/HorizontalScroller.svelte'; export { default as EndNotes } from './components/EndNotes/EndNotes.svelte'; export { default as InfoBox } from './components/InfoBox/InfoBox.svelte'; export { default as InlineAd } from './components/AdSlot/InlineAd.svelte';