@vit-foundation/ui 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ <!--
2
+ @component CrossfadeVideo
3
+
4
+ One responsive video: a desktop element and a mobile element, each with the
5
+ same three `<source>`s (webm → "safe" mp4 → mp4), plus the play / ended /
6
+ replay state that every scrolly video on the site used to re-implement.
7
+
8
+ Only one of the two elements is ever displayed (`desktopClass` /
9
+ `mobileClass` carry the breakpoint), but both exist so the browser picks the
10
+ right encode without a JS media query — the arrangement the feature
11
+ components already used.
12
+
13
+ Playback comes in two flavours:
14
+ - **managed** (default): the component plays from the first frame whenever
15
+ `active` turns true and pauses when it turns false.
16
+ - **native** (`autoplay`): playback is the browser's, and `active` only
17
+ matters for whatever classes the caller crossfades with.
18
+
19
+ Swapping the `<source>` children of a live `<video>` has no effect, so the
20
+ elements are keyed on their sources and recreated when those change.
21
+
22
+ @prop {string} [srcBase] - Folder the standard file names expand from:
23
+ `<srcBase>/desktop.webm`, `/desktop-safe.mp4`, `/desktop.mp4` and the
24
+ matching `mobile.*`. Ignored when `sources` is given.
25
+ @prop {CrossfadeVideoSources} [sources] - Explicit per-breakpoint sources,
26
+ for folders that don't follow the `desktop.*` / `mobile.*` naming.
27
+ @prop {boolean} [active=true] - Whether this video is the one on screen.
28
+ Drives managed playback.
29
+ @prop {boolean} [autoplay=false] - Hand playback to the browser (`autoplay`
30
+ on both elements) instead of managing it.
31
+ @prop {boolean} [loop=false] - Loop both elements.
32
+ @prop {string} [poster] - Poster frame for both elements.
33
+ @prop {string} [class] - Classes shared by both `<video>` elements.
34
+ @prop {string} [desktopClass='hidden lg:block'] - Extra classes on the
35
+ desktop element, carrying its breakpoint.
36
+ @prop {string} [mobileClass='block lg:hidden'] - Extra classes on the
37
+ mobile element, carrying its breakpoint.
38
+ @prop {boolean} [replayable=true] - Whether reaching the end offers a replay.
39
+ @prop {string} [replayClass] - Extra classes on the replay button, which is
40
+ already `absolute inset-0` over the caller's positioned wrapper.
41
+ @prop {string} [replayLabel='Replay video'] - Replay button aria-label.
42
+ @prop {Snippet} [overlay] - Replay button content. Defaults to the shared
43
+ reload badge.
44
+ @prop {() => void} [onended] - Called once when the video reaches its end.
45
+ -->
46
+ <script lang="ts" module>
47
+ /** The three encodes one breakpoint offers, in `<source>` order. */
48
+ export type CrossfadeVideoTrack = {
49
+ /** VP9/webm, preferred where it decodes. */
50
+ webm: string;
51
+ /** Conservatively encoded mp4, for players that choke on the main one. */
52
+ mp4Safe: string;
53
+ /** Main h.264 mp4. */
54
+ mp4: string;
55
+ };
56
+
57
+ /** Both breakpoints' encodes. */
58
+ export type CrossfadeVideoSources = {
59
+ desktop: CrossfadeVideoTrack;
60
+ mobile: CrossfadeVideoTrack;
61
+ };
62
+
63
+ /** What `bind:this` on a `<CrossfadeVideo>` exposes. */
64
+ export type CrossfadeVideoHandle = { replay: () => void };
65
+ </script>
66
+
67
+ <script lang="ts">
68
+ import type { Snippet } from 'svelte';
69
+
70
+ let {
71
+ srcBase = '',
72
+ sources,
73
+ active = true,
74
+ autoplay = false,
75
+ loop = false,
76
+ poster,
77
+ class: className = '',
78
+ desktopClass = 'hidden lg:block',
79
+ mobileClass = 'block lg:hidden',
80
+ replayable = true,
81
+ replayClass = '',
82
+ replayLabel = 'Replay video',
83
+ overlay,
84
+ onended
85
+ }: {
86
+ srcBase?: string;
87
+ sources?: CrossfadeVideoSources;
88
+ active?: boolean;
89
+ autoplay?: boolean;
90
+ loop?: boolean;
91
+ poster?: string;
92
+ class?: string;
93
+ desktopClass?: string;
94
+ mobileClass?: string;
95
+ replayable?: boolean;
96
+ replayClass?: string;
97
+ replayLabel?: string;
98
+ overlay?: Snippet;
99
+ onended?: () => void;
100
+ } = $props();
101
+
102
+ const resolved: CrossfadeVideoSources = $derived(
103
+ sources ?? {
104
+ desktop: {
105
+ webm: `${srcBase}/desktop.webm`,
106
+ mp4Safe: `${srcBase}/desktop-safe.mp4`,
107
+ mp4: `${srcBase}/desktop.mp4`
108
+ },
109
+ mobile: {
110
+ webm: `${srcBase}/mobile.webm`,
111
+ mp4Safe: `${srcBase}/mobile-safe.mp4`,
112
+ mp4: `${srcBase}/mobile.mp4`
113
+ }
114
+ }
115
+ );
116
+
117
+ /** Recreating the elements is the only way a `<source>` swap takes effect. */
118
+ const srcKey = $derived(`${resolved.desktop.webm}|${resolved.mobile.webm}`);
119
+
120
+ let desktopEl = $state<HTMLVideoElement | null>(null);
121
+ let mobileEl = $state<HTMLVideoElement | null>(null);
122
+ let ended = $state(false);
123
+
124
+ /** Both elements, in the order they are played/paused. */
125
+ const elements = $derived([desktopEl, mobileEl]);
126
+
127
+ /** Rewinds and plays both elements. Autoplay rejections are expected. */
128
+ function playFromStart() {
129
+ ended = false;
130
+ for (const el of elements) {
131
+ if (!el) continue;
132
+ el.currentTime = 0;
133
+ // `play()` returns undefined in non-browser DOMs; normalize before catching
134
+ void Promise.resolve(el.play()).catch(() => {
135
+ // Autoplay may be blocked by the browser — silently fail
136
+ });
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Restarts playback from the first frame.
142
+ *
143
+ * Exposed on the component instance so a host that plays on its own trigger
144
+ * (a section scrolling back into view, say) can ask for a replay.
145
+ */
146
+ export function replay() {
147
+ playFromStart();
148
+ }
149
+
150
+ /** Leaves the video on its last frame and offers the replay affordance. */
151
+ function handleEnded() {
152
+ if (ended) return; // already handled by the other element
153
+ ended = true;
154
+ onended?.();
155
+ }
156
+
157
+ $effect(() => {
158
+ if (autoplay) return; // native playback: the browser owns it
159
+ if (active) playFromStart();
160
+ else for (const el of elements) el?.pause();
161
+ });
162
+ </script>
163
+
164
+ {#key srcKey}
165
+ <!-- Desktop -->
166
+ <video
167
+ bind:this={desktopEl}
168
+ muted
169
+ playsinline
170
+ {autoplay}
171
+ {loop}
172
+ {poster}
173
+ onended={handleEnded}
174
+ class="{className} {desktopClass}"
175
+ >
176
+ <source src={resolved.desktop.webm} type="video/webm" />
177
+ <source src={resolved.desktop.mp4Safe} type="video/mp4" />
178
+ <source src={resolved.desktop.mp4} type="video/mp4" />
179
+ </video>
180
+
181
+ <!-- Mobile -->
182
+ <video
183
+ bind:this={mobileEl}
184
+ muted
185
+ playsinline
186
+ {autoplay}
187
+ {loop}
188
+ {poster}
189
+ onended={handleEnded}
190
+ class="{className} {mobileClass}"
191
+ >
192
+ <source src={resolved.mobile.webm} type="video/webm" />
193
+ <source src={resolved.mobile.mp4Safe} type="video/mp4" />
194
+ <source src={resolved.mobile.mp4} type="video/mp4" />
195
+ </video>
196
+
197
+ {#if ended && replayable}
198
+ <button onclick={replay} class="vit-video__replay {replayClass}" aria-label={replayLabel}>
199
+ {#if overlay}
200
+ {@render overlay()}
201
+ {:else}
202
+ <!-- The default badge draws its own icon rather than fetching one:
203
+ a package cannot assume a host ships `/common/reload.svg`. Pass
204
+ the `overlay` snippet to replace it entirely. -->
205
+ <span class="vit-video__replay-badge">
206
+ <svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true" focusable="false">
207
+ <path d="M12 5V2L8 6l4 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7Z" fill="currentColor" />
208
+ </svg>
209
+ </span>
210
+ {/if}
211
+ </button>
212
+ {/if}
213
+ {/key}
214
+
215
+ <style>
216
+ /*
217
+ * The replay affordance covers the finished video. Presentation is plain CSS
218
+ * so the component needs no framework; pass `replayClass` or the `overlay`
219
+ * snippet to restyle it.
220
+ */
221
+ .vit-video__replay {
222
+ position: absolute;
223
+ inset: 0;
224
+ z-index: 10;
225
+ display: flex;
226
+ align-items: center;
227
+ justify-content: center;
228
+ padding: 0;
229
+ border: 0;
230
+ background: none;
231
+ cursor: pointer;
232
+ pointer-events: auto;
233
+ }
234
+
235
+ .vit-video__replay-badge {
236
+ display: inline-flex;
237
+ align-items: center;
238
+ justify-content: center;
239
+ padding: var(--video-badge-padding, 1rem);
240
+ border-radius: 9999px;
241
+ color: var(--video-badge-color, #111827);
242
+ background: var(--video-badge-bg, rgb(255 255 255 / 0.5));
243
+ backdrop-filter: blur(4px);
244
+ -webkit-backdrop-filter: blur(4px);
245
+ }
246
+ </style>
@@ -0,0 +1,85 @@
1
+ /** The three encodes one breakpoint offers, in `<source>` order. */
2
+ export type CrossfadeVideoTrack = {
3
+ /** VP9/webm, preferred where it decodes. */
4
+ webm: string;
5
+ /** Conservatively encoded mp4, for players that choke on the main one. */
6
+ mp4Safe: string;
7
+ /** Main h.264 mp4. */
8
+ mp4: string;
9
+ };
10
+ /** Both breakpoints' encodes. */
11
+ export type CrossfadeVideoSources = {
12
+ desktop: CrossfadeVideoTrack;
13
+ mobile: CrossfadeVideoTrack;
14
+ };
15
+ /** What `bind:this` on a `<CrossfadeVideo>` exposes. */
16
+ export type CrossfadeVideoHandle = {
17
+ replay: () => void;
18
+ };
19
+ import type { Snippet } from 'svelte';
20
+ type $$ComponentProps = {
21
+ srcBase?: string;
22
+ sources?: CrossfadeVideoSources;
23
+ active?: boolean;
24
+ autoplay?: boolean;
25
+ loop?: boolean;
26
+ poster?: string;
27
+ class?: string;
28
+ desktopClass?: string;
29
+ mobileClass?: string;
30
+ replayable?: boolean;
31
+ replayClass?: string;
32
+ replayLabel?: string;
33
+ overlay?: Snippet;
34
+ onended?: () => void;
35
+ };
36
+ /**
37
+ * CrossfadeVideo
38
+ *
39
+ * One responsive video: a desktop element and a mobile element, each with the
40
+ * same three `<source>`s (webm → "safe" mp4 → mp4), plus the play / ended /
41
+ * replay state that every scrolly video on the site used to re-implement.
42
+ *
43
+ * Only one of the two elements is ever displayed (`desktopClass` /
44
+ * `mobileClass` carry the breakpoint), but both exist so the browser picks the
45
+ * right encode without a JS media query — the arrangement the feature
46
+ * components already used.
47
+ *
48
+ * Playback comes in two flavours:
49
+ * - **managed** (default): the component plays from the first frame whenever
50
+ * `active` turns true and pauses when it turns false.
51
+ * - **native** (`autoplay`): playback is the browser's, and `active` only
52
+ * matters for whatever classes the caller crossfades with.
53
+ *
54
+ * Swapping the `<source>` children of a live `<video>` has no effect, so the
55
+ * elements are keyed on their sources and recreated when those change.
56
+ *
57
+ * @prop {string} [srcBase] - Folder the standard file names expand from:
58
+ * `<srcBase>/desktop.webm`, `/desktop-safe.mp4`, `/desktop.mp4` and the
59
+ * matching `mobile.*`. Ignored when `sources` is given.
60
+ * @prop {CrossfadeVideoSources} [sources] - Explicit per-breakpoint sources,
61
+ * for folders that don't follow the `desktop.*` / `mobile.*` naming.
62
+ * @prop {boolean} [active=true] - Whether this video is the one on screen.
63
+ * Drives managed playback.
64
+ * @prop {boolean} [autoplay=false] - Hand playback to the browser (`autoplay`
65
+ * on both elements) instead of managing it.
66
+ * @prop {boolean} [loop=false] - Loop both elements.
67
+ * @prop {string} [poster] - Poster frame for both elements.
68
+ * @prop {string} [class] - Classes shared by both `<video>` elements.
69
+ * @prop {string} [desktopClass='hidden lg:block'] - Extra classes on the
70
+ * desktop element, carrying its breakpoint.
71
+ * @prop {string} [mobileClass='block lg:hidden'] - Extra classes on the
72
+ * mobile element, carrying its breakpoint.
73
+ * @prop {boolean} [replayable=true] - Whether reaching the end offers a replay.
74
+ * @prop {string} [replayClass] - Extra classes on the replay button, which is
75
+ * already `absolute inset-0` over the caller's positioned wrapper.
76
+ * @prop {string} [replayLabel='Replay video'] - Replay button aria-label.
77
+ * @prop {Snippet} [overlay] - Replay button content. Defaults to the shared
78
+ * reload badge.
79
+ * @prop {() => void} [onended] - Called once when the video reaches its end.
80
+ */
81
+ declare const CrossfadeVideo: import("svelte").Component<$$ComponentProps, {
82
+ replay: () => void;
83
+ }, "">;
84
+ type CrossfadeVideo = ReturnType<typeof CrossfadeVideo>;
85
+ export default CrossfadeVideo;
@@ -0,0 +1,39 @@
1
+ <!--
2
+ @component GlassCard
3
+
4
+ A frosted panel: padding plus a backdrop blur, and nothing else. It exists
5
+ because the same two declarations were being written at ten call sites.
6
+
7
+ | property | default |
8
+ |----------------------|----------|
9
+ | `--glass-padding`| `2.25rem`|
10
+ | `--glass-blur` | `4px` |
11
+
12
+ @prop {string} [class] - Extra classes appended to the panel
13
+ @prop {Snippet} children - The panel's contents
14
+ -->
15
+ <script lang="ts">
16
+ import type { Snippet } from 'svelte';
17
+
18
+ let {
19
+ class: className = '',
20
+ children,
21
+ ...rest
22
+ }: {
23
+ class?: string;
24
+ children: Snippet;
25
+ [key: string]: unknown;
26
+ } = $props();
27
+ </script>
28
+
29
+ <div class="vit-glass {className}" {...rest}>
30
+ {@render children()}
31
+ </div>
32
+
33
+ <style>
34
+ .vit-glass {
35
+ padding: var(--glass-padding, 2.25rem);
36
+ backdrop-filter: blur(var(--glass-blur, 4px));
37
+ -webkit-backdrop-filter: blur(var(--glass-blur, 4px));
38
+ }
39
+ </style>
@@ -0,0 +1,23 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ class?: string;
4
+ children: Snippet;
5
+ [key: string]: unknown;
6
+ };
7
+ /**
8
+ * GlassCard
9
+ *
10
+ * A frosted panel: padding plus a backdrop blur, and nothing else. It exists
11
+ * because the same two declarations were being written at ten call sites.
12
+ *
13
+ * | property | default |
14
+ * |----------------------|----------|
15
+ * | `--glass-padding`| `2.25rem`|
16
+ * | `--glass-blur` | `4px` |
17
+ *
18
+ * @prop {string} [class] - Extra classes appended to the panel
19
+ * @prop {Snippet} children - The panel's contents
20
+ */
21
+ declare const GlassCard: import("svelte").Component<$$ComponentProps, {}, "">;
22
+ type GlassCard = ReturnType<typeof GlassCard>;
23
+ export default GlassCard;
@@ -0,0 +1,93 @@
1
+ <!--
2
+ @component ScrollyStepIndicator
3
+
4
+ The row (or column) of dots that shows which step of a scrolly a reader is on,
5
+ optionally clickable to jump.
6
+
7
+ Colours arrive as CSS custom properties rather than class-name props. The
8
+ version this was extracted from took `activeColor="bg-[#3E2A12] ring-1 …"` —
9
+ Tailwind class strings with a brand hex baked into the default — which meant a
10
+ consumer had to have Tailwind *and* had to restate the whole class list to
11
+ change one colour.
12
+
13
+ | property | default |
14
+ |------------------------|-----------|
15
+ | `--dot-size` | `0.375rem`|
16
+ | `--dot-gap` | `0.5rem` |
17
+ | `--dot-color` | `currentColor` |
18
+ | `--dot-active` | `var(--dot-color, currentColor)` |
19
+
20
+ @prop {number} total - How many steps
21
+ @prop {number} current - Index of the active step
22
+ @prop {(index: number) => void} [onSelect] - Makes the dots buttons that jump
23
+ @prop {'vertical' | 'horizontal'} [orientation] - Layout direction
24
+ @prop {string} [class] - Extra classes appended to the container
25
+ -->
26
+ <script lang="ts">
27
+ let {
28
+ total,
29
+ current,
30
+ onSelect,
31
+ orientation = 'vertical',
32
+ class: className = ''
33
+ }: {
34
+ total: number;
35
+ current: number;
36
+ onSelect?: (index: number) => void;
37
+ orientation?: 'vertical' | 'horizontal';
38
+ class?: string;
39
+ } = $props();
40
+
41
+ const isHorizontal = $derived(orientation === 'horizontal');
42
+ </script>
43
+
44
+ <div class="vit-dots {className}" class:vit-dots--horizontal={isHorizontal}>
45
+ {#each Array.from({ length: total }, (_, k) => k) as i (i)}
46
+ {#if onSelect}
47
+ <button
48
+ type="button"
49
+ aria-label="Go to slide {i + 1}"
50
+ aria-current={i === current ? 'true' : undefined}
51
+ onclick={() => onSelect(i)}
52
+ class="vit-dots__dot vit-dots__dot--button"
53
+ class:vit-dots__dot--active={i === current}
54
+ ></button>
55
+ {:else}
56
+ <div class="vit-dots__dot" class:vit-dots__dot--active={i === current}></div>
57
+ {/if}
58
+ {/each}
59
+ </div>
60
+
61
+ <style>
62
+ .vit-dots {
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: var(--dot-gap, 0.5rem);
66
+ }
67
+
68
+ .vit-dots--horizontal {
69
+ flex-direction: row;
70
+ }
71
+
72
+ .vit-dots__dot {
73
+ width: var(--dot-size, 0.375rem);
74
+ height: var(--dot-size, 0.375rem);
75
+ border-radius: 9999px;
76
+ background: transparent;
77
+ box-shadow: inset 0 0 0 1px var(--dot-color, currentColor);
78
+ transition:
79
+ background-color 300ms,
80
+ box-shadow 300ms;
81
+ }
82
+
83
+ .vit-dots__dot--active {
84
+ background: var(--dot-active, var(--dot-color, currentColor));
85
+ }
86
+
87
+ .vit-dots__dot--button {
88
+ padding: 0;
89
+ border: 0;
90
+ cursor: pointer;
91
+ appearance: none;
92
+ }
93
+ </style>
@@ -0,0 +1,35 @@
1
+ type $$ComponentProps = {
2
+ total: number;
3
+ current: number;
4
+ onSelect?: (index: number) => void;
5
+ orientation?: 'vertical' | 'horizontal';
6
+ class?: string;
7
+ };
8
+ /**
9
+ * ScrollyStepIndicator
10
+ *
11
+ * The row (or column) of dots that shows which step of a scrolly a reader is on,
12
+ * optionally clickable to jump.
13
+ *
14
+ * Colours arrive as CSS custom properties rather than class-name props. The
15
+ * version this was extracted from took `activeColor="bg-[#3E2A12] ring-1 …"` —
16
+ * Tailwind class strings with a brand hex baked into the default — which meant a
17
+ * consumer had to have Tailwind *and* had to restate the whole class list to
18
+ * change one colour.
19
+ *
20
+ * | property | default |
21
+ * |------------------------|-----------|
22
+ * | `--dot-size` | `0.375rem`|
23
+ * | `--dot-gap` | `0.5rem` |
24
+ * | `--dot-color` | `currentColor` |
25
+ * | `--dot-active` | `var(--dot-color, currentColor)` |
26
+ *
27
+ * @prop {number} total - How many steps
28
+ * @prop {number} current - Index of the active step
29
+ * @prop {(index: number) => void} [onSelect] - Makes the dots buttons that jump
30
+ * @prop {'vertical' | 'horizontal'} [orientation] - Layout direction
31
+ * @prop {string} [class] - Extra classes appended to the container
32
+ */
33
+ declare const ScrollyStepIndicator: import("svelte").Component<$$ComponentProps, {}, "">;
34
+ type ScrollyStepIndicator = ReturnType<typeof ScrollyStepIndicator>;
35
+ export default ScrollyStepIndicator;
@@ -0,0 +1,112 @@
1
+ <!--
2
+ @component ScrollySteps
3
+
4
+ The one scrolly mechanism: a sticky background with a column of scrolling
5
+ step sections over it.
6
+
7
+ Wraps `@sveltejs/svelte-scroller` (a Svelte 4 component, driven through its
8
+ legacy `background`/`foreground` slots) and owns everything every scrolly
9
+ section on the site used to re-implement by hand: the `index`/`offset`
10
+ state, the `<Scroller>` element, the `scrolly-bg` sticky wrapper, and the
11
+ per-step opacity/translateY ramp (see `./scrollySteps.ts`).
12
+
13
+ Content stays in the feature component: `background` paints whatever sits
14
+ behind, `step` paints one section's card. One `<section>` is rendered per
15
+ entry of `steps` — including spacer/buffer entries, since the scroller
16
+ counts `<section>` elements to decide `index`. Sections that are not steps
17
+ at all (a trailing "hold" section, say) go in `before`/`after`.
18
+
19
+ @prop {T[]} steps - One entry per `<section>`, in scroll order. Spacer
20
+ sections are entries too, so `index` lines up with the array.
21
+ @prop {number} [top=0] - Scroller `top`, as a fraction of the viewport.
22
+ @prop {number} [bottom=1] - Scroller `bottom`, as a fraction of the viewport.
23
+ @prop {number} [threshold=0.5] - Viewport fraction at which a section
24
+ becomes the current step.
25
+ @prop {number} [index=0] - Bindable. Step index the scroller reports.
26
+ @prop {number} [offset=0] - Bindable. Scroll progress within that step, 0 → 1.
27
+ @prop {Snippet} background - Sticky background, rendered inside the
28
+ `scrolly-bg` wrapper. Receives `{ index, offset, progress }`.
29
+ @prop {Snippet} step - One step's content, rendered inside its `<section>`.
30
+ Receives `{ item, i, active, opacity, translateY }`.
31
+ @prop {Snippet} [before] - Raw sections rendered before the step sections.
32
+ @prop {Snippet} [after] - Raw sections rendered after the step sections
33
+ (trailing buffer / hold sections).
34
+ @prop {string} [class='w-full'] - Classes on the foreground column.
35
+ @prop {string} [backgroundClass] - Extra classes next to `scrolly-bg`.
36
+ @prop {string | ((item: T, i: number) => string)} [sectionClass] - Classes
37
+ on each step `<section>`; a function when they differ per step.
38
+ -->
39
+ <script lang="ts" generics="T">
40
+ import type { Snippet } from 'svelte';
41
+ import Scroller from '@sveltejs/svelte-scroller';
42
+ import { stepStyle } from './stepStyle.js';
43
+
44
+ let {
45
+ steps,
46
+ top = 0,
47
+ bottom = 1,
48
+ threshold = 0.5,
49
+ index = $bindable(0),
50
+ offset = $bindable(0),
51
+ background,
52
+ step,
53
+ before,
54
+ after,
55
+ class: className = 'w-full',
56
+ backgroundClass = '',
57
+ sectionClass = ''
58
+ }: {
59
+ steps: T[];
60
+ top?: number;
61
+ bottom?: number;
62
+ threshold?: number;
63
+ index?: number;
64
+ offset?: number;
65
+ background: Snippet<[{ index: number; offset: number; progress: number }]>;
66
+ step: Snippet<[{ item: T; i: number; active: boolean; opacity: number; translateY: number }]>;
67
+ before?: Snippet;
68
+ after?: Snippet;
69
+ class?: string;
70
+ backgroundClass?: string;
71
+ sectionClass?: string | ((item: T, i: number) => string);
72
+ } = $props();
73
+
74
+ /** Scroller progress across the whole sequence, 0 → 1. Handed to `background`. */
75
+ let progress = $state(0);
76
+
77
+ /** Resolves the per-section class list, which may depend on the step. */
78
+ function classFor(item: T, i: number): string {
79
+ return typeof sectionClass === 'function' ? sectionClass(item, i) : sectionClass;
80
+ }
81
+ </script>
82
+
83
+ <Scroller {top} {bottom} {threshold} bind:index bind:offset bind:progress>
84
+ <!-- BACKGROUND: sticky, full viewport height -->
85
+ <div slot="background" class="vit-scrolly__bg {backgroundClass}">
86
+ {@render background({ index, offset, progress })}
87
+ </div>
88
+
89
+ <!-- FOREGROUND: the step sections, scrolling over the background -->
90
+ <div slot="foreground" class={className}>
91
+ {@render before?.()}
92
+ {#each steps as item, i (i)}
93
+ <section class={classFor(item, i)}>
94
+ {@render step({ item, i, ...stepStyle(i, index, offset) })}
95
+ </section>
96
+ {/each}
97
+ {@render after?.()}
98
+ </div>
99
+ </Scroller>
100
+
101
+ <style>
102
+ /*
103
+ * The sticky background fills the viewport. `--device-h` lets a host pin a
104
+ * measured height instead of `100vh`, which is what mobile browsers need
105
+ * when the URL bar collapses and `vh` jumps.
106
+ */
107
+ .vit-scrolly__bg {
108
+ position: relative;
109
+ width: 100%;
110
+ height: var(--device-h, 100vh);
111
+ }
112
+ </style>
@@ -0,0 +1,87 @@
1
+ import type { Snippet } from 'svelte';
2
+ declare function $$render<T>(): {
3
+ props: {
4
+ steps: T[];
5
+ top?: number;
6
+ bottom?: number;
7
+ threshold?: number;
8
+ index?: number;
9
+ offset?: number;
10
+ background: Snippet<[{
11
+ index: number;
12
+ offset: number;
13
+ progress: number;
14
+ }]>;
15
+ step: Snippet<[{
16
+ item: T;
17
+ i: number;
18
+ active: boolean;
19
+ opacity: number;
20
+ translateY: number;
21
+ }]>;
22
+ before?: Snippet;
23
+ after?: Snippet;
24
+ class?: string;
25
+ backgroundClass?: string;
26
+ sectionClass?: string | ((item: T, i: number) => string);
27
+ };
28
+ exports: {};
29
+ bindings: "index" | "offset";
30
+ slots: {};
31
+ events: {};
32
+ };
33
+ declare class __sveltets_Render<T> {
34
+ props(): ReturnType<typeof $$render<T>>['props'];
35
+ events(): ReturnType<typeof $$render<T>>['events'];
36
+ slots(): ReturnType<typeof $$render<T>>['slots'];
37
+ bindings(): "index" | "offset";
38
+ exports(): {};
39
+ }
40
+ interface $$IsomorphicComponent {
41
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
42
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
43
+ } & ReturnType<__sveltets_Render<T>['exports']>;
44
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
45
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
46
+ }
47
+ /**
48
+ * ScrollySteps
49
+ *
50
+ * The one scrolly mechanism: a sticky background with a column of scrolling
51
+ * step sections over it.
52
+ *
53
+ * Wraps `@sveltejs/svelte-scroller` (a Svelte 4 component, driven through its
54
+ * legacy `background`/`foreground` slots) and owns everything every scrolly
55
+ * section on the site used to re-implement by hand: the `index`/`offset`
56
+ * state, the `<Scroller>` element, the `scrolly-bg` sticky wrapper, and the
57
+ * per-step opacity/translateY ramp (see `./scrollySteps.ts`).
58
+ *
59
+ * Content stays in the feature component: `background` paints whatever sits
60
+ * behind, `step` paints one section's card. One `<section>` is rendered per
61
+ * entry of `steps` — including spacer/buffer entries, since the scroller
62
+ * counts `<section>` elements to decide `index`. Sections that are not steps
63
+ * at all (a trailing "hold" section, say) go in `before`/`after`.
64
+ *
65
+ * @prop {T[]} steps - One entry per `<section>`, in scroll order. Spacer
66
+ * sections are entries too, so `index` lines up with the array.
67
+ * @prop {number} [top=0] - Scroller `top`, as a fraction of the viewport.
68
+ * @prop {number} [bottom=1] - Scroller `bottom`, as a fraction of the viewport.
69
+ * @prop {number} [threshold=0.5] - Viewport fraction at which a section
70
+ * becomes the current step.
71
+ * @prop {number} [index=0] - Bindable. Step index the scroller reports.
72
+ * @prop {number} [offset=0] - Bindable. Scroll progress within that step, 0 → 1.
73
+ * @prop {Snippet} background - Sticky background, rendered inside the
74
+ * `scrolly-bg` wrapper. Receives `{ index, offset, progress }`.
75
+ * @prop {Snippet} step - One step's content, rendered inside its `<section>`.
76
+ * Receives `{ item, i, active, opacity, translateY }`.
77
+ * @prop {Snippet} [before] - Raw sections rendered before the step sections.
78
+ * @prop {Snippet} [after] - Raw sections rendered after the step sections
79
+ * (trailing buffer / hold sections).
80
+ * @prop {string} [class='w-full'] - Classes on the foreground column.
81
+ * @prop {string} [backgroundClass] - Extra classes next to `scrolly-bg`.
82
+ * @prop {string | ((item: T, i: number) => string)} [sectionClass] - Classes
83
+ * on each step `<section>`; a function when they differ per step.
84
+ */
85
+ declare const ScrollySteps: $$IsomorphicComponent;
86
+ type ScrollySteps<T> = InstanceType<typeof ScrollySteps<T>>;
87
+ export default ScrollySteps;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @module scrolly/stepStyle
3
+ *
4
+ * The per-step style a scrolly card is drawn with, derived from the scroller's
5
+ * `index` / `offset` pair. No DOM, no Svelte — the ramp is assertable as
6
+ * numbers, which is the whole reason it is not inline in the component.
7
+ *
8
+ * In the app this came from, the same arithmetic lived in two modules:
9
+ * `scrollyUtils.ts` held `calcOpacity` / `calcTranslateY`, and a six-line
10
+ * `scrollySteps.ts` re-exported them zipped into one record. Two modules for one
11
+ * three-field value is a pass-through, not a seam, so they are one module here.
12
+ * Both halves stay exported: a caller that wants only the opacity ramp should
13
+ * not have to take the record.
14
+ */
15
+ /** Everything one step needs to draw itself for the current scroll position. */
16
+ export type StepStyle = {
17
+ /** True when this step is the one the scroller currently reports. */
18
+ active: boolean;
19
+ /** Card opacity: 0 → 1 → 1 → 0 across the step's own scroll progress. */
20
+ opacity: number;
21
+ /** Card translateY in px: 30 → 0 → 0 → -30 across that same progress. */
22
+ translateY: number;
23
+ };
24
+ /**
25
+ * Card opacity across a step's scroll progress: fade in over the first quarter,
26
+ * hold through the middle half, fade out over the last quarter.
27
+ *
28
+ * @param stepIndex - Index of the step being drawn.
29
+ * @param currentIndex - Step index the scroller currently reports.
30
+ * @param currentOffset - Scroll progress within the current step, 0 → 1.
31
+ * @returns Opacity in `[0, 1]`; `0` for any step that is not current.
32
+ */
33
+ export declare function calcOpacity(stepIndex: number, currentIndex: number, currentOffset: number): number;
34
+ /**
35
+ * Card translateY in px across the same progress: slides up on entry, holds
36
+ * through the plateau, slides further up on exit.
37
+ *
38
+ * @param stepIndex - Index of the step being drawn.
39
+ * @param currentIndex - Step index the scroller currently reports.
40
+ * @param currentOffset - Scroll progress within the current step, 0 → 1.
41
+ * @returns Offset in px; the full travel for any step that is not current, so
42
+ * an inactive card is parked below rather than mid-animation.
43
+ */
44
+ export declare function calcTranslateY(stepIndex: number, currentIndex: number, currentOffset: number): number;
45
+ /**
46
+ * Projects the scroller's position onto one step.
47
+ *
48
+ * @param i - Index of the step being drawn.
49
+ * @param index - Step index the scroller currently reports.
50
+ * @param offset - Scroll progress within the current step, 0 → 1.
51
+ * @returns The step's `active` flag plus its card opacity and translateY.
52
+ */
53
+ export declare function stepStyle(i: number, index: number, offset: number): StepStyle;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @module scrolly/stepStyle
3
+ *
4
+ * The per-step style a scrolly card is drawn with, derived from the scroller's
5
+ * `index` / `offset` pair. No DOM, no Svelte — the ramp is assertable as
6
+ * numbers, which is the whole reason it is not inline in the component.
7
+ *
8
+ * In the app this came from, the same arithmetic lived in two modules:
9
+ * `scrollyUtils.ts` held `calcOpacity` / `calcTranslateY`, and a six-line
10
+ * `scrollySteps.ts` re-exported them zipped into one record. Two modules for one
11
+ * three-field value is a pass-through, not a seam, so they are one module here.
12
+ * Both halves stay exported: a caller that wants only the opacity ramp should
13
+ * not have to take the record.
14
+ */
15
+ /** How far the card slides, in px, at the extremes of a step. */
16
+ const TRAVEL_PX = 30;
17
+ /**
18
+ * Card opacity across a step's scroll progress: fade in over the first quarter,
19
+ * hold through the middle half, fade out over the last quarter.
20
+ *
21
+ * @param stepIndex - Index of the step being drawn.
22
+ * @param currentIndex - Step index the scroller currently reports.
23
+ * @param currentOffset - Scroll progress within the current step, 0 → 1.
24
+ * @returns Opacity in `[0, 1]`; `0` for any step that is not current.
25
+ */
26
+ export function calcOpacity(stepIndex, currentIndex, currentOffset) {
27
+ if (stepIndex !== currentIndex)
28
+ return 0;
29
+ return Math.max(0, Math.min(currentOffset * 4, 1, (1 - currentOffset) * 4));
30
+ }
31
+ /**
32
+ * Card translateY in px across the same progress: slides up on entry, holds
33
+ * through the plateau, slides further up on exit.
34
+ *
35
+ * @param stepIndex - Index of the step being drawn.
36
+ * @param currentIndex - Step index the scroller currently reports.
37
+ * @param currentOffset - Scroll progress within the current step, 0 → 1.
38
+ * @returns Offset in px; the full travel for any step that is not current, so
39
+ * an inactive card is parked below rather than mid-animation.
40
+ */
41
+ export function calcTranslateY(stepIndex, currentIndex, currentOffset) {
42
+ if (stepIndex !== currentIndex)
43
+ return TRAVEL_PX;
44
+ if (currentOffset < 0.25)
45
+ return (1 - currentOffset * 4) * TRAVEL_PX;
46
+ if (currentOffset < 0.75)
47
+ return 0;
48
+ return -(currentOffset - 0.75) * 4 * TRAVEL_PX;
49
+ }
50
+ /**
51
+ * Projects the scroller's position onto one step.
52
+ *
53
+ * @param i - Index of the step being drawn.
54
+ * @param index - Step index the scroller currently reports.
55
+ * @param offset - Scroll progress within the current step, 0 → 1.
56
+ * @returns The step's `active` flag plus its card opacity and translateY.
57
+ */
58
+ export function stepStyle(i, index, offset) {
59
+ return {
60
+ active: i === index,
61
+ opacity: calcOpacity(i, index, offset),
62
+ translateY: calcTranslateY(i, index, offset)
63
+ };
64
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `@sveltejs/svelte-scroller` ships no type declarations, so the peer it is
3
+ * declared as is untyped without this. It lives under `src/lib` deliberately:
4
+ * `svelte-package` emits it into `dist`, so a consumer of this package inherits
5
+ * the declaration instead of having to write their own — which is what the app
6
+ * this was extracted from had to do.
7
+ */
8
+ declare module '@sveltejs/svelte-scroller' {
9
+ import type { SvelteComponent } from 'svelte';
10
+
11
+ export default class Scroller extends SvelteComponent<{
12
+ top?: number;
13
+ bottom?: number;
14
+ threshold?: number;
15
+ query?: string;
16
+ parallax?: boolean;
17
+ index?: number;
18
+ count?: number;
19
+ offset?: number;
20
+ progress?: number;
21
+ visible?: boolean;
22
+ }> {}
23
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Scrollytelling primitives: the scroller and its step ramp, a responsive
3
+ * crossfading video, step dots, and a frosted panel. Domain-free, and themed
4
+ * with CSS custom properties rather than utility classes.
5
+ *
6
+ * `<ScrollySteps>` needs the optional peer `@sveltejs/svelte-scroller`; nothing
7
+ * else here does, so a consumer that only wants `CrossfadeVideo` or the
8
+ * `stepStyle` ramp can skip it.
9
+ */
10
+ export { default as ScrollySteps } from './components/scrolly/ScrollySteps.svelte';
11
+ export { default as ScrollyStepIndicator } from './components/scrolly/ScrollyStepIndicator.svelte';
12
+ export { default as CrossfadeVideo } from './components/scrolly/CrossfadeVideo.svelte';
13
+ export { default as GlassCard } from './components/scrolly/GlassCard.svelte';
14
+ export { calcOpacity, calcTranslateY, stepStyle, type StepStyle } from './components/scrolly/stepStyle.js';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Scrollytelling primitives: the scroller and its step ramp, a responsive
3
+ * crossfading video, step dots, and a frosted panel. Domain-free, and themed
4
+ * with CSS custom properties rather than utility classes.
5
+ *
6
+ * `<ScrollySteps>` needs the optional peer `@sveltejs/svelte-scroller`; nothing
7
+ * else here does, so a consumer that only wants `CrossfadeVideo` or the
8
+ * `stepStyle` ramp can skip it.
9
+ */
10
+ export { default as ScrollySteps } from './components/scrolly/ScrollySteps.svelte';
11
+ export { default as ScrollyStepIndicator } from './components/scrolly/ScrollyStepIndicator.svelte';
12
+ export { default as CrossfadeVideo } from './components/scrolly/CrossfadeVideo.svelte';
13
+ export { default as GlassCard } from './components/scrolly/GlassCard.svelte';
14
+ export { calcOpacity, calcTranslateY, stepStyle } from './components/scrolly/stepStyle.js';
@@ -65,4 +65,18 @@
65
65
 
66
66
  /* Motion */
67
67
  --transition-fast: 150ms ease;
68
+
69
+ /* Scrollytelling (the `./scrolly` subpath) */
70
+ /* The sticky background's height. Mobile browsers change `100vh` when the
71
+ URL bar collapses, so a host that measures the real viewport sets this. */
72
+ --device-h: 100vh;
73
+ --dot-size: 0.375rem;
74
+ --dot-gap: var(--space-2);
75
+ --dot-color: currentColor;
76
+ --dot-active: var(--dot-color);
77
+ --glass-padding: var(--space-5);
78
+ --glass-blur: 4px;
79
+ --video-badge-padding: var(--space-3);
80
+ --video-badge-color: var(--color-ink);
81
+ --video-badge-bg: rgb(255 255 255 / 50%);
68
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vit-foundation/ui",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",
@@ -91,10 +91,16 @@
91
91
  "default": "./dist/testing/remote-form.js"
92
92
  },
93
93
  "./tokens.css": "./dist/styles/tokens.css",
94
- "./base.css": "./dist/styles/base.css"
94
+ "./base.css": "./dist/styles/base.css",
95
+ "./scrolly": {
96
+ "types": "./dist/scrolly.d.ts",
97
+ "svelte": "./dist/scrolly.js",
98
+ "default": "./dist/scrolly.js"
99
+ }
95
100
  },
96
101
  "peerDependencies": {
97
- "svelte": "^5.0.0"
102
+ "svelte": "^5.0.0",
103
+ "@sveltejs/svelte-scroller": "^2.0.0"
98
104
  },
99
105
  "devDependencies": {
100
106
  "@chromatic-com/storybook": "^4.1.2",
@@ -109,6 +115,7 @@
109
115
  "@sveltejs/adapter-auto": "^7.0.0",
110
116
  "@sveltejs/kit": "^2.47.1",
111
117
  "@sveltejs/package": "^2.5.4",
118
+ "@sveltejs/svelte-scroller": "^2.0.7",
112
119
  "@sveltejs/vite-plugin-svelte": "^6.2.1",
113
120
  "@types/node": "^22",
114
121
  "@vitest/browser-playwright": "^4.0.5",
@@ -142,5 +149,10 @@
142
149
  },
143
150
  "publishConfig": {
144
151
  "access": "public"
152
+ },
153
+ "peerDependenciesMeta": {
154
+ "@sveltejs/svelte-scroller": {
155
+ "optional": true
156
+ }
145
157
  }
146
158
  }