Merge pull request #386 from reuters-graphics/sudev-scroller-lottie

Adds Lottie component
This commit is contained in:
Sudev Kiyada 2026-01-13 21:58:39 +05:30 committed by GitHub
commit 32c13fe627
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1749 additions and 9 deletions

View file

@ -0,0 +1,5 @@
---
'@reuters-graphics/graphics-components': patch
---
Adds Lottie component

View file

@ -98,6 +98,7 @@
"dependencies": {
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@lottiefiles/dotlottie-web": "^0.52.2",
"@reuters-graphics/svelte-markdown": "^0.0.3",
"@sveltejs/kit": "^2.0.0",
"dayjs": "^1.11.13",

View file

@ -14,6 +14,9 @@ importers:
'@fortawesome/free-solid-svg-icons':
specifier: ^6.7.2
version: 6.7.2
'@lottiefiles/dotlottie-web':
specifier: ^0.52.2
version: 0.52.2
'@reuters-graphics/svelte-markdown':
specifier: ^0.0.3
version: 0.0.3(svelte@5.28.1)
@ -569,6 +572,9 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@lottiefiles/dotlottie-web@0.52.2':
resolution: {integrity: sha512-aeXCMUB5RdusHrvZ3Py2KaMgQ0w7SdA8NFbPK+SpwqC1UW1CDFZl5vPLueZHju7vLhB1rpPvpQ5fQ0L/KEZt7w==}
'@manypkg/find-root@1.1.0':
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
@ -4641,6 +4647,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@lottiefiles/dotlottie-web@0.52.2': {}
'@manypkg/find-root@1.1.0':
dependencies:
'@babel/runtime': 7.27.0

View file

@ -69,6 +69,17 @@ export type ScrollerVideoForegroundPosition =
| 'center left'
| 'center right';
export type LottieForegroundPosition =
| 'top center'
| 'top left'
| 'top right'
| 'bottom center'
| 'bottom left'
| 'bottom right'
| 'center center'
| 'center left'
| 'center right';
// Complete ScrollerVideo instance interface
export interface ScrollerVideoInstance {
// Properties

View file

@ -0,0 +1,269 @@
<script lang="ts">
const { componentState } = $props();
let isMoving = $state(false);
let preventDetails = $state(false);
let position = $state({ x: 8, y: 8 });
function onMouseDown(e: MouseEvent) {
isMoving = true;
e.preventDefault();
}
function onMouseMove(e: MouseEvent) {
if (isMoving) {
position = {
x: position.x + e.movementX,
y: position.y + e.movementY,
};
preventDetails = true;
}
e.preventDefault();
}
function onMouseUp(e: MouseEvent) {
if (isMoving) {
isMoving = false;
setTimeout(() => {
preventDetails = false;
}, 5);
e.stopImmediatePropagation();
}
e.preventDefault();
}
function onClick(e: MouseEvent) {
if (preventDetails) {
e.preventDefault();
}
isMoving = false;
}
</script>
<svelte:window onmousemove={onMouseMove} />
<div
style="position: absolute; top: {position.y}px; left: {position.x}px; z-index: 5; user-select: none;"
role="region"
>
<details class="debug-info" open>
<summary
class="text-xxs font-sans font-bold title"
style="grid-column: span 2;"
onmousedown={onMouseDown}
onmouseup={onMouseUp}
onclick={onClick}
>
CONSOLE
</summary>
<div class="state-debug">
<!-- -->
<p>Progress:</p>
<div style="display: flex; flex-direction: column; gap: 4px;">
<p class="state-value">{componentState.progress}</p>
<div id="video-progress-bar">
<div
style="width: {componentState.progress * 100}%; height: 100%;"
></div>
</div>
</div>
<!-- -->
<p>Duration:</p>
<p class="state-value">
{componentState.duration}s
</p>
<!-- -->
{#if componentState.segment}
<p>Segment:</p>
<p class="state-value">
{componentState.segment[0]} -- {componentState.segment[1]}
</p>
{/if}
<!-- -->
<p>Current frame:</p>
<p class="state-value">
{componentState.currentFrame}/{componentState.totalFrames}
</p>
<!-- -->
<p>Speed:</p>
<p class="state-value">
{componentState.speed}
</p>
<!-- -->
<p>Autoplay:</p>
<p class="state-value">
<span class="tag">{componentState.autoplay}</span>
</p>
<!-- -->
<p>Loop:</p>
<p class="state-value">
<span class="tag">{componentState.loop}</span>
{componentState.loop ? `(Loop count: ${componentState.loopCount})` : ''}
</p>
<!-- -->
<p>Mode:</p>
<p class="state-value">
<span class="tag">{componentState.mode}</span>
</p>
<!-- -->
<p>Layout:</p>
<p class="state-value">
{JSON.stringify(componentState.layout)}
</p>
<!-- -->
{#if Object.keys(componentState.allMarkers).length}
<p>All markers:</p>
<p class="state-value">
{componentState.allMarkers}
</p>
{/if}
<!-- -->
{#if componentState.marker}
<p>Active marker:</p>
<p class="state-value">
{componentState.marker}
</p>
{/if}
<!-- -->
{#if componentState.allThemes.length}
<p>All themes:</p>
<p class="state-value">
{componentState.allThemes.join(', ')}
</p>
{/if}
{#if componentState.activeThemeId}
<p>Active theme ID:</p>
<p class="state-value">
{componentState.activeThemeId}
</p>
{/if}
<!-- -->
<p>isPaused:</p>
<p class="state-value">
<span class="tag">{componentState.isPaused}</span>
</p>
<!-- -->
<p>isPlaying:</p>
<p class="state-value">
<span class="tag">{componentState.isPlaying}</span>
</p>
<!-- -->
<p>isStopped:</p>
<p class="state-value">
<span class="tag">{componentState.isStopped}</span>
</p>
<!-- -->
<p>isLoaded:</p>
<p class="state-value">
<span class="tag">{componentState.isLoaded}</span>
</p>
<!-- -->
<p>isFrozen:</p>
<p class="state-value">
<span class="tag">{componentState.isFrozen}</span>
</p>
</div>
</details>
</div>
<style lang="scss">
.debug-info {
position: absolute;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 1);
z-index: 3;
margin: 0;
width: 50vmin;
min-width: 50vmin;
padding: 8px;
border-radius: 8px;
overflow: auto;
resize: horizontal;
opacity: 0.6;
transition: opacity 0.3s ease;
filter: drop-shadow(0 0 16px rgba(0, 0, 0, 0.5));
@media (prefers-reduced-motion: no-preference) {
interpolate-size: allow-keywords;
}
&::details-content {
opacity: 0;
block-size: 0;
overflow-y: clip;
transition:
content-visibility 0.4s allow-discrete,
opacity 0.4s,
block-size 0.4s cubic-bezier(0.87, 0, 0.13, 1);
}
&[open]::details-content {
opacity: 1;
block-size: auto;
}
.title {
width: 100%;
font-family: var(--theme-font-family-monospace);
color: white;
margin: 0;
}
* {
user-select: none;
}
}
.debug-info[open] {
opacity: 1;
}
div.state-debug {
display: grid;
width: 100%;
padding: 8px 8px 16px 8px;
grid-template-columns: 20vmin 1fr;
align-items: center;
gap: 0.75rem 0.25rem;
background-color: #1e1e1e;
border-radius: 4px;
margin-top: 8px;
}
p {
font-size: var(--theme-font-size-xxs);
font-family: var(--theme-font-family-monospace);
padding: 0;
margin: 0;
color: rgba(255, 255, 255, 0.7);
overflow-wrap: anywhere;
line-height: 100%;
font-variant: tabular-nums;
}
.state-value {
color: white;
}
#video-progress-bar {
width: 100%;
background-color: rgba(255, 255, 255, 0.2);
height: 2px;
border-radius: 50px;
// margin: auto;
div {
background-color: white;
border-radius: 50px;
}
}
.tag {
padding: 0.1rem 0.2rem;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.2);
text-transform: uppercase;
font-weight: 500;
}
</style>

View file

@ -0,0 +1,386 @@
import { Meta } from '@storybook/blocks';
import * as LottieStories from './Lottie.stories.svelte';
import CompositionMarkerImage from './assets/marker.jpg?url';
<Meta of={LottieStories} />
# Lottie
The `Lottie` component uses the [dotLottie-web](https://developers.lottiefiles.com/docs/dotlottie-player/dotlottie-web/) library to render Lottie animations.
## How to prepare Lottie files
[LottieFiles](https://lottiefiles.com/) is the official platform for creating and editing Lottie animations. The free version of LottieFiles has limited features, so [Bodymovin](https://exchange.adobe.com/apps/cc/12557/bodymovin) remains a popular, free way to export Lottie animations as JSON files.
[dotLottie](https://dotlottie.io/) is another common format for Lottie files. This format bundles the Lottie JSON file and any associated assets, such as images and fonts, into a single compressed file with the extension `.lottie`.
This `Lottie` component is flexible and supports both `dotLottie` and JSON Lottie files. For best performance it is recommended that you convert your Lottie JSON file into a `.zip` file by following these steps:
1. Export your Lottie animation as a JSON file using [Bodymovin](https://exchange.adobe.com/apps/cc/12557/bodymovin) or another Lottie exporter.
2. Use the [LottieFiles converter](https://lottiefiles.com/tools/lottie-to-dotlottie) to convert the JSON file into a `.lottie` file.
3. Change the file extension to `.zip` from `.lottie`. This ensures full compatibility with the Reuters graphics publisher while maintaining the benefits of dotLottie format's compression and optimisation.
## When not to use Lottie
Lottie animations are great for lightweight, scalable animations. However, they may not be suitable for all use cases. Consider the following before using Lottie:
- **Huge raster images**: Lottie is best suited for simple to moderately complex animations. Animations with large raster images may not render well or could lead to performance issues. In such cases, consider using a [Video](?path=/docs/components-multimedia-video--docs) component or a [ScrollerVideo](?path=/docs/components-graphics-scrollervideo--docs) component instead.
- **Complex effects**: Some advanced effects and features available in After Effects may not be fully supported in Lottie, which could lead to discrepancies between the original design and the rendered animation. Check the [Lottie documentation](https://lottiefiles.com/supported-features) for a list of supported features.
- **Text rendering**: Lottie renders text as vector shapes. If you need DOM text for accessibility or CSS manipulation, consider using HTML/CSS animations instead.
- **SVG DOM manipulation**: Lottie renders animations on a canvas. If you need to manipulate individual elements of the animation using JavaScript or CSS, consider using SVG animations instead.
## Basic demo
To use the `Lottie` component, import it and provide the Lottie animation source. The height of the container defaults to `100lvh`, but you can adjust this to any valid CSS height value such as `1200px` or `200lvh` with the `height` prop.
**Use `lvh` or `svh` units instead of `vh`** as [these units](https://www.w3.org/TR/css-values-4/#large-viewport-size) are more reliable on mobile and other devices where elements such as the address bar appear and disappear and affect the height.
The component also provides a `width` prop to set the width of the Lottie container. While the `width` prop defaults to `fluid`, it allows any `ContainerWidth` value such as `narrower`, `narrow`, `normal`, `wide`, `wider`, `widest`, `fluid`, or a custom CSS width value like `600px` or `80vw`.
If importing the Lottie file directly into a Svelte component, make sure to append **?url** to the import statement (see example below). This ensures that the file is treated as a URL.
> 💡TIP: Set `showDebugInfo` prop to `true` to display information about the component state.
[Demo](?path=/story/components-graphics-scrollerlottie--demo)
```svelte
<script lang="ts">
import { Lottie } from '@reuters-graphics/graphics-components';
// Import Lottie file
import MyLottie from './lottie/my-lottie.zip?url'; // Append **?url** to the file path
</script>
<Lottie src={MyLottie} autoplay={true} showDebugInfo={true} />
```
## Using with ArchieML
If you are using `Lottie` with ArchieML, store your Lottie zip file in the `src/statics/lottie/` folder.
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: lottie
# Lottie file stored in `src/statics/lottie/` folder
src: lottie/LottieFile.zip
autoplay: true
loop: true
showDebugInfo: true
[]
```
... which you'll parse out of a ArchieML block object before passing to the `Lottie` component.
```svelte
<script lang="ts">
import { Lottie } from '@reuters-graphics/graphics-components';
// Graphics kit only
import { assets } from '$app/paths'; // 👈 If using in the graphics kit...
import { truthy } from '$utils/propValidators'; // 👈 If using in the graphics kit...
</script>
{#each content.blocks as block}
<!-- Inside the content.blocks for loop... -->
{#if block.type == 'lottie'}
<Lottie
src={`${assets}/${block.src}`}
autoplay={truthy(block.autoplay)}
loop={truthy(block.loop)}
showDebugInfo={truthy(block.showDebugInfo)}
/>
{/if}
{/each}
```
## Playing a segment
The `Lottie` component can play a specific segment of the Lottie animation using the `segment` prop. The `segment` prop expects an array of two numbers representing the start and end frames of the segment.
[Demo](?path=/story/components-graphics-scrollerlottie--segment)
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: lottie
showDebugInfo: true
loop: true
# Optionally, set playback speed
speed: 0.5
# Lottie file stored in `src/statics/lottie/` folder
src: lottie/LottieFile.zip
[.segment]
start: 0
end: 20
[]
[]
```
... which you'll parse out of a ArchieML block object before passing to the `Lottie` component.
```svelte
<script lang="ts">
import { Lottie } from '@reuters-graphics/graphics-components';
// Graphics kit only
import { assets } from '$app/paths'; // 👈 If using in the graphics kit...
import { truthy } from '$utils/propValidators'; // 👈 If using in the graphics kit...
</script>
{#each content.blocks as block}
<!-- Inside the content.blocks for loop... -->
{#if block.type == 'lottie'}
<Lottie
src={`${assets}/${block.src}`}
segment={[parseInt(block.segment.start), parseInt(block.segment.end)]}
showDebugInfo={truthy(block.showDebugInfo)}
loop={truthy(block.loop)}
speed={parseInt(block.speed)}
/>
{/if}
{/each}
```
## Markers
The `Lottie` component can also play a specific portion of the Lottie animation using markers set in [AfterEffects](https://helpx.adobe.com/in/after-effects/using/layer-markers-composition-markers.html).
The list of available markers, which can be passed into the `marker` prop, can be found in the debug info box that appears when `showDebugInfo` is set to `true`.
When setting markers in AfterEffects, ensure that the **Comment** section of the Composition Marker contains only the name of your marker:
<img src={CompositionMarkerImage} alt="Composition Marker Dialog" />
[Demo](?path=/story/components-graphics-scrollerlottie--marker)
```svelte
<script lang="ts">
import { Lottie } from '@reuters-graphics/graphics-components';
// Import Lottie file
import MyLottie from './lottie/my-lottie.zip?url'; // Append **?url** to the file path
</script>
<Lottie src={MyLottie} marker="myMarker" />
```
## Switching themes
[Lottie Creator](https://lottiefiles.com/theming) allows you to define multiple colour themes for your animation. You can switch between these themes using the `theme` prop.
Available themes can be found in the debug info when the `showDebugInfo` prop is set to `true`.
You can set multiple themes and switch between them dynamically -- for example, based on the `progress` of the animation.
[Demo](?path=/story/components-graphics-scrollerlottie--themes)
```svelte
<script lang="ts">
import { Lottie } from '@reuters-graphics/graphics-components';
import MyLottie from './lottie/my-lottie.zip?url';
// Set a bindable `progress` variable to pass into `<Lottie />`
let progress = $state(0);
</script>
<Lottie
src={MyLottie}
bind:progress
themeId={progress < 0.33 ? 'Water'
: progress < 0.66 ? 'air'
: 'earth'}
autoplay
showDebugInfo
/>
```
## Using with `ScrollerBase`
The `Lottie` component can be used with the `ScrollerBase` component to create a more complex scrolling experience. `ScrollerBase` provides a scrollable container sets the `Lottie` component as a background.
```svelte
<script lang="ts">
import { Lottie, ScrollerBase } from '@reuters-graphics/graphics-components';
import MyLottie from './lottie/my-lottie.zip?url';
// Pass `progress` as `videoPercentage` to Lottie
let progress = $state(0);
</script>
<ScrollerBase bind:progress query="div.step-foreground-container">
{#snippet backgroundSnippet()}
<!-- Pass bindable prop `progress` as `progress` -->
<Lottie src={MyLottie} {progress} height="100lvh" showDebugInfo />
{/snippet}
{#snippet foregroundSnippet()}
<div class="step-foreground-container">
<h3 class="text-center">Step 1</h3>
</div>
<div class="step-foreground-container">
<h3 class="text-center">Step 2</h3>
</div>
<div class="step-foreground-container">
<h3 class="text-center">Step 3</h3>
</div>
{/snippet}
</ScrollerBase>
<style lang="scss">
.step-foreground-container {
height: 100lvh;
width: 50%;
margin: 0 auto;
h3 {
background-color: antiquewhite;
text-align: center;
}
}
</style>
```
## With foregrounds
The `Lottie` component can also be used with the `LottieForeground` component to display foreground elements at specific times in the animation.
[Demo](?path=/story/components-graphics-scrollerlottie--with-foregrounds).
```svelte
<script lang="ts">
import { Lottie, ScrollerBase } from '@reuters-graphics/graphics-components';
import MyLottie from './lottie/my-lottie.zip?url';
</script>
<Lottie src={MyLottie} autoplay>
<!-- Foreground 1: Headline component as foreground -->
<LottieForeground
startFrame={0}
endFrame={50}
position="center center"
backgroundColour="rgba(0, 0, 0)"
>
<div class="headline-container">
<Theme base="dark">
<Headline
hed="Headline"
dek="This is an example of using a Svelte component as the foreground."
authors={['Jane Doe', 'John Doe']}
/>
</Theme>
</div>
</LottieForeground>
<!-- Foreground 2: Text only -->
<LottieForeground
startFrame={50}
endFrame={100}
text="Foreground caption between frames 50 and 100."
position="bottom center"
backgroundColour="rgba(0, 0, 0)"
width="wide"
/>
</Lottie>
```
### Using with ArchieML
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: lottie
# Lottie file stored in `src/statics/lottie/` folder
src: lottie/LottieFile.zip
# Array of foregrounds
[.foregrounds]
# Foreground 1: Headline component
startFrame: 0 # When in the animation to start showing the foreground
endFrame: 50 # When to stop showing the foreground
# Set foreground type
type: component
# Set props to pass into `LottieForeground`
{.foregroundProps}
componentName: Headline
hed: Headline
dek: Some deck text
[.authors]
* Jane Doe
* John Smith
[]
{}
# Foreground 2: Text only
startFrame: 50
endFrame: 100
# Set foreground type
type: text
# If the foreground type is `text`, set text prop here
{.foregroundProps}
text: Some text for the foreground
{}
[]
```
... which you'll parse out of a ArchieML block object before passing to the `Lottie` component.
```svelte
<script lang="ts">
import {
Lottie,
LottieForeground,
} from '@reuters-graphics/graphics-components';
import { assets } from '$app/paths';
// Make an object of components to use as foregrounds
const Components = $state({
Headline,
});
</script>
{#each content.blocks as block}
<!-- Inside the content.blocks for loop... -->
{#if block.type == 'lottie'}
<Lottie src={`${assets}/${block.src}`}>
{#each block.foregrounds as foreground}
{#if foreground.type == 'text'}
<LottieForeground
startFrame={parseInt(foreground.startFrame)}
endFrame={parseInt(foreground.endFrame)}
text={foreground.foregroundProps.text}
/>
{:else if foreground.type == 'component'}
{@const Component =
Components[foreground.foregroundProps.componentName]}
<LottieForeground
startFrame={parseInt(foreground.startFrame)}
endFrame={parseInt(foreground.endFrame)}
>
<Component {...foreground.foregroundProps} />
</LottieForeground>
{/if}
{/each}
</Lottie>
{/if}
{/each}
```

View file

@ -0,0 +1,150 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
// Components
import Lottie from './Lottie.svelte';
import LottieForeground from './LottieForeground.svelte';
import Headline from '../Headline/Headline.svelte';
import Theme from '../Theme/Theme.svelte';
// Denmo Lottie file
import DemoLottie from './lottie/demo.zip?url';
import MarkerSample from './lottie/markerSample.zip?url';
import ForegroundSample from './lottie/foregroundSample.zip?url';
import ThemesSample from './lottie/themesLottie.zip?url';
import WithScrollerBase from './demo/withScrollerBase.svelte';
const { Story } = defineMeta({
title: 'Components/Multimedia/Lottie',
component: Lottie,
argTypes: {
data: {
table: {
disable: true, // Hides from Docs table
},
control: false, // Removes from Controls panel
},
lottiePlayer: {
table: {
category: 'Bindable states',
},
},
progress: {
table: {
category: 'Bindable states',
},
},
},
});
let progress = $state(0);
</script>
<Story name="Demo" args={{ autoplay: true, showDebugInfo: true }}>
{#snippet children(args)}
<Lottie src={DemoLottie} {...args} />
{/snippet}
</Story>
<Story
name="Segment"
args={{
autoplay: true,
loop: true,
showDebugInfo: true,
segment: [0, 20],
speed: 0.5,
}}
>
{#snippet children(args)}
<Lottie src={DemoLottie} {...args} />
{/snippet}
</Story>
<Story
name="Marker"
args={{
autoplay: true,
loop: true,
showDebugInfo: true,
marker: 'ballerina',
mode: 'bounce',
}}
>
{#snippet children(args)}
<Lottie src={MarkerSample} {...args} />
{/snippet}
</Story>
<Story name="Themes" args={{ autoplay: true, showDebugInfo: true }}>
{#snippet children(args)}
<Lottie
src={ThemesSample}
bind:progress
themeId={progress < 0.33 ? 'Water'
: progress < 0.66 ? 'air'
: 'earth'}
{...args}
/>
{/snippet}
</Story>
<Story name="Using with ScrollerBase" exportName="ScrollerBase">
<WithScrollerBase />
</Story>
<Story name="With foregrounds">
<Lottie src={ForegroundSample} autoplay showDebugInfo>
<LottieForeground
startFrame={0}
endFrame={50}
position="center center"
backgroundColour="rgba(0, 0, 0)"
>
<div class="headline-container">
<Theme base="dark">
<Headline
hed="Headline"
dek="This is an example of using a Svelte component as the foreground."
authors={['Jane Doe', 'John Doe']}
/>
</Theme>
</div>
</LottieForeground>
<LottieForeground
startFrame={50}
endFrame={100}
text="Foreground caption between frames 50ms and 100ms."
position="bottom center"
backgroundColour="rgba(0, 0, 0)"
width="wide"
/>
</Lottie>
</Story>
<style lang="scss">
:global {
.lottie-foreground-container {
header {
padding: 2rem;
background-color: black;
}
.foreground-text {
p {
color: white !important;
}
}
}
}
.headline-container {
width: 100%;
height: 100%;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View file

@ -0,0 +1,443 @@
<script lang="ts">
// Libraries & utils
import { onMount, setContext } from 'svelte';
// @ts-ignore library has no types
import { DotLottie } from '@lottiefiles/dotlottie-web';
import { createLottieState } from './ts/lottieState.svelte';
import { isEqual } from 'es-toolkit';
import {
syncLottieState,
getMarkerRange,
calculateTargetFrame,
isReverseMode,
createRenderConfig,
isNullish,
isContainerWidth,
} from './ts/utils';
import { Tween } from 'svelte/motion';
// Components
import Debug from './Debug.svelte';
// Types
import type { Props } from './ts/types';
let canvas: HTMLCanvasElement;
let canvasWidth: number = $state(1);
let canvasHeight: number = $state(1);
let prevSrc: undefined | string = void 0;
let prevData: undefined | unknown = void 0;
let progressTween = new Tween(0, { duration: 100 });
let start: number = $state(0);
let end: number = $state(0);
let {
autoplay = false,
loop = false,
mode = 'forward',
src,
speed = 1,
data = undefined,
backgroundColor = '#ffffff',
segment,
renderConfig,
dotLottieRefCallback = () => {},
useFrameInterpolation = true,
themeId = '',
themeData = '',
playOnHover = false,
marker,
layout = { fit: 'contain', align: [0.5, 0.5] },
animationId = '',
lottiePlayer = $bindable(undefined),
width = 'fluid',
height = 'auto',
showDebugInfo = false,
lottieState = createLottieState(),
progress = $bindable(0),
tweenDuration = 100,
easing = (t: number) => t,
onLoad = () => {},
onRender = () => {},
onComplete = () => {},
children,
}: Props = $props();
// pass on component state to child components
// this controls fade in and out of foregrounds
setContext('lottieState', lottieState);
function onLoadEvent() {
if (lottiePlayer) {
lottiePlayer.setLayout(layout);
lottieState.allMarkers = lottiePlayer.markers().map((x) => x.name);
if (lottiePlayer.manifest) {
lottieState.allThemes =
lottiePlayer?.manifest.themes ?
lottiePlayer.manifest.themes.map((t) => t.id)
: [];
}
if (isNullish(marker)) {
start = segment ? segment[0] : 0;
end = segment ? segment[1] : lottiePlayer.totalFrames - 1;
}
// set to frame 1 to trigger initial render
// helpful especially when themeId is set
lottiePlayer.setFrame(1);
onLoad(); // call user-defined onLoad function
}
}
function onCompleteEvent() {
onComplete();
}
function onRenderEvent() {
if (lottiePlayer && lottieState) {
syncLottieState(lottiePlayer, lottieState);
progress = (lottiePlayer.currentFrame + 1) / lottiePlayer.totalFrames;
lottieState.progress = progress;
onRender();
}
}
function handleMouseEnter() {
if (playOnHover && lottiePlayer?.isLoaded) {
lottiePlayer.play();
}
}
function handleMouseLeave() {
if (playOnHover && lottiePlayer?.isLoaded) {
lottiePlayer.pause();
}
}
function handleWindowResize() {
let resizing = false;
let timer = undefined;
if (!resizing && lottiePlayer?.isLoaded && lottiePlayer.isPlaying) {
lottiePlayer?.pause();
resizing = true;
}
clearTimeout(timer);
timer = setTimeout(() => {
resizing = false;
if (lottiePlayer?.isLoaded && lottiePlayer.isPaused) {
lottiePlayer?.play();
}
}, 1000);
}
onMount(() => {
const shouldAutoplay = autoplay && !playOnHover;
progressTween = new Tween(0, { duration: tweenDuration, easing: easing });
const _renderConfig = createRenderConfig();
lottiePlayer = new DotLottie({
canvas,
src,
autoplay: shouldAutoplay,
loop,
speed,
data,
renderConfig: _renderConfig,
segment,
useFrameInterpolation,
backgroundColor,
mode,
animationId,
themeId,
});
lottiePlayer.addEventListener('load', onLoadEvent);
lottiePlayer.addEventListener('frame', onRenderEvent);
lottiePlayer.addEventListener('complete', onCompleteEvent);
window.addEventListener('resize', handleWindowResize);
if (dotLottieRefCallback) {
dotLottieRefCallback(lottiePlayer);
}
return () => {
if (lottiePlayer) {
lottiePlayer.removeEventListener('render', onRender);
lottiePlayer.removeEventListener('load', onLoad);
lottiePlayer.destroy();
}
window.removeEventListener('resize', handleWindowResize);
};
});
// Handles progress change
$effect(() => {
if (lottieState.isLoaded && lottieState.progress !== progress) {
autoplay = false;
lottiePlayer?.pause();
loop = false;
if (progress >= 0 && progress <= 1) {
if (lottieState.isFrozen) {
lottiePlayer?.unfreeze();
lottieState.isFrozen = false;
}
const targetFrame = calculateTargetFrame(progress, mode, start, end);
progressTween.target = targetFrame;
// lottiePlayer.setFrame(targetFrame);
} else if ((progress < 0 || progress > 1) && !lottieState.isFrozen) {
if (isReverseMode(mode)) {
progressTween.target = progress < 0 ? end : start;
} else {
progressTween.target = progress < 0 ? start : end;
}
lottiePlayer?.freeze();
lottieState.isFrozen = true;
}
}
});
// Tweens to progress value
$effect(() => {
if (progressTween.current >= 0) {
lottiePlayer?.setFrame(progressTween.current);
}
});
// Handles layout change
$effect(() => {
if (
typeof layout === 'object' &&
lottiePlayer?.isLoaded &&
!isEqual(layout, lottiePlayer.layout)
) {
lottiePlayer.setLayout(layout);
}
});
// Handles marker change
$effect(() => {
if (lottieState.isLoaded && lottiePlayer?.marker !== marker) {
if (typeof marker === 'string' && lottiePlayer) {
lottiePlayer.setMarker(marker);
[start, end] = getMarkerRange(lottiePlayer, marker);
lottieState.marker = marker;
} else if (isNullish(marker)) {
lottiePlayer?.setMarker('');
} else {
console.warn('Invalid marker type:', marker);
}
}
});
// Handles speed change
$effect(() => {
if (
lottieState.isLoaded &&
typeof speed == 'number' &&
lottiePlayer?.speed !== speed
) {
lottiePlayer?.setSpeed(speed);
}
});
// Handles frame interpolation change
$effect(() => {
if (
lottieState.isLoaded &&
typeof useFrameInterpolation == 'boolean' &&
lottiePlayer?.useFrameInterpolation !== useFrameInterpolation
) {
lottiePlayer?.setUseFrameInterpolation(useFrameInterpolation);
}
});
// Handles segment change
$effect(() => {
if (lottieState.isLoaded && !isEqual(lottiePlayer?.segment, segment)) {
if (
Array.isArray(segment) &&
segment.length === 2 &&
typeof segment[0] === 'number' &&
typeof segment[1] === 'number'
) {
let [start, end] = segment;
lottiePlayer?.setSegment(start, end);
} else if (segment === null || segment === undefined) {
lottiePlayer?.setSegment(0, lottiePlayer?.totalFrames);
}
}
});
// Handles loop change
$effect(() => {
if (
lottieState.isLoaded &&
typeof loop == 'boolean' &&
lottiePlayer?.loop !== loop
) {
lottiePlayer?.setLoop(loop);
}
});
// Handles autoplay change
$effect(() => {
if (
lottieState.isLoaded &&
typeof autoplay == 'boolean' &&
lottieState.autoplay !== autoplay
) {
lottieState.autoplay = !autoplay;
}
});
// Handles background color change
$effect(() => {
if (
lottieState.isLoaded &&
lottiePlayer?.backgroundColor !== backgroundColor
) {
lottiePlayer?.setBackgroundColor(backgroundColor || '');
}
});
// Handles mode change
$effect(() => {
if (
lottieState.isLoaded &&
typeof mode == 'string' &&
lottiePlayer?.mode !== mode
) {
lottiePlayer?.setMode(mode);
}
});
// Handles src change
$effect(() => {
if (lottieState && src !== prevSrc) {
lottiePlayer?.load({
src,
autoplay,
loop,
speed,
data,
renderConfig,
segment,
useFrameInterpolation,
backgroundColor,
mode,
marker,
layout,
animationId,
themeId,
});
prevSrc = src;
}
});
// Generate new instance if data changes
$effect(() => {
if (lottiePlayer && data !== prevData) {
lottiePlayer.load({
src,
autoplay,
loop,
speed,
data,
renderConfig,
segment,
useFrameInterpolation,
backgroundColor,
mode,
marker,
layout,
animationId,
themeId,
});
prevData = data;
}
});
// Handles animationId change
$effect(() => {
if (
lottieState.isLoaded &&
lottiePlayer?.activeAnimationId !== animationId
) {
lottiePlayer?.loadAnimation(animationId);
}
});
// Handles themeId change
$effect(() => {
if (lottieState.isLoaded && lottiePlayer?.activeThemeId !== themeId) {
lottiePlayer?.setTheme(themeId);
}
});
// Handles themeData change
$effect(() => {
if (lottieState.isLoaded && lottiePlayer?.isLoaded) {
lottiePlayer?.setThemeData(themeData);
}
});
</script>
<div
class="lottie-block"
class:debug-border={showDebugInfo}
style="max-width: {isContainerWidth(width) ?
`var(--${width}-column-width)`
: width};"
>
{#if showDebugInfo && lottiePlayer}
<Debug componentState={lottieState} />
{/if}
<div class="lottie-container" style:height>
<canvas
bind:this={canvas}
bind:clientWidth={canvasWidth}
bind:clientHeight={canvasHeight}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
></canvas>
</div>
{#if children}
{@render children()}
{/if}
</div>
<style lang="scss">
:global(.lottie-block) {
position: relative;
height: 100%;
width: 100%;
margin: 0 auto;
.lottie-container {
width: 100%;
height: 100%;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
}
.debug-border {
border: 1px dashed lightgray;
}
</style>

View file

@ -0,0 +1,145 @@
<script lang="ts">
import Block from '../Block/Block.svelte';
import { fade } from 'svelte/transition';
import { getContext, onDestroy } from 'svelte';
import { Markdown } from '@reuters-graphics/svelte-markdown';
// Types
import type { Snippet } from 'svelte';
import type { LottieState } from './ts/lottieState.svelte';
import type {
ContainerWidth,
LottieForegroundPosition,
} from '../@types/global';
interface ForegroundProps {
id?: string;
class?: string;
startFrame?: number;
endFrame?: number;
children?: Snippet;
backgroundColour?: string;
width?: ContainerWidth;
position?: LottieForegroundPosition | string;
text?: string;
}
let {
id,
class: cls,
startFrame = 0,
endFrame = 10,
children,
backgroundColour = '#000',
width = 'normal',
position = 'center center',
text,
}: ForegroundProps = $props();
let componentState: LottieState | null = $state(getContext('lottieState'));
onDestroy(() => {
componentState = null;
});
</script>
<div class="lottie-foreground-container {cls}" {id}>
{#if componentState?.currentFrame && componentState.currentFrame >= startFrame && componentState.currentFrame <= endFrame}
<div
class="lottie-foreground"
in:fade={{ delay: 100, duration: 200 }}
out:fade={{ delay: 0, duration: 100 }}
>
<!-- Text blurb foreground -->
{#if text}
<Block class="lottie-foreground-block {position.split(' ')[1]}" {width}>
<div
style="background-color: {backgroundColour};"
class="foreground-text {position.split(' ')[0]}"
>
<Markdown source={text} />
</div>
</Block>
<!-- Render children snippet -->
{:else if children}
{@render children()}
{/if}
</div>
{/if}
</div>
<style lang="scss">
@use '../../scss/mixins' as mixins;
.lottie-foreground-container {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 2;
.lottie-foreground {
width: 100%;
height: 100%;
}
}
:global {
.lottie-foreground-block {
position: absolute;
width: 100%;
max-width: calc(mixins.$column-width-normal * 0.9);
height: 100%;
&.center {
left: 50%;
transform: translateX(-50%);
}
&.left {
left: 0;
}
&.right {
right: 0;
}
@media (max-width: 1200px) {
left: 50%;
transform: translateX(-50%);
}
}
.foreground-text {
position: absolute;
left: 50%;
width: 100%;
border-radius: 0.25rem;
@include mixins.fpy-5;
@include mixins.fpx-4;
@include mixins.fm-0;
* {
margin: 0;
padding: 0;
}
&.center {
top: 50%;
transform: translate(-50%, -50%);
}
&.top {
top: 0;
transform: translate(-50%, 50%);
}
&.bottom {
top: 100%;
transform: translate(-50%, -150%);
}
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -0,0 +1,64 @@
<script lang="ts">
import BodyText from '../../BodyText/BodyText.svelte';
import ScrollerBase from '../../ScrollerBase/ScrollerBase.svelte';
import Lottie from '../Lottie.svelte';
import LottieSample from '../lottie/themesLottie.zip?url';
// Optional: Bind your own variables to use them in your code.
let count = $state(1);
let index = $state(0);
let offset = $state(0);
let progress = $state(0);
let top = $state(0);
let threshold = $state(0.5);
let bottom = $state(1);
const dummyText = `Greetings, earthling. This placeholder text is running in debug mode. All bugs have been upgraded to features, and all features downgraded to TODOs. If you find yourself reading this, you are now the sysadmin of your own destiny.
Remember the network is down because someone tripped over the Ethernet cable. Keep calm and RTFM before summoning the wizard. In case of panic, type 'kill -9' and hope for the best. End of line. Insert witty comment here.`;
</script>
<BodyText text={dummyText} />
<ScrollerBase
{top}
{threshold}
{bottom}
bind:count
bind:index
bind:offset
bind:progress
query="div.step-foreground-container"
>
{#snippet backgroundSnippet()}
<Lottie src={LottieSample} {progress} height="100lvh" showDebugInfo />
{/snippet}
{#snippet foregroundSnippet()}
<div class="step-foreground-container"><h3>Step 1</h3></div>
<div class="step-foreground-container"><h3>Step 2</h3></div>
<div class="step-foreground-container"><h3>Step 3</h3></div>
<div class="step-foreground-container"><h3>Step 4</h3></div>
<div class="step-foreground-container"><h3>Step 5</h3></div>
{/snippet}
</ScrollerBase>
<BodyText text={dummyText} />
<style lang="scss">
@use '../../../scss/mixins' as mixins;
.step-foreground-container {
height: 100lvh;
width: 50%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
h3 {
width: 100%;
background-color: antiquewhite;
text-align: center;
}
}
</style>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,61 @@
import type { Layout } from '@lottiefiles/dotlottie-web';
export interface LottieState {
[key: string]:
| number
| boolean
| string
| null
| Array<string>
| Array<number>
| [number, number]
| Layout
| undefined;
progress: number;
currentFrame: number;
totalFrames: number;
duration: number;
loop: boolean;
speed: number;
loopCount: number;
mode: string;
isPaused: boolean;
isPlaying: boolean;
isStopped: boolean;
isLoaded: boolean;
isFrozen: boolean;
segment: null | [number, number];
autoplay: boolean;
layout: null | Layout;
allMarkers: Array<string>;
marker: undefined | string;
allThemes: Array<string>;
activeThemeId: null | string;
}
export function createLottieState(): LottieState {
const lottieState = $state<LottieState>({
progress: 0,
currentFrame: 0,
totalFrames: 0,
duration: 0,
loop: false,
speed: 1,
loopCount: 0,
mode: '',
isPaused: false,
isPlaying: false,
isStopped: false,
isLoaded: false,
isFrozen: false,
segment: null,
autoplay: false,
layout: null,
allMarkers: [],
marker: undefined,
allThemes: [],
activeThemeId: null,
});
return lottieState;
}

View file

@ -0,0 +1,45 @@
// Types
import type { Snippet } from 'svelte';
import {
type Config,
type DotLottie as DotLottieType,
} from '@lottiefiles/dotlottie-web';
import { type LottieState } from './lottieState.svelte';
import type { ContainerWidth } from '../../@types/global';
type DotlottieProps = {
autoplay?: Config['autoplay'];
backgroundColor?: Config['backgroundColor'];
data?: Config['data'];
loop?: Config['loop'];
mode?: Config['mode'];
renderConfig?: Config['renderConfig'];
segment?: Config['segment'];
speed?: Config['speed'];
src: Config['src'];
useFrameInterpolation?: Config['useFrameInterpolation'];
marker?: Config['marker'] | undefined;
layout?: Config['layout'];
animationId?: Config['animationId'];
themeId?: Config['themeId'];
playOnHover?: boolean;
themeData?: string;
dotLottieRefCallback?: (dotLottie: DotLottieType) => void;
onLoad?: () => void;
onRender?: () => void;
onComplete?: () => void;
};
export type Props = DotlottieProps & {
// Additional properties can be added here if needed
lottiePlayer?: DotLottieType | undefined;
showDebugInfo?: boolean;
width?: string | ContainerWidth;
height?: string;
lottieState?: LottieState;
progress?: number;
tweenDuration?: number;
easing?: (t: number) => number;
/** Children render function */
children?: Snippet;
};

View file

@ -0,0 +1,127 @@
import type { DotLottie } from '@lottiefiles/dotlottie-web';
import type { LottieState } from './lottieState.svelte';
import type { ContainerWidth } from '$lib/components/@types/global';
function constrain(n: number, low: number, high: number) {
return Math.max(Math.min(n, high), low);
}
export function map(
n: number,
start1: number,
stop1: number,
start2: number,
stop2: number,
withinBounds: boolean = true
) {
const newval = ((n - start1) / (stop1 - start1)) * (stop2 - start2) + start2;
if (!withinBounds) {
return newval;
}
if (start2 < stop2) {
return constrain(newval, start2, stop2);
} else {
return constrain(newval, stop2, start2);
}
}
/**
* Syncs the lottie player state with the component's lottie state
*/
export function syncLottieState(
lottiePlayer: DotLottie,
lottieState: LottieState
) {
lottieState.currentFrame = lottiePlayer.currentFrame;
lottieState.totalFrames = lottiePlayer.totalFrames;
lottieState.duration = lottiePlayer.duration;
lottieState.loop = lottiePlayer.loop;
lottieState.speed = lottiePlayer.speed;
lottieState.loopCount = lottiePlayer.loopCount;
lottieState.mode = lottiePlayer.mode;
lottieState.isPaused = lottiePlayer.isPaused;
lottieState.isPlaying = lottiePlayer.isPlaying;
lottieState.isStopped = lottiePlayer.isStopped;
lottieState.isLoaded = lottiePlayer.isLoaded;
lottieState.isFrozen = lottiePlayer.isFrozen;
lottieState.segment = lottiePlayer.segment ?? null;
lottieState.autoplay = lottiePlayer.autoplay ?? false;
lottieState.layout = lottiePlayer.layout ?? null;
lottieState.activeThemeId = lottiePlayer.activeThemeId ?? null;
lottieState.marker = lottiePlayer.marker ?? undefined;
}
/**
* Gets marker info by name
*/
export function getMarkerByName(lottiePlayer: DotLottie, markerName: string) {
return lottiePlayer.markers().find((m) => m.name === markerName);
}
/**
* Gets the start and end frames for a marker
*/
export function getMarkerRange(
lottiePlayer: DotLottie,
markerName: string
): [number, number] {
const marker = getMarkerByName(lottiePlayer, markerName);
const start = marker?.time ?? 0;
const end = start + (marker?.duration ?? 0);
return [start, end];
}
/**
* Calculates target frame based on progress and mode
*/
export function calculateTargetFrame(
progress: number,
mode: string,
start: number,
end: number
): number {
const adjustedProgress =
mode === 'reverse' || mode === 'reverse-bounce' ? 1 - progress : progress;
return map(adjustedProgress, 0, 1, start, end);
}
/**
* Determines if mode is reverse
*/
export function isReverseMode(mode: string): boolean {
return mode === 'reverse' || mode === 'reverse-bounce';
}
/**
* Creates render config with optimized defaults
*/
export function createRenderConfig() {
return {
autoResize: true,
devicePixelRatio:
window.devicePixelRatio > 1 ? window.devicePixelRatio * 0.75 : 1,
freezeOnOffscreen: true,
};
}
/**
* Checks if a value is null or undefined (empty marker check)
*/
export function isNullish(value: unknown): boolean {
return value === null || value === undefined || value === '';
}
/**
* Checks if a value is of type ContainerWidth
*/
export function isContainerWidth(string: string): string is ContainerWidth {
return (
string === 'narrower' ||
string === 'narrow' ||
string === 'normal' ||
string === 'wide' ||
string === 'wider' ||
string === 'widest' ||
string === 'fluid'
);
}

View file

@ -120,8 +120,6 @@
</div>
<style lang="scss">
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&display=swap');
.debug-info {
position: absolute;
top: 0;
@ -160,7 +158,7 @@
.title {
width: 100%;
font-family: 'Geist Mono', monospace;
font-family: var(--theme-font-family-monospace);
color: white;
margin: 0;
}
@ -188,7 +186,7 @@
p {
font-size: var(--theme-font-size-xxs);
font-family: 'Geist Mono', monospace;
font-family: var(--theme-font-family-monospace);
padding: 0;
margin: 0;
color: rgba(255, 255, 255, 0.7);

View file

@ -41,6 +41,25 @@ npx ffmpeg -y -i <input_video_src>.mp4 -c:v libx264 -movflags +faststart -crf 24
Adjust the `-crf` value to control the quality. A lower `-crf` value means higher quality, with 20-24 being a generally good balance. The video framerate can be altered by using `-r` flag. Set keyframe intervals using the `-g` flag. It is advisable to keep it around 3 seconds (3 \* video framerate) for a good output. See [FFmpeg documentation](https://ffmpeg.org/ffmpeg.html) and [Testing Media Capabilities](https://cconcolato.github.io/media-mime-support/mediacapabilities.html) for more.
## Setting custom width
By default, the `ScrollerVideo` component takes up the full width of its container. To set a custom width, you can wrap the `ScrollerVideo` component in a `<Block>` with one of the acceptable `ContainerWidth` values.
Further, it also allows you to set the `objectFit` property to control how the video should be resized to fit its container. The available options are `cover` and `contain`. The default value is `cover`.
[Demo](?path=/story/components-graphics-scrollervideo--object-fit)
```svelte
<script lang="ts">
import { Block, ScrollerVideo } from '@reuters-graphics/graphics-components';
</script>
<!-- Optionally set `height` to adjust scroll height -->
<Block width="normal">
<ScrollerVideo src="my-video.mp4" height="500lvh" objectFit="contain" />
</Block>
```
## Responsive videos
To show different videos based on the screen width, use the `ScrollerVideo` component with conditional logic that uses a different video source depending on the [window width](https://svelte.dev/docs/svelte/svelte-window).
@ -349,10 +368,9 @@ The `ScrollerVideo` component can be used inside the [ScrollerBase](?path=/story
<ScrollerVideo
src="my-video.mp4"
height="100lvh"
videoPercentage={Math.min(1, Math.max(progress, 0))}
trackScroll={false}
videoPercentage={progress}
transitionSpeed={40}
showDebugInfo
transitionSpeed={20}
/>
{/snippet}
{#snippet foregroundSnippet()}

View file

@ -128,6 +128,7 @@
import Video_LG from './videos/waves_lg.mp4';
import Goldengate from './videos/goldengate.mp4';
import AdvancedUsecases from './demo/AdvancedUsecases.svelte';
import Block from '../Block/Block.svelte';
const videoSrc = {
Video_SM,
@ -147,6 +148,12 @@
<ScrollerVideo {...args} src={videoSrc.Goldengate} />
</Story>
<Story name="Object Fit">
<Block width="normal">
<ScrollerVideo {...args} src={videoSrc.Goldengate} objectFit="contain" />
</Block>
</Story>
<Story name="Responsive videos" exportName="ResponsiveVideos">
{#if width < 600}
<ScrollerVideo {...args} src={videoSrc.Video_SM} />

View file

@ -13,8 +13,8 @@
src={Goldengate}
height="100lvh"
trackScroll={false}
videoPercentage={progress}
transitionSpeed={40}
videoPercentage={Math.min(1, Math.max(progress, 0))}
transitionSpeed={20}
showDebugInfo
/>
{/snippet}

View file

@ -47,6 +47,8 @@ export { default as Spinner } from './components/Spinner/Spinner.svelte';
export { default as ScrollerBase } from './components/ScrollerBase/ScrollerBase.svelte';
export { default as ScrollerVideo } from './components/ScrollerVideo/ScrollerVideo.svelte';
export { default as ScrollerVideoForeground } from './components/ScrollerVideo/ScrollerVideoForeground.svelte';
export { default as Lottie } from './components/Lottie/Lottie.svelte';
export { default as LottieForeground } from './components/Lottie/LottieForeground.svelte';
export { default as SponsorshipAd } from './components/AdSlot/SponsorshipAd.svelte';
export { default as Table } from './components/Table/Table.svelte';
export { default as Theme, themes } from './components/Theme/Theme.svelte';