
Dotlottie Web
- 78 installs
- 838 repo stars
- Updated August 4, 2026
- lottiefiles/dotlottie-web
Helps with ai & agent building tasks.
About
dotlottie-web is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- dotlottie-web
- AI & Agent Building
- AI-coding skill
Dotlottie Web by the numbers
- 78 all-time installs (skills.sh)
- Ranked #5,339 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lottiefiles/dotlottie-web --skill dotlottie-webAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 78 |
|---|---|
| repo stars | ★ 838 |
| Last updated | August 4, 2026 |
| Repository | lottiefiles/dotlottie-web ↗ |
What it does
Helps with ai & agent building tasks.
Files
dotLottie Implementation Guidelines
You are an expert at implementing Lottie animations using dotLottie runtimes. Follow these guidelines when working with dotLottie in web projects.
Package Selection
Use @lottiefiles/dotlottie-web when:
- You need direct canvas control
- Building framework-agnostic code
- Maximum performance is critical
- You want the smallest bundle
Use @lottiefiles/dotlottie-react when:
- Building React applications
- You want declarative component API
- You need React lifecycle integration
Installation
# Web (vanilla JS, Vue, Svelte, etc.)
npm install @lottiefiles/dotlottie-web
# React
npm install @lottiefiles/dotlottie-reactBasic Implementation
Vanilla JavaScript
import { DotLottie } from '@lottiefiles/dotlottie-web';
const dotLottie = new DotLottie({
canvas: document.getElementById('canvas') as HTMLCanvasElement,
src: 'https://example.com/animation.lottie',
autoplay: true,
loop: true,
});React
import { DotLottieReact } from '@lottiefiles/dotlottie-react';
function Animation() {
return (
<DotLottieReact
src="https://example.com/animation.lottie"
autoplay
loop
/>
);
}React with Instance Control
import { useRef } from 'react';
import { DotLottieReact } from '@lottiefiles/dotlottie-react';
import type { DotLottie } from '@lottiefiles/dotlottie-web';
function Animation() {
const dotLottieRef = useRef<DotLottie | null>(null);
return (
<DotLottieReact
src="https://example.com/animation.lottie"
dotLottieRefCallback={(dotLottie) => (dotLottieRef.current = dotLottie)}
/>
);
}.lottie vs .json
Always prefer `.lottie` format over `.json`:
- Smaller file size (compressed)
- Supports multiple animations in one file
- Embedded assets (images, fonts)
- State machines for interactivity
- Theming with slots
Web Workers (Recommended for Performance)
Use DotLottieWorker to offload animation rendering to a Web Worker, keeping the main thread free for UI interactions:
Basic Worker Usage
import { DotLottieWorker } from '@lottiefiles/dotlottie-web';
const dotLottie = new DotLottieWorker({
canvas: document.getElementById('canvas') as HTMLCanvasElement,
src: 'https://example.com/animation.lottie',
autoplay: true,
loop: true,
});Worker Grouping (Multiple Animations)
By default, all DotLottieWorker instances share the same worker. Group animations into separate workers using workerId:
// Hero animation in its own worker
const heroAnimation = new DotLottieWorker({
canvas: heroCanvas,
src: 'hero.lottie',
workerId: 'hero-worker',
});
// UI animations share a different worker
const buttonAnimation = new DotLottieWorker({
canvas: buttonCanvas,
src: 'button.lottie',
workerId: 'ui-worker',
});When to Use Workers
- Use `DotLottieWorker` for:
- Multiple simultaneous animations
- Complex animations with many layers
- Animations running alongside heavy JS operations
- Mobile devices where main thread performance is critical
- Use regular `DotLottie` for:
- Single simple animations
- When you need synchronous frame access
- SSR environments (workers not available)
React with Workers
import { DotLottieWorkerReact } from '@lottiefiles/dotlottie-react';
function Animation() {
return (
<DotLottieWorkerReact
src="animation.lottie"
autoplay
loop
workerId="my-worker" // Optional: dedicate to specific worker
/>
);
}State Machines (Interactivity)
State machines enable interactive animations without code. See the State Machine Guide for details.
const dotLottie = new DotLottie({
canvas,
src: 'interactive.lottie', // Contains state machine
autoplay: true,
});
// Fire events to trigger state transitions
dotLottie.stateMachineFireEvent('click');
dotLottie.stateMachineFireEvent('hover');
dotLottie.stateMachineFireEvent('custom-event');
// Set numeric/boolean/string inputs for state conditions
// See: https://github.com/LottieFiles/dotlottie-web/wiki/dotLottie-State-Machine-Guide#working-with-inputs
dotLottie.stateMachineSetNumericInput('progress', 0.5);
dotLottie.stateMachineSetBooleanInput('isActive', true);
dotLottie.stateMachineSetStringInput('mode', 'dark');State Machine Events
click- User click/taphover- Mouse enterunhover- Mouse leavecomplete- Animation finished- Custom events defined in the state machine
Theming with Slots
Slots allow runtime color/value customization. Themes follow the dotLottie 2.0 spec.
const dotLottie = new DotLottie({
canvas,
src: 'themed.lottie',
themeId: 'dark-mode', // Use embedded theme by ID
});
// Or apply theme data directly (JSON string per dotLottie 2.0 spec)
// See: https://dotlottie.io/spec/2.0/#themes
dotLottie.setThemeData(JSON.stringify({
rules: [
{ id: 'primary-color', value: [1, 0.34, 0.13] }, // RGB values 0-1
]
}));Dynamic Slot Overriding
Slots enable runtime customization of animated properties using typed APIs. Available slot types: color, scalar, vector, gradient, text, image.
Key APIs: getSlotIds(), getSlotType(), setColorSlot(), setScalarSlot(), setVectorSlot(), setGradientSlot(), setTextSlot(), resetSlot(), clearSlots().
For complete API reference with code examples for each slot type, animated keyframes, resetting, bulk updates, common use cases (branding, dark mode, progress indicators), and React integration, see Dynamic Slots Reference.
Markers & Segments
Playing Specific Segments
// Play frames 0-60
dotLottie.setSegment(0, 60);
dotLottie.play();
// Play by marker name (defined in animation)
dotLottie.setMarker('intro');
dotLottie.play();Getting Markers
const markers = dotLottie.markers();
// Returns: [{ name: 'intro', time: 0, duration: 60 }, ...]Rendering a Specific Frame to an Image
Set autoplay: false so playback doesn't advance past your target, then call setFrame() after load. setFrame() renders synchronously, so the canvas and dotLottie.buffer (RGBA Uint8Array) hold that exact frame immediately after the call.
Browser
const dotLottie = new DotLottie({ canvas, src: 'animation.lottie', autoplay: false });
dotLottie.addEventListener('load', () => {
dotLottie.setFrame(42); // render frame 42
const dataUrl = canvas.toDataURL('image/png'); // or canvas.toBlob(cb, 'image/png')
});Node.js (@napi-rs/canvas)
import fs from 'node:fs';
import { createCanvas } from '@napi-rs/canvas';
const canvas = createCanvas(200, 200);
const dotLottie = new DotLottie({
canvas: canvas as unknown as HTMLCanvasElement,
src: 'animation.lottie',
autoplay: false,
});
dotLottie.addEventListener('load', async () => {
dotLottie.setFrame(42);
fs.writeFileSync('frame-42.png', await canvas.encode('png'));
dotLottie.destroy();
});For custom encoding, read raw RGBA pixels directly from dotLottie.buffer (length = width × height × 4).
Event Handling
dotLottie.addEventListener('load', () => {
console.log('Animation loaded');
});
dotLottie.addEventListener('play', () => {
console.log('Playing');
});
dotLottie.addEventListener('complete', () => {
console.log('Animation completed');
});
dotLottie.addEventListener('frame', ({ currentFrame }) => {
console.log('Frame:', currentFrame);
});
// Clean up
dotLottie.removeEventListener('load', handler);Performance Best Practices
1. Use Web Workers for Complex Animations
import { DotLottieWorker } from '@lottiefiles/dotlottie-web';
// Offload rendering to worker thread
const dotLottie = new DotLottieWorker({
canvas,
src: 'complex-animation.lottie',
});2. Lazy Load Animations
// Only load when visible
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadAnimation();
observer.disconnect();
}
});
});
observer.observe(container);3. Auto-Freeze is Enabled by Default
DotLottie automatically freezes animations when they're not visible (offscreen). To disable this behavior:
const dotLottie = new DotLottie({
canvas,
src: 'animation.lottie',
renderConfig: {
freezeOnOffscreen: false, // Disable auto-freeze (not recommended)
},
});4. Device Pixel Ratio
By default, devicePixelRatio is set to 75% of the actual value for better performance. For full retina quality (with higher performance cost):
const dotLottie = new DotLottie({
canvas,
src: 'animation.lottie',
renderConfig: {
devicePixelRatio: window.devicePixelRatio, // Full retina (higher CPU/GPU)
},
});5. Clean Up (Vanilla JS only)
// Always destroy when done (vanilla JS)
dotLottie.destroy();Note: DotLottieReact handles cleanup automatically on unmount - no manual cleanup needed.
6. Frame Interpolation Control
const dotLottie = new DotLottie({
canvas,
src: 'animation.lottie',
useFrameInterpolation: true, // Smooth playback (default)
// useFrameInterpolation: false, // Match original AE frame rate
});Multi-Animation Files
A single .lottie file can contain multiple animations:
// Load specific animation by ID
dotLottie.loadAnimation('animation-2');
// Get all animation IDs
const animations = dotLottie.manifest?.animations;
// Returns: [{ id: 'animation-1' }, { id: 'animation-2' }]Canvas Sizing
Set canvas size via CSS styles (recommended). DotLottie will automatically determine the optimal drawing area:
<canvas id="canvas" style="width: 400px; height: 400px;"></canvas>Auto-Resize to Container
Use the autoResize render config to automatically resize when the container changes:
const dotLottie = new DotLottie({
canvas,
src: 'animation.lottie',
renderConfig: {
autoResize: true, // Canvas resizes to fit container
},
});Common Patterns
Play on Hover
canvas.addEventListener('mouseenter', () => dotLottie.play());
canvas.addEventListener('mouseleave', () => dotLottie.pause());Play on Click (Once)
canvas.addEventListener('click', () => {
dotLottie.setFrame(0);
dotLottie.setLoop(false);
dotLottie.play();
});Scrub with Scroll
window.addEventListener('scroll', () => {
const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
const frame = progress * dotLottie.totalFrames;
dotLottie.setFrame(frame);
});Loading States (React)
function Animation() {
const [isLoaded, setIsLoaded] = useState(false);
return (
<>
{!isLoaded && <Skeleton />}
<DotLottieReact
src="animation.lottie"
style={{ opacity: isLoaded ? 1 : 0 }}
dotLottieRefCallback={(dotLottie) => {
dotLottie.addEventListener('load', () => setIsLoaded(true));
}}
/>
</>
);
}Responsive Animation
function ResponsiveAnimation() {
return (
<DotLottieReact
src="animation.lottie"
autoplay
loop
style={{ width: '100%', maxWidth: '400px' }}
renderConfig={{ autoResize: true }}
/>
);
}Debugging
// Check if loaded
console.log('Loaded:', dotLottie.isLoaded);
// Get animation info
console.log('Duration:', dotLottie.duration);
console.log('Total Frames:', dotLottie.totalFrames);
console.log('Current Frame:', dotLottie.currentFrame);
console.log('Is Playing:', dotLottie.isPlaying);
console.log('Loop:', dotLottie.loop);
console.log('Speed:', dotLottie.speed);
// Get manifest (for .lottie files)
console.log('Manifest:', dotLottie.manifest);Error Handling
dotLottie.addEventListener('loadError', (error) => {
console.error('Failed to load animation:', error);
// Show fallback UI
});SSR / Next.js Considerations
dotLottie requires browser APIs. For SSR frameworks:
import dynamic from 'next/dynamic';
const DotLottieReact = dynamic(
() => import('@lottiefiles/dotlottie-react').then(mod => mod.DotLottieReact),
{ ssr: false }
);
function Animation() {
return <DotLottieReact src="animation.lottie" autoplay loop />;
}Resources
- Documentation: https://developers.lottiefiles.com/docs/dotlottie-player
- State Machine Guide: https://github.com/LottieFiles/dotlottie-web/wiki/dotLottie-State-Machine-Guide
- GitHub: https://github.com/LottieFiles/dotlottie-web
- dotLottie 2.0 Spec: https://dotlottie.io/spec/2.0/
- Create .lottie files: https://lottiefiles.com or https://creators.lottiefiles.com
{
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["*-example", "viewer"],
"prettier": false
}
Fix blank rendering on non-browser canvases (e.g. @napi-rs/canvas in Node).
Bundle sub-path dependency imports (e.g. lit/decorators.js) into the self-contained ESM build so the package works via CDN without an import map.
Changesets
Hello and welcome! This folder has been automatically generated by @changesets/cli, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it in our repository
We have a quick list of common questions to get you started engaging with this project in our documentation
Upgrade dotlottie-rs WASM bindings to v0.1.58
root = true
# General rules
[*]
charset = utf-8
end_of_line = lf
# JSON
[*.json]
indent_size = 2
indent_style = space
tab_width = 2
# HTML
[*.html]
indent_size = 4
indent_style = tab
tab_width = 4
# Markdown
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false
# Patch diffs
[*.patch]
insert_final_newline = false
trim_trailing_whitespace = false
# Typescript
[*.{ts,tsx}]
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 120
quote_type = single
tab_width = 2
# Yaml
[*.{yaml,yml}]
indent_size = 2
indent_style = space
insert_final_newline = true
tab_width = 2
# Build outputs
build/
dist/
artifacts/
# Changelog
.changeset/config.json
CHANGELOG.md
# IDE related
.idea/
.history/
# Package management
node_modules/
.yarn/
.pnp.*
# Testing
coverage/
__snapshots__
fixtures
# Temporary or local data
temp/
tmp/
# Clinic profiling
.clinic
# Misc files to ignroe
.eslintrc.cjs
# NextJS
.next/
next-env.d.ts
# Exclude from ignore
!.github
!.vscode,
# Ignore renderer releases
releases/
dotlottie-player.js
dotlottie.worker.js
# Ignore all apps
apps/
# AUTO-DETECT
# Handle line endings automatically for files detected as
# text and leave all files detected as binary untouched.
# This will handle all files NOT defined below.
* text eol=lf
*.json linguist-language=JSON-with-Comments
*.wasm binary
*.gif binary
/.github/ @LottieFiles/dotlottie
/package.json @LottieFiles/dotlottie
/pnpm-lock.yaml @LottieFiles/dotlottie
/pnpm-workspace.yaml @LottieFiles/dotlottie
/.npmrc @LottieFiles/dotlottie
/.gitmodules @LottieFiles/dotlottie
/.changeset/ @LottieFiles/dotlottie
/turbo.json @LottieFiles/dotlottie
/lefthook.yml @LottieFiles/dotlottie
/commitlint.config.ts @LottieFiles/dotlottie
/scripts/ @LottieFiles/dotlottie
/packages/ @theashraf
/packages/**/package.json @LottieFiles/dotlottie
/packages/**/.npmrc @LottieFiles/dotlottieversion: 2
updates:
- package-ecosystem: "npm"
directories:
- "/packages/web"
- "/packages/react"
- "/packages/vue"
- "/packages/svelte"
- "/packages/solid"
- "/packages/wc"
schedule:
interval: "weekly"
groups:
dev-dependencies:
dependency-type: "development"
update-types:
- "minor"
- "patch"
prod-dependencies:
dependency-type: "production"
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
github-actions:
patterns:
- "*"
Package
<!-- Which package(s) are affected? -->
- [ ]
@lottiefiles/dotlottie-web(core) - [ ]
@lottiefiles/dotlottie-react - [ ]
@lottiefiles/dotlottie-vue - [ ]
@lottiefiles/dotlottie-svelte - [ ]
@lottiefiles/dotlottie-solid - [ ]
@lottiefiles/dotlottie-wc
Description
<!-- A clear description of the bug. -->
Steps to reproduce
1. 2. 3.
Expected behavior
<!-- What did you expect to happen? -->
Actual behavior
<!-- What happened instead? -->
Animation file
<!-- If possible, attach the .lottie or .json animation file that triggers the bug. -->
Environment
- Package version:
- Browser:
- OS:
Package
<!-- Which package(s) does this apply to? -->
- [ ]
@lottiefiles/dotlottie-web(core) - [ ]
@lottiefiles/dotlottie-react - [ ]
@lottiefiles/dotlottie-vue - [ ]
@lottiefiles/dotlottie-svelte - [ ]
@lottiefiles/dotlottie-solid - [ ]
@lottiefiles/dotlottie-wc
Description
<!-- What would you like to improve? -->
Motivation
<!-- Why is this enhancement needed? What problem does it solve? -->
Proposed solution
<!-- If you have a specific approach in mind, describe it here. -->
Package
<!-- Which package(s) does this apply to? -->
- [ ]
@lottiefiles/dotlottie-web(core) - [ ]
@lottiefiles/dotlottie-react - [ ]
@lottiefiles/dotlottie-vue - [ ]
@lottiefiles/dotlottie-svelte - [ ]
@lottiefiles/dotlottie-solid - [ ]
@lottiefiles/dotlottie-wc
Type
- [ ] New feature
- [ ] Changes to existing feature
Description
<!-- Describe the feature you'd like to see. -->
Motivation
<!-- What problem does this feature solve? What use case does it enable? -->
Proposed API / usage
<!-- If applicable, show how this feature would be used. -->
// Example usageDescription
<!-- Summarize your changes. If this fixes an issue, include "Fixes #<issue>" below. -->
Package(s) affected
- [ ]
@lottiefiles/dotlottie-web(core) - [ ]
@lottiefiles/dotlottie-react - [ ]
@lottiefiles/dotlottie-vue - [ ]
@lottiefiles/dotlottie-svelte - [ ]
@lottiefiles/dotlottie-solid - [ ]
@lottiefiles/dotlottie-wc
Type of change
- [ ] Bug fix (non-breaking)
- [ ] New feature (non-breaking)
- [ ] Breaking change
- [ ] Chore (build, CI, docs, refactor)
Checklist
- [ ] Changes have been tested locally
- [ ] Tests have been added or updated
- [ ] Changeset has been added (if applicable)
Security Policy
Supported versions
Security fixes land on the latest published release of each package in this repo:
@lottiefiles/dotlottie-web@lottiefiles/dotlottie-react@lottiefiles/dotlottie-solid@lottiefiles/dotlottie-svelte@lottiefiles/dotlottie-vue@lottiefiles/dotlottie-wc
All packages are currently pre-1.0 — older minors are not backported. Please upgrade to the latest version to receive fixes.
Reporting a vulnerability
Do not open a public issue for security reports. Public disclosure before a fix is released puts every consumer at risk.
Use GitHub's Private Vulnerability Reporting:
1. Go to the repo's Security tab. 2. Click Report a vulnerability. 3. Fill in the form with reproduction steps, affected package(s), and the version range you've tested.
Reports are visible only to repo maintainers.
What to expect
- Acknowledgement — within 5 business days.
- Assessment + remediation plan — within 30 days of acknowledgement.
- Coordinated disclosure — a GitHub Security Advisory is published alongside or shortly after the patched release. Reporters are credited unless they request otherwise.
Scope
In scope:
- Any package published from this repository under
@lottiefiles/dotlottie-*. - The build, release, and CI configuration in this repository that could compromise a published artifact.
Out of scope:
- Vulnerabilities in third-party dependencies — please report those to the upstream project first; we track them via Dependabot.
- Bugs without a security impact — file a regular issue.
- Findings from automated scanners without a working proof of concept.
name: CI
on:
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
build:
name: Build
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: ⎔ Setup pnpm@v10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: ⎔ Setup Node@lts
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: pnpm
node-version: lts/*
- name: 📥 Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: 🏗 Build packages
run: pnpm build:packages
- name: 📦 Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: build-output
path: |
packages/*/dist
packages/svelte/.svelte-kit
retention-days: 1
include-hidden-files: true
lint:
name: Lint & Format
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: ⎔ Setup pnpm@v10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: ⎔ Setup Node@lts
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: pnpm
node-version: lts/*
- name: 📥 Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: 📦 Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: build-output
path: packages
- name: 🕵️ Lint + Format
run: pnpm exec biome check .
type-check:
name: Type Check
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: ⎔ Setup pnpm@v10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: ⎔ Setup Node@lts
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: pnpm
node-version: lts/*
- name: 📥 Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: 📦 Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: build-output
path: packages
- name: 🔍 Verify types
run: pnpm type-check
test:
name: Test
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: 🛡 Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: ⬇️ Checkout repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: ⎔ Setup pnpm@v10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: ⎔ Setup Node@lts
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: pnpm
node-version: lts/*
- name: 📥 Install dependencies
run: pnpm install
- name: 📦 Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: build-output
path: packages
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright
run: pnpm exec playwright install --with-deps chromium
- name: 🛡️ Test (with coverage)
env:
BASE_REF: ${{ github.base_ref || 'main' }}
run: pnpm vitest run --browser.headless --coverage --coverage.all=false --changed="origin/$BASE_REF"
- name: Check coverage outputs
if: always()
id: coverage-check
run: |
echo "web=$(test -f packages/web/coverage/coverage-summary.json && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "react=$(test -f packages/react/coverage/coverage-summary.json && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "wc=$(test -f packages/wc/coverage/coverage-summary.json && echo true || echo false)" >> "$GITHUB_OUTPUT"
- name: 📏 Report coverage (web)
if: always() && steps.coverage-check.outputs.web == 'true'
uses: davelosert/vitest-coverage-report-action@3c054a2d2e2ca45446417ad5d6d5eb33092af8f1 # v2.12.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
working-directory: packages/web
name: '@lottiefiles/dotlottie-web'
- name: 📏 Report coverage (react)
if: always() && steps.coverage-check.outputs.react == 'true'
uses: davelosert/vitest-coverage-report-action@3c054a2d2e2ca45446417ad5d6d5eb33092af8f1 # v2.12.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
working-directory: packages/react
name: '@lottiefiles/dotlottie-react'
- name: 📏 Report coverage (wc)
if: always() && steps.coverage-check.outputs.wc == 'true'
uses: davelosert/vitest-coverage-report-action@3c054a2d2e2ca45446417ad5d6d5eb33092af8f1 # v2.12.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
working-directory: packages/wc
name: '@lottiefiles/dotlottie-wc'
bundle-size:
name: Bundle Size
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: 🛡 Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: ⬇️ Checkout repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: ⎔ Setup pnpm@v10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: ⎔ Setup Node@lts
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: pnpm
node-version: lts/*
- name: 📥 Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: 📏 Report bundle size
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
continue-on-error: true
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:packages
name: Deploy Viewer
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'apps/viewer/**'
- 'packages/web/**'
concurrency:
group: deploy-viewer
cancel-in-progress: true
permissions: {}
jobs:
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
if: github.repository == 'LottieFiles/dotlottie-web'
permissions:
contents: write
pages: write
steps:
- name: 🛡 Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: ⬇️ Checkout repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: ⎔ Setup pnpm@v10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: ⎔ Setup Node@lts
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: pnpm
node-version: lts/*
- name: 📥 Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: 🏗 Build
run: pnpm build --filter='viewer...'
- name: 🌐 Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./apps/viewer/dist
name: Release
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: release
cancel-in-progress: false
permissions: {}
jobs:
validate:
name: Validate
runs-on: ubuntu-latest
if: github.event_name == 'push'
permissions:
contents: read
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: ⎔ Setup pnpm@v10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: ⎔ Setup Node@lts
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: pnpm
node-version: lts/*
- name: 📥 Install dependencies
run: pnpm install
- name: 🏗 Build packages
run: pnpm build:packages
- name: 🏗 Build apps & examples
run: pnpm build:apps && pnpm build:examples
- name: 🔍 Verify types
run: pnpm type-check
- name: 🕵️ Lint + Format
run: pnpm exec biome check .
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install Playwright
run: pnpm exec playwright install --with-deps chromium
- name: 🛡️ Test (Browser)
run: pnpm test
npm-release:
name: NPM Release
needs: validate
runs-on: ubuntu-latest
if: github.repository == 'LottieFiles/dotlottie-web'
outputs:
published: ${{ steps.changesets.outputs.published }}
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
hasChangesets: ${{ steps.changesets.outputs.hasChangesets }}
permissions:
contents: write
id-token: write
packages: write
pull-requests: write
steps:
- name: 🛡 Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: ⬇️ Checkout repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: ⎔ Setup pnpm@v10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: ⎔ Setup Node@lts
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: pnpm
node-version: lts/*
registry-url: https://registry.npmjs.org
- name: 📥 Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: 🚀 Release to NPM
id: changesets
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
with:
commit: 'chore: update versions'
title: 'chore: update versions'
publish: pnpm release:publish
version: pnpm release:version
createGithubReleases: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: 📣 Notify docs-content
if: steps.changesets.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.DOCS_CONTENT_SYNC_TOKEN }}
REPO_TOKEN: ${{ github.token }}
PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }}
SOURCE_REPO: ${{ github.repository }}
SENDER: ${{ github.actor }}
run: |
# Allow-list: only known packages, semver-ish version.
TAG_RE='^@lottiefiles/dotlottie-(react|vue|svelte|solid|web|wc)@[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'
echo "$PUBLISHED" | jq -c '.[]' | while read -r PKG; do
NAME=$(jq -r '.name' <<<"$PKG")
VERSION=$(jq -r '.version' <<<"$PKG")
TAG="${NAME}@${VERSION}"
if [[ ! "$TAG" =~ $TAG_RE ]]; then
echo "Skipping '$TAG' — failed strict validation"
continue
fi
SHORT="${BASH_REMATCH[1]}"
case "$SHORT" in
web) PKG_DIR="packages/web" ;;
wc) PKG_DIR="packages/wc" ;;
*) PKG_DIR="packages/${SHORT}" ;;
esac
REL=$(GH_TOKEN="$REPO_TOKEN" gh release view "$TAG" \
--repo "$SOURCE_REPO" --json name,body,url)
jq -n \
--arg source_repo "$SOURCE_REPO" \
--arg source_package "$PKG_DIR" \
--arg release_tag "$TAG" \
--argjson rel "$REL" \
--arg sender "$SENDER" \
'{event_type:"dotlottie-docs-eval", client_payload:{source_repo:$source_repo, source_package:$source_package, release_tag:$release_tag, release_name:$rel.name, release_body:$rel.body, release_url:$rel.url, sender:$sender}}' \
> payload.json
echo "Dispatching for release: $TAG → $PKG_DIR"
gh api --method POST "/repos/LottieFiles/docs-content/dispatches" --input payload.json
done
gpr-release:
name: GPR Release
needs: npm-release
runs-on: ubuntu-latest
if: |
always() &&
github.repository == 'LottieFiles/dotlottie-web' &&
needs.npm-release.result != 'cancelled' &&
(
needs.npm-release.outputs.published == 'true' ||
needs.npm-release.result == 'failure' ||
github.event_name == 'workflow_dispatch'
)
permissions:
contents: read
id-token: write
packages: write
steps:
- name: 🛡 Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: ⬇️ Checkout repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: ⎔ Setup pnpm@v10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: ⎔ Setup Node@lts
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: pnpm
node-version: lts/*
registry-url: https://registry.npmjs.org
- name: 📥 Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: 🏗 Build packages
run: pnpm build:packages
- name: 🚀 Publish to GitHub Packages
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
NPMRC=$(mktemp)
trap 'rm -f "$NPMRC"' EXIT
{
echo "@lottiefiles:registry=https://npm.pkg.github.com/"
echo '//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}'
} > "$NPMRC"
NPM_CONFIG_USERCONFIG="$NPMRC" pnpm changeset publish
jsr-release:
name: JSR Release
needs: [npm-release, gpr-release]
runs-on: ubuntu-latest
if: needs.npm-release.outputs.published == 'true'
permissions:
contents: read
id-token: write
steps:
- name: 🛡 Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: ⬇️ Checkout repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: ⎔ Setup pnpm@v10
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: ⎔ Setup Node@lts
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: pnpm
node-version: lts/*
registry-url: https://registry.npmjs.org
- name: 📥 Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: 🔄 Sync version to jsr.json
run: |
VERSION=$(jq -r '.version' ./packages/web/package.json)
jq '.version = $newVersion' --arg newVersion "$VERSION" ./packages/web/jsr.json > temp.json
mv temp.json ./packages/web/jsr.json
# related issue https://github.com/denoland/deno/issues/26152
- name: 🚀 Release to JSR
working-directory: packages/web
env:
NODE_AUTH_TOKEN: ${{ secrets.NPMJS_TOKEN }}
run: |
NPMRC=$(mktemp)
trap 'rm -f "$NPMRC"' EXIT
{
echo "@lottiefiles:registry=https://registry.npmjs.org/"
echo '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}'
} > "$NPMRC"
NPM_CONFIG_USERCONFIG="$NPMRC" npx jsr publish --allow-dirty
name: 'Close stale issues'
on:
schedule:
- cron: '30 1 * * *'
permissions: {}
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: 🛡 Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Idle number of days before marking an issue/pr as stale.
days-before-stale: 60
# Idle number of days before closing an stale issue/pr.
# Set to -1 to never automatically close stale issues.
days-before-close: -1
# Message to post on the stale issue.
stale-issue-message:
'This issue has been automatically marked as stale because it has not had recent activity. It will be closed
in 7 days if no further activity occurs.'
# Label to apply on the stale issue
stale-issue-label: 'stale'
# Labels on an issue exempted from being marked as stale.
# Set to #wip for work-in-progress:
exempt-issue-labels: 'wip'
# Message to post on the stale pr.
stale-pr-message:
'This pull request has been automatically marked as stale because it has not had recent activity. It will be
closed in 7 days if no further activity occurs.'
# Label to apply on the stale pr.
stale-pr-label: 'stale'
# Labels on a pr exempted from being marked as stale.
# Set to #wip for work-in-progress:
exempt-pr-labels: 'wip'
# Whether to remove stale label from issue/pr on updates or comments.
remove-stale-when-updated: true
# Build artifacts
build/
dist/
artifacts/
.next/
*.tgz
# DotEnv local files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Linting
.eslintcache
# IDE related
.idea/
.history/
# Logs
*.log
.*.log
# NPM/Yarn
node_modules/
.yarn/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Monorepo management
.turbo
# Operating system
.DS_Store
*.bak
*.swp
*~
# Pnpm cache
.pnpm-store/
# Temporary or local data
temp/
tmp/
.tmp
# Testing
.jestcache
coverage/
reports/
__diff_output__/
# TsDoc
tsdoc-metadata.json
# Typescript
*.tsbuildinfo
.parcel-cache/
[submodule "deps/dotlottie-rs"]
path = packages/web/dotlottie-rs
url = https://github.com/Lottiefiles/dotlottie-rs
22
# Build artifacts
build/
dist/
artifacts/
# Changelog
CHANGELOG.md
# IDE related
.idea/
.history/
# Package management
node_modules/
.yarn/
# Testing
fixtures
__snapshots__
coverage/
reports
# Temporary or local data
temp/
tmp/
dotlottie-rsimport remarkPreset from '@lottiefiles/remark-preset';
export default remarkPreset;
export default [
{
name: '@lottiefiles/dotlottie-web',
path: 'packages/web/dist/index.js',
import: '*',
},
{
name: '@lottiefiles/dotlottie-web WASM',
path: 'packages/web/dist/*.wasm',
modifyWebpackConfig: (config) => {
config.experiments = {
asyncWebAssembly: true,
};
},
},
{
name: '@lottiefiles/dotlottie-react',
path: 'packages/react/dist/index.js',
import: '*',
},
{
name: '@lottiefiles/dotlottie-vue',
path: 'packages/vue/dist/index.js',
import: '*',
},
{
name: '@lottiefiles/dotlottie-wc',
path: 'packages/wc/dist/index.js',
import: '*',
},
{
name: '@lottiefiles/dotlottie-svelte',
path: 'packages/svelte/dist/index.js',
import: '*',
modifyWebpackConfig: (config) => {
config.module.rules.push({
test: /\.svelte$/,
use: {
loader: 'svelte-loader',
},
});
return config;
},
},
{
name: '@lottiefiles/dotlottie-solid',
path: 'packages/solid/dist/index.js',
import: '*',
},
];
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
href="https://fonts.googleapis.com/css2?family=Karla:ital,wght@0,400;0,700;1,400;1,700&display=swap"
rel="stylesheet"
/>
<title>dotLottie-viewer</title>
</head>
<body class="">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script src="profiler.js"></script>
</body>
</html>
{
"name": "viewer",
"version": "0.0.1",
"type": "module",
"homepage": "https://lottiefiles.github.io/dotlottie-web/",
"private": true,
"scripts": {
"build": "tsc && vite build && cp dist/index.html dist/404.html",
"dev": "vite --mode=development",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.12",
"@dotlottie/react-player": "^1.6.19",
"@heroicons/react": "^2.1.5",
"@lottiefiles/dotlottie-react": "workspace:*",
"@lottiefiles/dotlottie-web": "workspace:*",
"@lottiefiles/react-lottie-player": "^3.5.4",
"@reduxjs/toolkit": "^2.2.3",
"@tanstack/react-virtual": "^3.10.8",
"@uiw/codemirror-theme-github": "^4.25.4",
"autoprefixer": "^10.4.19",
"canvaskit-wasm": "^0.39.1",
"codemirror": "^6.0.2",
"lottie-web": "^5.12.2",
"lz-string": "^1.5.0",
"postcss": "8.5.10",
"react": "^19.2.3",
"react-device-detect": "^2.2.3",
"react-dom": "^19.2.3",
"react-dropzone": "^14.2.3",
"react-icons": "^5.0.1",
"react-range": "^1.8.14",
"react-redux": "^9.1.0",
"react-router-dom": "^6.26.1",
"react-toastify": "^10.0.5",
"tailwindcss": "^3.4.3",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/lz-string": "^1.5.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"typescript": "^5.2.2",
"vite": "^6.4.2"
}
}
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
import { DotLottieWorkerReact } from '@lottiefiles/dotlottie-react';
import { useAppDispatch } from '../store/hooks';
import { setSrc } from '../store/viewer-slice';
interface ListItemProps {
name: string;
url: string;
}
function ListItem(props: ListItemProps) {
const dispatch = useAppDispatch();
return (
<button
onClick={() => {
dispatch(setSrc(props.url));
}}
className="rounded-lg bg-white border border-transparent hover:border-lottie"
>
<div>
<DotLottieWorkerReact style={{ height: '120px' }} src={props.url} autoplay loop />
</div>
<div className="text-xs py-1 bg-strong rounded-b-lg">{props.name}</div>
</button>
);
}
interface AnimationListProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
}
const AnimationList: React.FC<AnimationListProps> = ({ className = '', ...props }) => {
return (
<div className={`gap-2 flex flex-col ${className}`} {...props}>
<ListItem
name="multi-animations"
url="https://lottie.host/294b684d-d6b4-4116-ab35-85ef566d4379/VkGHcqcMUI.lottie"
/>
<ListItem
name="theming example"
url="https://lottie.host/884c11a9-e648-4b2f-9906-2c77279710b1/PalAqPKzRZ.lottie"
/>
<ListItem
name="marker example"
url={`https://lottie.host/a04c548c-307f-420b-9ba8-e90a4a2efea4/MT9OsNynSw.lottie`}
/>
</div>
);
};
export default AnimationList;
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
setActiveAnimationId,
setActiveMarker,
setActiveStateMachineId,
setActiveThemeId,
setAvailableVersions,
setBackgroundColor,
setMdode,
setRenderer,
setSegment,
setSegmentInput,
setShowLottieWeb,
setSpeed,
setUseFrameInterpolation,
setVersion,
} from '../store/viewer-slice';
import BaseInput from './form/base-input';
import BaseSelect from './form/base-select';
import InputLabel from './form/input-label';
import StepSelect from './form/step-select';
import Switch from './form/switch';
export default function Controls() {
const speed = useAppSelector((state) => state.viewer.speed);
const animations = useAppSelector((state) => state.viewer.animations);
const themes = useAppSelector((state) => state.viewer.themes);
const backgroundColor = useAppSelector((state) => state.viewer.backgroundColor);
const activeAnimationId = useAppSelector((state) => state.viewer.activeAnimationId);
const totalFrames = useAppSelector((state) => state.viewer.totalFrames);
const segmentInput = useAppSelector((state) => state.viewer.segmentInput);
const useFrameInterpolation = useAppSelector((state) => state.viewer.useFrameInterpolation);
const markers = useAppSelector((state) => state.viewer.markers);
const stateMachines = useAppSelector((state) => state.viewer.stateMachines);
const activeStateMachineId = useAppSelector((state) => state.viewer.activeStateMachineId);
const renderer = useAppSelector((state) => state.viewer.renderer);
const isJson = useAppSelector((state) => state.viewer.isJson);
const showLottieWeb = useAppSelector((state) => state.viewer.showLottieWeb);
const version = useAppSelector((state) => state.viewer.version);
const availableVersions = useAppSelector((state) => state.viewer.availableVersions);
const dispatch = useAppDispatch();
useEffect(() => {
if (availableVersions.length > 0) return;
fetch('https://registry.npmjs.org/@lottiefiles/dotlottie-react')
.then((res) => res.json())
.then((data) => {
const allVersions = Object.keys(data.versions).reverse();
const versionPairs = allVersions.map((v) => ({
reactVersion: v,
coreVersion: data.versions[v]?.dependencies?.['@lottiefiles/dotlottie-web'] ?? v,
}));
dispatch(setAvailableVersions(versionPairs));
})
.catch((err) => {
console.error('Failed to fetch dotlottie-react versions:', err);
});
}, [availableVersions.length, dispatch]);
return (
<div className="flex h-full p-4 bg-white border rounded-lg">
<div className="flex flex-col items-center w-full gap-4">
<InputLabel lablel="Version">
<BaseSelect
className="w-full"
onChange={(event) => {
dispatch(setVersion(event.target.value));
}}
value={version}
items={[
{ value: 'local', label: '(dev)' },
...availableVersions.map((v, i) => ({
value: v.reactVersion,
label: i === 0 ? `${v.coreVersion} (latest)` : v.coreVersion,
})),
]}
/>
</InputLabel>
{isJson && (
<InputLabel lablel="Lottie Web v5.12.2">
<Switch
onChange={(value) => dispatch(setShowLottieWeb(value === 'true'))}
items={[
{ label: 'Show', value: 'true' },
{ label: 'Hide', value: 'false' },
]}
value={String(showLottieWeb)}
/>
</InputLabel>
)}
<InputLabel lablel="Renderer">
<Switch
onChange={(value) => {
dispatch(setRenderer(value));
}}
items={[
{ label: 'Software', value: 'software' },
{ label: 'WebGL', value: 'webgl' },
{ label: 'WebGPU', value: 'webgpu' },
]}
value={renderer}
/>
</InputLabel>
<InputLabel lablel="backgroundColor">
<BaseInput
// defaultValue={backgroundColor}
value={backgroundColor}
onChange={(value) => {
dispatch(setBackgroundColor(value));
}}
/>
</InputLabel>
<InputLabel lablel="Speed">
<StepSelect
min={0.5}
max={3}
step={0.5}
values={[speed]}
onChange={(values) => {
dispatch(setSpeed(values[0]));
}}
/>
</InputLabel>
<InputLabel lablel="Mode">
<BaseSelect
className="w-full"
onChange={(event) => {
dispatch(setMdode(event.target.value));
}}
defaultValue="3"
items={[
{
value: 'forward',
label: 'Forward',
},
{
value: 'reverse',
label: 'Reverse',
},
{
value: 'bounce',
label: 'Bounce',
},
{
value: 'reverse-bounce',
label: 'Reverse Bounce',
},
]}
/>
</InputLabel>
<InputLabel lablel="Segment">
<div className="flex gap-2">
<StepSelect
min={1}
max={totalFrames || 2}
step={1}
values={segmentInput}
onChange={(values) => {
dispatch(setSegmentInput(values));
}}
/>
<button
className="p-1 px-2 font-bold border rounded-lg bg-subtle hover:bg-subtle/60 border-subtle h-9"
onClick={() => {
dispatch(setSegment(segmentInput));
}}
>
Apply
</button>
</div>
</InputLabel>
<InputLabel lablel="useFrameInterpolation">
<Switch
onChange={(value) => {
dispatch(setUseFrameInterpolation(value === 'true'));
}}
items={[
{ label: 'On', value: 'true' },
{ label: 'Off', value: 'false' },
]}
value={String(useFrameInterpolation)}
/>
</InputLabel>
<InputLabel lablel="Animation">
<BaseSelect
className="w-full"
onChange={(event) => {
dispatch(setActiveAnimationId(event.target.value));
}}
value={activeAnimationId}
emptyMessage="Single animation available for this file"
placeholder="Select an Animation"
items={
animations.length === 1
? []
: animations.map((animation) => ({
value: animation,
label: animation,
}))
}
/>
</InputLabel>
<InputLabel lablel="State Machine">
<BaseSelect
className="w-full"
onChange={(event) => {
dispatch(setActiveStateMachineId(event.target.value));
}}
value={activeStateMachineId}
placeholder="Select a state machine"
emptyMessage="No state machines available for this animation"
items={stateMachines.map((stateMachine) => ({
value: stateMachine.id,
label: stateMachine.id,
}))}
/>
</InputLabel>
<InputLabel lablel="Themes">
<BaseSelect
className="w-full"
onChange={(event) => {
dispatch(setActiveThemeId(event.target.value));
}}
placeholder="default theme"
emptyMessage="No themes available for this animation"
items={themes.map((theme) => ({
value: theme.id,
label: theme.id,
}))}
/>
</InputLabel>
<InputLabel lablel="Markers">
<BaseSelect
className="w-full"
onChange={(event) => {
dispatch(setActiveMarker(event.target.value));
}}
placeholder="Select a marker"
emptyMessage="No markers available for this animation"
items={markers.map((marker) => ({
value: marker,
label: marker,
}))}
/>
</InputLabel>
</div>
</div>
);
}
import type { DotLottie, Mode } from '@lottiefiles/dotlottie-web';
import { useCallback, useEffect, useRef } from 'react';
interface DotLottieCDNPlayerProps {
version: string;
src: string;
autoplay: boolean;
loop: boolean;
speed: number;
mode: Mode;
backgroundColor: string;
useFrameInterpolation: boolean;
animationId: string;
themeId: string;
marker: string;
segment: [number, number] | number[];
stateMachineId: string;
dotLottieRefCallback: (instance: DotLottie | null) => void;
}
// Cache imported modules by version to avoid re-fetching
const moduleCache = new Map<string, { DotLottie: typeof DotLottie }>();
async function createCDNDotLottieInstance(
version: string,
config: Omit<DotLottieCDNPlayerProps, 'version' | 'dotLottieRefCallback'> & { canvas: HTMLCanvasElement },
): Promise<DotLottie> {
const cached = moduleCache.get(version);
const mod = cached ?? (await import(/* @vite-ignore */ `https://esm.sh/@lottiefiles/dotlottie-web@${version}`));
if (!cached) {
moduleCache.set(version, mod);
}
return new mod.DotLottie({
canvas: config.canvas,
src: config.src,
autoplay: config.autoplay,
loop: config.loop,
speed: config.speed,
mode: config.mode,
backgroundColor: config.backgroundColor,
useFrameInterpolation: config.useFrameInterpolation,
animationId: config.animationId || undefined,
themeId: config.themeId || undefined,
marker: config.marker || undefined,
segment: config.segment.length === 2 ? (config.segment as [number, number]) : undefined,
stateMachineId: config.stateMachineId || undefined,
}) as unknown as DotLottie;
}
export default function DotLottieCDNPlayer({
version,
src,
autoplay,
loop,
speed,
mode,
backgroundColor,
useFrameInterpolation,
animationId,
themeId,
marker,
segment,
stateMachineId,
dotLottieRefCallback,
}: DotLottieCDNPlayerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const instanceRef = useRef<DotLottie | null>(null);
const versionRef = useRef(version);
const initPlayer = useCallback(async () => {
if (instanceRef.current) {
try {
instanceRef.current.destroy();
} catch {
// WASM may throw during cleanup
}
instanceRef.current = null;
dotLottieRefCallback(null);
}
const canvas = canvasRef.current;
if (!canvas) return;
try {
const instance = await createCDNDotLottieInstance(version, {
canvas,
src,
autoplay,
loop,
speed,
mode,
backgroundColor,
useFrameInterpolation,
animationId,
themeId,
marker,
segment,
stateMachineId,
});
if (versionRef.current !== version) {
instance.destroy();
return;
}
instanceRef.current = instance;
dotLottieRefCallback(instance);
} catch (error) {
console.error(`[DotLottieCDNPlayer] Failed to load v${version}:`, error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version, src]);
useEffect(() => {
versionRef.current = version;
initPlayer();
return () => {
if (instanceRef.current) {
try {
instanceRef.current.destroy();
} catch {
// WASM may throw during cleanup
}
instanceRef.current = null;
dotLottieRefCallback(null);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initPlayer]);
useEffect(() => {
instanceRef.current?.setSpeed(speed);
}, [speed]);
useEffect(() => {
instanceRef.current?.setLoop(loop);
}, [loop]);
useEffect(() => {
instanceRef.current?.setMode(mode);
}, [mode]);
useEffect(() => {
instanceRef.current?.setUseFrameInterpolation(useFrameInterpolation);
}, [useFrameInterpolation]);
useEffect(() => {
instanceRef.current?.setBackgroundColor(backgroundColor);
}, [backgroundColor]);
useEffect(() => {
if (!instanceRef.current || !animationId) return;
instanceRef.current.loadAnimation(animationId);
}, [animationId]);
useEffect(() => {
if (!instanceRef.current) return;
if (themeId) {
instanceRef.current.setTheme(themeId);
} else {
instanceRef.current.resetTheme();
}
}, [themeId]);
useEffect(() => {
if (!instanceRef.current || !marker) return;
instanceRef.current.setMarker(marker);
}, [marker]);
useEffect(() => {
if (!instanceRef.current || segment.length !== 2) return;
instanceRef.current.setSegment(segment[0]!, segment[1]!);
}, [segment]);
useEffect(() => {
if (!instanceRef.current) return;
if (stateMachineId) {
instanceRef.current.stateMachineLoad(stateMachineId);
instanceRef.current.stateMachineStart();
} else {
instanceRef.current.stateMachineStop();
}
}, [stateMachineId]);
return <canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />;
}
import type { DotLottie, Mode } from '@lottiefiles/dotlottie-web';
import { useCallback, useEffect, useRef } from 'react';
import webglWasmUrl from '../../../../packages/web/src/webgl/dotlottie-player.wasm?url';
import webgpuWasmUrl from '../../../../packages/web/src/webgpu/dotlottie-player.wasm?url';
import type { Renderer } from '../store/viewer-slice';
interface DotLottieGPUPlayerProps {
renderer: Exclude<Renderer, 'software'>;
src: string;
autoplay: boolean;
loop: boolean;
speed: number;
mode: Mode;
backgroundColor: string;
useFrameInterpolation: boolean;
animationId: string;
themeId: string;
marker: string;
segment: [number, number] | number[];
stateMachineId: string;
dotLottieRefCallback: (instance: DotLottie | null) => void;
}
async function createDotLottieInstance(
renderer: 'webgl' | 'webgpu',
config: Omit<DotLottieGPUPlayerProps, 'renderer' | 'dotLottieRefCallback'> & { canvas: HTMLCanvasElement },
): Promise<DotLottie> {
if (renderer === 'webgl') {
const { DotLottie: DotLottieWebGL } = await import('@lottiefiles/dotlottie-web/webgl');
DotLottieWebGL.setWasmUrl(webglWasmUrl);
return new DotLottieWebGL({
canvas: config.canvas,
src: config.src,
autoplay: config.autoplay,
loop: config.loop,
speed: config.speed,
mode: config.mode,
backgroundColor: config.backgroundColor,
useFrameInterpolation: config.useFrameInterpolation,
animationId: config.animationId || undefined,
themeId: config.themeId || undefined,
marker: config.marker || undefined,
segment: config.segment.length === 2 ? (config.segment as [number, number]) : undefined,
stateMachineId: config.stateMachineId || undefined,
}) as unknown as DotLottie;
}
const { DotLottie: DotLottieWebGPU } = await import('@lottiefiles/dotlottie-web/webgpu');
DotLottieWebGPU.setWasmUrl(webgpuWasmUrl);
return new DotLottieWebGPU({
canvas: config.canvas,
src: config.src,
autoplay: config.autoplay,
loop: config.loop,
speed: config.speed,
mode: config.mode,
backgroundColor: config.backgroundColor,
useFrameInterpolation: config.useFrameInterpolation,
animationId: config.animationId || undefined,
themeId: config.themeId || undefined,
marker: config.marker || undefined,
segment: config.segment.length === 2 ? (config.segment as [number, number]) : undefined,
stateMachineId: config.stateMachineId || undefined,
}) as unknown as DotLottie;
}
export default function DotLottieGPUPlayer({
renderer,
src,
autoplay,
loop,
speed,
mode,
backgroundColor,
useFrameInterpolation,
animationId,
themeId,
marker,
segment,
stateMachineId,
dotLottieRefCallback,
}: DotLottieGPUPlayerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const instanceRef = useRef<DotLottie | null>(null);
const rendererRef = useRef(renderer);
// Create / destroy the player when renderer or src changes
const initPlayer = useCallback(async () => {
// Destroy previous instance
if (instanceRef.current) {
try {
instanceRef.current.destroy();
} catch {
// WASM may throw during cleanup if GPU resources are already gone
}
instanceRef.current = null;
dotLottieRefCallback(null);
}
const canvas = canvasRef.current;
if (!canvas) return;
try {
const instance = await createDotLottieInstance(renderer, {
canvas,
src,
autoplay,
loop,
speed,
mode,
backgroundColor,
useFrameInterpolation,
animationId,
themeId,
marker,
segment,
stateMachineId,
});
// Guard against stale init (renderer changed while awaiting)
if (rendererRef.current !== renderer) {
instance.destroy();
return;
}
instanceRef.current = instance;
dotLottieRefCallback(instance);
} catch (error) {
console.error(`[DotLottieGPUPlayer] Failed to create ${renderer} player:`, error);
}
// Only re-create on renderer or src change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [renderer, src]);
useEffect(() => {
rendererRef.current = renderer;
initPlayer();
return () => {
if (instanceRef.current) {
try {
instanceRef.current.destroy();
} catch {
// WASM may throw during cleanup if GPU resources are already gone
}
instanceRef.current = null;
dotLottieRefCallback(null);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initPlayer]);
// Sync props to existing instance
useEffect(() => {
const instance = instanceRef.current;
if (!instance) return;
instance.setSpeed(speed);
}, [speed]);
useEffect(() => {
const instance = instanceRef.current;
if (!instance) return;
instance.setLoop(loop);
}, [loop]);
useEffect(() => {
const instance = instanceRef.current;
if (!instance) return;
instance.setMode(mode);
}, [mode]);
useEffect(() => {
const instance = instanceRef.current;
if (!instance) return;
instance.setUseFrameInterpolation(useFrameInterpolation);
}, [useFrameInterpolation]);
useEffect(() => {
const instance = instanceRef.current;
if (!instance) return;
instance.setBackgroundColor(backgroundColor);
}, [backgroundColor]);
useEffect(() => {
const instance = instanceRef.current;
if (!instance || !animationId) return;
instance.loadAnimation(animationId);
}, [animationId]);
useEffect(() => {
const instance = instanceRef.current;
if (!instance) return;
if (themeId) {
instance.setTheme(themeId);
} else {
instance.resetTheme();
}
}, [themeId]);
useEffect(() => {
const instance = instanceRef.current;
if (!instance || !marker) return;
instance.setMarker(marker);
}, [marker]);
useEffect(() => {
const instance = instanceRef.current;
if (!instance || segment.length !== 2) return;
instance.setSegment(segment[0]!, segment[1]!);
}, [segment]);
useEffect(() => {
const instance = instanceRef.current;
if (!instance) return;
if (stateMachineId) {
instance.stateMachineLoad(stateMachineId);
instance.stateMachineStart();
} else {
instance.stateMachineStop();
}
}, [stateMachineId]);
return <canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />;
}
import { DotLottiePlayer, type Props } from '@dotlottie/react-player';
export default function DotLottieNew(props: Props) {
return (
<div className="">
<DotLottiePlayer {...props} />
</div>
);
}
interface BaseButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
className?: string;
}
const BaseButton: React.FC<BaseButtonProps> = ({ className = '', children, ...props }) => {
// Combine the default classes with any classes passed via props
const combinedClassName = `text-white border bg-brand hover:bg-brand/50 px-2 py-2 ${className}`;
return (
<button className={combinedClassName} {...props}>
{children}
</button>
);
};
export default BaseButton;
import { useRef } from 'react';
interface ColorInputProps {
className?: string;
value: string;
onChange: (value: string) => void;
}
function invert(hex: string) {
hex = hex.replace('#', '');
// Convert hex to RGB
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// Invert each component by subtracting from 255
const rInv = (255 - r).toString(16).padStart(2, '0');
const gInv = (255 - g).toString(16).padStart(2, '0');
const bInv = (255 - b).toString(16).padStart(2, '0');
// Concatenate the inverted components and return
return `#${rInv}${gInv}${bInv}`;
}
const ColorInput: React.FC<ColorInputProps> = ({ className = '', onChange, value, ...props }) => {
const input = useRef<HTMLInputElement>(null);
return (
<button
onClick={() => {
input.current?.click();
}}
className={`border relative border-subtle rounded-lg bg-subtle w-full p-1 h-9 ${className}`}
{...props}
>
<div
className={`rounded-lg`}
style={{
backgroundColor: value,
}}
>
<span
style={{
color: invert(value),
}}
>
{value}
</span>
</div>
<input
onChange={(event) => onChange(event.target.value)}
ref={input}
type="color"
className="invisible absolute left-0 bottom-0"
defaultValue={value}
/>
</button>
);
};
export default ColorInput;
import { useEffect, useState } from 'react';
import { FaInfoCircle } from 'react-icons/fa';
interface BaseSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
className?: string;
items: { value: string; label: string }[];
emptyMessage?: string;
placeholder?: string;
}
const BaseSelect: React.FC<BaseSelectProps> = ({ className = '', items, placeholder, emptyMessage, ...props }) => {
// Combine the default classes with any classes passed via props
const combinedClassName = `border rounded-lg font-bold border-subtle dark:border-dark-border bg-subtle dark:bg-dark-surface text-primary dark:text-dark-text px-2 py-1 h-9 ${className}`;
const [allItems, setAllItems] = useState(() => {
if (placeholder) {
return [{ value: '', label: placeholder }, ...items];
} else {
return items;
}
});
useEffect(() => {
if (placeholder) {
setAllItems([{ value: '', label: placeholder }, ...items]);
} else {
setAllItems(items);
}
}, [items, placeholder]);
if (items.length === 0) {
return (
<span className={`min-h-9 text-gray-500 text-xs flex items-center gap-2 h-10`}>
<FaInfoCircle />
{emptyMessage}
</span>
);
}
return (
<select className={`${combinedClassName}`} {...props}>
{allItems.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
);
};
export default BaseSelect;
interface InputLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
className?: string;
lablel: React.ReactNode;
}
const InputLabel: React.FC<InputLabelProps> = ({ className = '', lablel, children, ...props }) => {
// Combine the default classes with any classes passed via props
const combinedClassName = `flex flex-col gap-1 w-full ${className}`;
return (
<div className={combinedClassName}>
<label className="text-sm font-bold" {...props}>
{lablel}
</label>
<div>{children}</div>
</div>
);
};
export default InputLabel;
import { getTrackBackground, Range } from 'react-range';
interface StepSelect {
className?: string;
values: number[];
min: number;
max: number;
allowOverlap?: boolean;
step: number;
onChange: (values: number[]) => void;
}
const StepSelect: React.FC<StepSelect> = ({
className = '',
min,
max,
step = 1,
allowOverlap = false,
values,
onChange,
...props
}) => {
return (
<div className={`flex gap-1 h-9 w-full ${className}`} {...props}>
<div className="flex items-center justify-center rounded-lg bg-subtle text-xs border border-subtle w-16 p-1 text-secondary">
{values.join(' - ')}
</div>
<Range
min={min}
max={max}
step={step}
allowOverlap={allowOverlap}
values={values}
onChange={(values) => {
onChange(values);
}}
renderTrack={({ props, children }) => (
<div
className="flex-grow flex bg-subtle border border-subtle overflow-hidden rounded-lg"
onMouseDown={(event) => {
props.onMouseDown(event);
}}
onTouchStart={(event) => {
props.onTouchStart(event);
}}
style={{
...props.style,
}}
>
<div
ref={props.ref}
style={{
background: getTrackBackground({
values: values,
colors: values.length === 2 ? ['#F3F6F8', '#80cec8', '#F3F6F8'] : ['#80cec8', '#F3F6F8'],
min,
max,
}),
}}
className="self-center w-full h-full"
>
{children}
</div>
</div>
)}
renderThumb={({ props }) => (
<div
{...props}
className="rounded-lg border border-subtle h-full w-5 bg-white hover:bg-hover"
style={{
...props.style,
}}
/>
)}
/>
</div>
);
};
export default StepSelect;
interface SwitchProps {
className?: string;
items: { label: string; value: string }[];
value: string;
onChange: (value: string) => void;
}
const Switch: React.FC<SwitchProps> = ({ className = '', onChange, items, value }) => {
return (
<div
className={`h-9 w-min p-1 text-sm flex justify-evenly gap-1 rounded-lg bg-subtle border border-subtle ${className}`}
>
{items.map((item) => (
<button
key={item.value}
className={`text-secondary flex justify-center items-center min-w-9 flex-1 p-2 rounded-lg border font-bold ${
value === item.value
? 'bg-white border-subtle'
: 'bg-subtle border-transparent hover:border-subtle text-tertiary'
}`}
onClick={() => {
onChange(item.value);
}}
>
{item.label}
</button>
))}
</div>
);
};
export default Switch;
interface LoadTimeProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
title: string;
version: string;
}
const LoadTime: React.FC<LoadTimeProps> = ({ className = '', title, version, ...props }) => {
return (
<div className={`flex flex-col items-center ${className}`} {...props}>
<div className="flex items-start">
<h6 className="text-xl font-bold mb-0">{title}</h6>
<span className="ml-1 text-xs text-secondary bg-strong p-0.5 px-1 rounded-lg">{version}</span>
</div>
</div>
);
};
export default LoadTime;
import { type DotLottieCommonPlayer, DotLottiePlayer } from '@dotlottie/react-player';
import {
type DotLottie,
DotLottieReact,
type RenderEvent,
setWasmUrl as setDotLottieWasmUrl,
} from '@lottiefiles/dotlottie-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FaPause, FaPlay } from 'react-icons/fa';
import { GiNextButton, GiPreviousButton } from 'react-icons/gi';
import { ImLoop } from 'react-icons/im';
import { getTrackBackground, Range } from 'react-range';
import dotLottieWasmUrl from '../../../../packages/web/src/core/dotlottie-player.wasm?url';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
setActiveAnimationId,
setAnimations,
setCurrentFrame,
setCurrentState,
setLoop,
setMarkers,
setStateMachines,
setThemes,
setTotalFrames,
} from '../store/viewer-slice';
import DotLottieCDNPlayer from './dotlottie-cdn-player';
import DotLottieGPUPlayer from './dotlottie-gpu-player';
import LoadTime from './load-time';
setDotLottieWasmUrl(dotLottieWasmUrl);
interface PlayersProps {
onDotLottieChange?: (dotLottie: DotLottie | null) => void;
}
export default function Players({ onDotLottieChange }: PlayersProps) {
const lottieWebRef = useRef<DotLottieCommonPlayer | null>(null);
const [dotLottie, setDotLottieState] = useState<DotLottie | null>(null);
const setDotLottie = useCallback(
(instance: DotLottie | null) => {
setDotLottieState(instance);
onDotLottieChange?.(instance);
},
[onDotLottieChange],
);
const src = useAppSelector((state) => state.viewer.src);
const backgroundColor = useAppSelector((state) => state.viewer.backgroundColor);
const speed = useAppSelector((state) => state.viewer.speed);
const autoplay = useAppSelector((state) => state.viewer.autoplay);
const loop = useAppSelector((state) => state.viewer.loop);
const totalFrames = useAppSelector((state) => state.viewer.totalFrames);
const currentFrame = useAppSelector((state) => state.viewer.currentFrame);
const currentState = useAppSelector((state) => state.viewer.currentState);
const mode = useAppSelector((state) => state.viewer.mode);
const activeAnimationId = useAppSelector((state) => state.viewer.activeAnimationId);
const activeThemeId = useAppSelector((state) => state.viewer.activeThemeId);
const isJson = useAppSelector((state) => state.viewer.isJson);
const animations = useAppSelector((state) => state.viewer.animations);
const segment = useAppSelector((state) => state.viewer.segment);
const useFrameInterpolation = useAppSelector((state) => state.viewer.useFrameInterpolation);
const activeMarker = useAppSelector((state) => state.viewer.activeMarker);
const activeStateMachineId = useAppSelector((state) => state.viewer.activeStateMachineId);
const renderer = useAppSelector((state) => state.viewer.renderer);
const showLottieWeb = useAppSelector((state) => state.viewer.showLottieWeb);
const version = useAppSelector((state) => state.viewer.version);
const availableVersions = useAppSelector((state) => state.viewer.availableVersions);
const dispatch = useAppDispatch();
const coreVersion = availableVersions.find((v) => v.reactVersion === version)?.coreVersion;
const onLoad = useCallback(() => {
dispatch(setTotalFrames(dotLottie?.totalFrames));
if (!src.endsWith('.json') && !src.startsWith('data:application/json')) {
if (!activeAnimationId) {
dispatch(setActiveAnimationId(dotLottie?.manifest?.animations?.[0]?.id || ''));
}
dispatch(setAnimations(dotLottie?.manifest?.animations?.map((item) => item.id) || []));
dispatch(setThemes(dotLottie?.manifest?.themes || []));
dispatch(setStateMachines(dotLottie?.manifest?.stateMachines || []));
dispatch(setMarkers(dotLottie?.markers()?.map((marker) => marker.name) || []));
}
}, [src, dotLottie, dispatch, activeAnimationId]);
const onRender = useCallback(
({ currentFrame }: RenderEvent) => {
dispatch(setCurrentFrame(currentFrame));
},
[dispatch],
);
const onPlay = useCallback(() => {
dispatch(setCurrentState('playing'));
}, [dispatch]);
const onStop = useCallback(() => {
dispatch(setCurrentState('stopped'));
}, [dispatch]);
const onPause = useCallback(() => {
dispatch(setCurrentState('paused'));
}, [dispatch]);
const getNext = useCallback(() => {
const currentIndex = animations.indexOf(activeAnimationId);
if (currentIndex === -1) return undefined;
const nextIndex = (currentIndex + 1) % animations.length;
return animations[nextIndex];
}, [animations, activeAnimationId]);
const getPrevious = useCallback(() => {
const currentIndex = animations.indexOf(activeAnimationId);
if (currentIndex === -1) return undefined; // or handle error
const prevIndex = (currentIndex - 1 + animations.length) % animations.length;
return animations[prevIndex];
}, [animations, activeAnimationId]);
useEffect(() => {
if (!dotLottie) return;
dotLottie.addEventListener('load', onLoad);
dotLottie.addEventListener('render', onRender);
dotLottie.addEventListener('complete', onStop);
dotLottie.addEventListener('stop', onStop);
dotLottie.addEventListener('play', onPlay);
dotLottie.addEventListener('pause', onPause);
return () => {
dotLottie.removeEventListener('load', onLoad);
dotLottie.removeEventListener('render', onRender);
dotLottie.removeEventListener('complete', onStop);
dotLottie.removeEventListener('stop', onStop);
dotLottie.removeEventListener('play', onPlay);
dotLottie.removeEventListener('pause', onPause);
};
}, [dotLottie, onLoad, onRender, onStop, onPlay, onPause]);
useEffect(() => {
if (!dotLottie) return;
dispatch(setAnimations(dotLottie?.manifest?.animations?.map((item) => item.id) || []));
dispatch(setThemes(dotLottie?.manifest?.themes || []));
dispatch(setStateMachines(dotLottie?.manifest?.stateMachines || []));
}, [dotLottie, dispatch]);
return (
<div className="flex flex-col items-center justify-between flex-grow h-full gap-4">
<div className="flex justify-center h-full">
<div className="flex flex-col dotlottie-player">
<LoadTime
version={version === 'local' ? 'dev' : (coreVersion ?? version)}
className="mb-4"
title="dotLottie Web"
/>
<div className="flex items-center justify-center flex-grow p-4">
<div style={{ width: '350px', height: '350px' }}>
{version !== 'local' ? (
<DotLottieCDNPlayer
key={version}
version={coreVersion ?? version}
src={src}
autoplay={autoplay}
loop={loop}
speed={speed}
mode={mode}
backgroundColor={backgroundColor}
useFrameInterpolation={useFrameInterpolation}
animationId={activeAnimationId}
themeId={activeThemeId}
marker={activeMarker}
segment={segment}
stateMachineId={activeStateMachineId}
dotLottieRefCallback={setDotLottie}
/>
) : renderer === 'software' ? (
<DotLottieReact
backgroundColor={backgroundColor}
width={350}
height={350}
autoplay={autoplay}
useFrameInterpolation={useFrameInterpolation}
loop={loop}
mode={mode}
speed={speed}
themeId={activeThemeId}
animationId={activeAnimationId}
stateMachineId={activeStateMachineId}
segment={segment as [number, number]}
marker={activeMarker}
dotLottieRefCallback={setDotLottie}
src={src}
/>
) : (
<DotLottieGPUPlayer
key={renderer}
renderer={renderer}
src={src}
autoplay={autoplay}
loop={loop}
speed={speed}
mode={mode}
backgroundColor={backgroundColor}
useFrameInterpolation={useFrameInterpolation}
animationId={activeAnimationId}
themeId={activeThemeId}
marker={activeMarker}
segment={segment}
stateMachineId={activeStateMachineId}
dotLottieRefCallback={setDotLottie}
/>
)}
</div>
</div>
</div>
{isJson && showLottieWeb ? (
<div className="flex flex-col lottie-web">
<LoadTime version="v5.12.2" className="mb-4" title="Lottie Web" />
<div className="flex items-center justify-center flex-grow p-4">
<div style={{ width: '350px', height: '350px' }}>
<DotLottiePlayer
lottieRef={(ref) => {
lottieWebRef.current = ref;
}}
background={backgroundColor}
autoplay={autoplay}
loop={loop}
speed={speed}
src={src}
/>
</div>
</div>
</div>
) : null}
</div>
<div className="flex items-center gap-4 w-full max-w-[720px]">
{animations.length > 1 ? (
<button
onClick={() => {
const next = getPrevious();
if (next) {
dispatch(setActiveAnimationId(next));
}
}}
>
<GiPreviousButton />
</button>
) : null}
{currentState !== 'playing' ? (
<button
onClick={() => {
dotLottie?.play();
lottieWebRef.current?.play();
}}
>
<FaPlay />
</button>
) : (
<button
onClick={() => {
dotLottie?.pause();
lottieWebRef.current?.pause();
}}
>
<FaPause />
</button>
)}
{animations.length > 1 ? (
<button
onClick={() => {
const next = getNext();
if (next) {
dispatch(setActiveAnimationId(next));
}
}}
>
<GiNextButton />
</button>
) : null}
<Range
min={0}
max={totalFrames || 1}
values={[currentFrame]}
onChange={(values) => {
dotLottie?.setFrame(values[0]);
lottieWebRef.current?.seek(values[0]);
}}
renderTrack={({ props, children }) => (
<div
onMouseDown={(event) => {
dotLottie?.pause();
lottieWebRef.current?.pause();
props.onMouseDown(event);
}}
onTouchStart={(event) => {
dotLottie?.pause();
lottieWebRef.current?.pause();
props.onTouchStart(event);
}}
className="flex-grow w-full flex h-[20px]"
style={{
...props.style,
}}
>
<div
ref={props.ref}
className="self-center w-full h-[6px] bg-strong rounded-lg"
style={{
background: getTrackBackground({
values: [currentFrame],
colors: ['#80cec8', '#ccc'],
min: 0,
max: totalFrames,
}),
}}
>
{children}
</div>
</div>
)}
renderThumb={({ props: { key, ...thumbProps } }) => (
<div
key={key}
{...thumbProps}
style={{
...thumbProps.style,
height: '20px',
width: '20px',
backgroundColor: '#019D91',
borderRadius: '50%',
}}
/>
)}
/>
<span className="flex items-center justify-center p-2 text-sm text-center bg-white rounded-lg">
<span className="relative flex pr-1 text-right bg-transparent w-min">
<span className="invisible">{totalFrames.toFixed(2)}</span>
<span className="absolute self-center">{currentFrame.toFixed(2)}</span>
</span>
<span className="text-xs text-secondary">of</span>
<span className="pl-1 bg-transparent w-max">{totalFrames}</span>
</span>
<button className="cursor-pointer" onClick={() => dispatch(setLoop(!loop))}>
<ImLoop className={`${!loop ? 'text-gray-500' : ''}`} />
</button>
</div>
</div>
);
}
import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
import { keymap } from '@codemirror/view';
import { githubLight } from '@uiw/codemirror-theme-github';
import { basicSetup, EditorView } from 'codemirror';
import { useEffect, useRef } from 'react';
import { type ResolvedTheme, useTheme } from '../../context/theme-context';
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
onRun?: () => void;
}
function getThemeExtension(resolvedTheme: ResolvedTheme) {
return resolvedTheme === 'dark' ? oneDark : githubLight;
}
export const CodeEditor: React.FC<CodeEditorProps> = ({ value, onChange, onRun }) => {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorView | null>(null);
const isExternalChange = useRef(false);
const { resolvedTheme } = useTheme();
// Use refs for callbacks to avoid recreating the editor on every keystroke
const onRunRef = useRef(onRun);
onRunRef.current = onRun;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (!containerRef.current) return;
const runKeymap = keymap.of([
{
key: 'Mod-Enter',
run: () => {
onRunRef.current?.();
return true;
},
},
]);
const editor = new EditorView({
doc: value,
extensions: [
basicSetup,
javascript(),
getThemeExtension(resolvedTheme),
runKeymap,
EditorView.updateListener.of((update) => {
if (update.docChanged && !isExternalChange.current) {
onChangeRef.current(update.state.doc.toString());
}
}),
EditorView.theme({
'&': { height: '100%' },
'.cm-scroller': { overflow: 'auto' },
}),
],
parent: containerRef.current,
});
editorRef.current = editor;
return () => {
editor.destroy();
editorRef.current = null;
};
// Only recreate editor when theme changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolvedTheme]);
// Sync external value changes to editor
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const currentContent = editor.state.doc.toString();
if (currentContent !== value) {
isExternalChange.current = true;
editor.dispatch({
changes: {
from: 0,
to: currentContent.length,
insert: value,
},
});
isExternalChange.current = false;
}
}, [value]);
return <div ref={containerRef} className="w-full h-full overflow-hidden" />;
};
import { useEffect, useRef } from 'react';
export interface ConsoleMessage {
id: number;
method: 'log' | 'warn' | 'error' | 'info';
args: string[];
timestamp: number;
}
interface ConsolePanelProps {
messages: ConsoleMessage[];
onClear: () => void;
isOpen: boolean;
onToggle: () => void;
}
const methodStyles: Record<ConsoleMessage['method'], string> = {
log: 'text-gray-800 dark:text-gray-200',
warn: 'text-yellow-800 dark:text-yellow-200 bg-yellow-50 dark:bg-yellow-900/30',
error: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/30',
info: 'text-blue-700 dark:text-blue-300',
};
const methodLabels: Record<ConsoleMessage['method'], string | null> = {
log: null,
warn: 'warn',
error: 'error',
info: 'info',
};
export const ConsolePanel: React.FC<ConsolePanelProps> = ({ messages, onClear, isOpen, onToggle }) => {
const listRef = useRef<HTMLDivElement>(null);
const shouldAutoScroll = useRef(true);
// Track whether user has scrolled up
const handleScroll = () => {
const el = listRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
shouldAutoScroll.current = distanceFromBottom < 30;
};
// Auto-scroll to bottom on new messages
useEffect(() => {
if (shouldAutoScroll.current && listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, []);
return (
<div className="border-t border-subtle dark:border-dark-border flex flex-col bg-white dark:bg-dark-bg">
{/* Header bar — always visible */}
<button
onClick={onToggle}
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-secondary dark:text-dark-muted hover:bg-subtle dark:hover:bg-dark-surface transition-colors select-none w-full"
>
<svg
className={`w-3.5 h-3.5 transition-transform ${isOpen ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span>Console</span>
{messages.length > 0 && (
<span className="ml-auto mr-1 px-1.5 py-0.5 rounded-full text-xs bg-gray-200 dark:bg-dark-border text-secondary dark:text-dark-muted tabular-nums">
{messages.length}
</span>
)}
{messages.length > 0 && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onClear();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
onClear();
}
}}
className="p-0.5 rounded hover:bg-gray-300 dark:hover:bg-dark-border transition-colors"
title="Clear console"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
</span>
)}
</button>
{/* Message list */}
{isOpen && (
<div
ref={listRef}
onScroll={handleScroll}
className="h-[200px] overflow-y-auto font-mono text-sm border-t border-subtle dark:border-dark-border"
>
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-secondary dark:text-dark-muted text-xs">
Console output will appear here
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`px-3 py-1 border-b border-gray-100 dark:border-dark-border/50 whitespace-pre-wrap break-all ${
methodStyles[msg.method]
}`}
>
{methodLabels[msg.method] && <span className="opacity-60 mr-2">[{methodLabels[msg.method]}]</span>}
{msg.args.join(' ')}
</div>
))
)}
</div>
)}
</div>
);
};
interface ErrorDisplayProps {
error: string;
onDismiss: () => void;
}
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onDismiss }) => {
return (
<div className="absolute bottom-0 left-0 right-0 bg-red-600 text-white p-3 flex items-start gap-2">
<div className="flex-1 font-mono text-sm whitespace-pre-wrap">{error}</div>
<button onClick={onDismiss} className="text-white hover:text-red-200 font-bold px-2" aria-label="Dismiss error">
×
</button>
</div>
);
};
import { type PlaygroundExample, playgroundExamples } from '../../data/playground-examples';
import BaseSelect from '../form/base-select';
interface ExampleSelectorProps {
selectedId: string;
onSelect: (example: PlaygroundExample) => void;
}
export const ExampleSelector: React.FC<ExampleSelectorProps> = ({ selectedId, onSelect }) => {
const items = playgroundExamples.map((example) => ({
value: example.id,
label: example.name,
}));
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const example = playgroundExamples.find((ex) => ex.id === e.target.value);
if (example) {
onSelect(example);
}
};
return <BaseSelect items={items} value={selectedId} onChange={handleChange} className="min-w-40" />;
};
import { useEffect, useRef, useState } from 'react';
import { type ResolvedTheme, useTheme } from '../../context/theme-context';
interface PreviewIframeProps {
code: string;
onError: (error: string | null) => void;
onConsole?: (method: string, args: string[]) => void;
onConsoleClear?: () => void;
}
function generateSrcdoc(code: string, theme: ResolvedTheme): string {
const bgColor = theme === 'dark' ? '#1e1e1e' : '#f5f5f5';
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
background: ${bgColor};
display: flex;
align-items: center;
justify-content: center;
}
canvas {
max-width: 100%;
max-height: 100%;
}
</style>
</head>
<body>
<script type="module">
// Error handling
window.onerror = (message, source, lineno, colno, error) => {
window.parent.postMessage({
type: 'error',
message: error?.message || String(message),
line: lineno
}, '*');
return true;
};
window.onunhandledrejection = (event) => {
window.parent.postMessage({
type: 'error',
message: 'Unhandled Promise rejection: ' + (event.reason?.message || String(event.reason))
}, '*');
};
// Console interception
(function() {
function serialize(arg) {
if (arg instanceof Error) return arg.stack || arg.message;
if (typeof arg === 'object' && arg !== null) {
try { return JSON.stringify(arg, null, 2); } catch { return String(arg); }
}
return String(arg);
}
['log', 'warn', 'error', 'info'].forEach(function(method) {
const orig = console[method].bind(console);
console[method] = function(...args) {
orig(...args);
window.parent.postMessage({
type: 'console',
method: method,
args: args.map(serialize)
}, '*');
};
});
const origClear = console.clear.bind(console);
console.clear = function() {
origClear();
window.parent.postMessage({ type: 'console-clear' }, '*');
};
})();
// Clear any previous error
window.parent.postMessage({ type: 'clear-error' }, '*');
</script>
<script type="module">
// User code runs here (separate module to allow top-level imports)
${code}
</script>
</body>
</html>`;
}
export const PreviewIframe: React.FC<PreviewIframeProps> = ({ code, onError, onConsole, onConsoleClear }) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [key, setKey] = useState(0);
const { resolvedTheme } = useTheme();
const isInitialMount = useRef(true);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'error') {
onError(event.data.message);
} else if (event.data?.type === 'clear-error') {
onError(null);
} else if (event.data?.type === 'console') {
onConsole?.(event.data.method, event.data.args);
} else if (event.data?.type === 'console-clear') {
onConsoleClear?.();
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [onError, onConsole, onConsoleClear]);
// Force iframe reload when code or theme changes (skip initial mount)
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
setKey((prev) => prev + 1);
}, []);
return (
<iframe
key={key}
ref={iframeRef}
sandbox="allow-scripts allow-same-origin"
srcDoc={generateSrcdoc(code, resolvedTheme)}
title="Preview"
className="w-full h-full border-0 bg-[#f5f5f5] dark:bg-dark-bg"
/>
);
};
import { useCallback, useEffect, useRef, useState } from 'react';
interface ResizableSplitProps {
left: React.ReactNode;
right: React.ReactNode;
initialLeftPercent?: number;
minLeftPercent?: number;
maxLeftPercent?: number;
}
export const ResizableSplit: React.FC<ResizableSplitProps> = ({
left,
right,
initialLeftPercent = 50,
minLeftPercent = 20,
maxLeftPercent = 80,
}) => {
const [leftPercent, setLeftPercent] = useState(initialLeftPercent);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = (x / rect.width) * 100;
// Clamp between min and max
const clamped = Math.min(maxLeftPercent, Math.max(minLeftPercent, percent));
setLeftPercent(clamped);
},
[isDragging, minLeftPercent, maxLeftPercent],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isDragging, handleMouseMove, handleMouseUp]);
return (
<div ref={containerRef} className="flex h-full w-full relative">
{/* Overlay to capture mouse events during drag (prevents iframe from stealing events) */}
{isDragging && <div className="absolute inset-0 z-50" />}
{/* Left panel */}
<div style={{ width: `calc(${leftPercent}% - 4px)` }} className="min-w-0 h-full overflow-hidden flex-shrink-0">
{left}
</div>
{/* Resize handle */}
<div
onMouseDown={handleMouseDown}
className={`w-2 h-full cursor-col-resize flex-shrink-0 transition-colors hover:bg-lottie/50 ${
isDragging ? 'bg-lottie' : 'bg-subtle dark:bg-dark-border'
}`}
/>
{/* Right panel */}
<div className="flex-1 min-w-0 h-full overflow-hidden">{right}</div>
</div>
);
};
import type { DotLottie } from '@lottiefiles/dotlottie-react';
import logo from '../assets/brand-logo.svg';
import AnimationList from './animation-list';
import SlotController from './slot-controller';
interface SidePanelProps {
dotLottie?: DotLottie | null;
}
export default function SidePanel({ dotLottie }: SidePanelProps) {
return (
<section className="flex flex-col h-full gap-4 p-4">
<a href="/">
<img src={logo} alt="logo" />
</a>
<div className="flex flex-col flex-grow gap-4 overflow-auto">
<SlotController dotLottie={dotLottie ?? null} />
<AnimationList />
</div>
</section>
);
}
import type { CanvasKit } from 'canvaskit-wasm';
import { useEffect, useRef, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
let canvasKit: CanvasKit | undefined;
export const setCanvasKit = (ck: CanvasKit) => {
if (canvasKit) {
return;
}
canvasKit = ck;
};
interface Props {
lottieURL: string;
width: number;
height: number;
}
export function Skottie({ lottieURL, width, height }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [id, setId] = useState<string>('');
let initialized = false;
const load = async () => {
if (initialized || !canvasKit) {
return;
}
initialized = true;
const id = uuidv4();
setId(id);
const dpr = window.devicePixelRatio || 1;
const data = await fetch(lottieURL);
const lottieJSON = await data.text();
const animation = canvasKit.MakeManagedAnimation(lottieJSON);
const bounds = canvasKit!.LTRBRect(0, 0, width * dpr, height * dpr);
canvasRef.current!.width = width * dpr;
canvasRef.current!.height = height * dpr;
let beginTime = Date.now() / 1000;
const surface = canvasKit!.MakeSWCanvasSurface(id);
const canvas = surface!.getCanvas();
const damageRect = Float32Array.of(0, 0, 0, 0);
const clearColor = canvasKit.TRANSPARENT;
function drawFrame() {
const currentTime = Date.now() / 1000;
let currentFrame = (currentTime - beginTime) / animation.duration();
if (currentFrame > 1) {
currentFrame = 0;
beginTime = currentTime;
}
const damage = animation.seek(currentFrame, damageRect);
if (damage[2] > damage[0] && damage[3] > damage[1]) {
canvas.clear(clearColor);
animation.render(canvas, bounds);
surface?.flush();
}
window.requestAnimationFrame(drawFrame);
}
window.requestAnimationFrame(drawFrame);
};
useEffect(() => {
load();
}, [load]);
return <canvas id={id} ref={canvasRef} style={{ width, height }} />;
}
import type { DotLottie } from '@lottiefiles/dotlottie-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FaAlignCenter, FaAlignLeft, FaAlignRight } from 'react-icons/fa';
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
import InputLabel from './form/input-label';
interface SlotControllerProps {
dotLottie: DotLottie | null;
}
interface SlotInfo {
id: string;
type: string;
}
// Collapsible section component
function CollapsibleSection({
title,
children,
defaultOpen = true,
}: {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="overflow-hidden border rounded-lg border-subtle">
<button
className="flex items-center justify-between w-full p-3 transition-colors bg-subtle hover:bg-subtle/60"
onClick={() => setIsOpen(!isOpen)}
>
<span className="text-sm font-bold">{title}</span>
{isOpen ? <IoChevronUp /> : <IoChevronDown />}
</button>
{isOpen && <div className="flex flex-col gap-3 p-3 bg-white">{children}</div>}
</div>
);
}
// Color slot input component
function ColorSlotInput({ slotId, dotLottie }: { slotId: string; dotLottie: DotLottie }) {
const inputRef = useRef<HTMLInputElement>(null);
const [color, setColor] = useState('#000000');
useEffect(() => {
const slotValue = dotLottie.getSlot(slotId) as { k?: number[] } | undefined;
if (slotValue?.k && Array.isArray(slotValue.k) && slotValue.k.length >= 3) {
// Convert RGBA (0-1) to hex
const r = Math.round((slotValue.k[0] ?? 0) * 255);
const g = Math.round((slotValue.k[1] ?? 0) * 255);
const b = Math.round((slotValue.k[2] ?? 0) * 255);
const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b
.toString(16)
.padStart(2, '0')}`;
setColor(hex);
}
}, [dotLottie, slotId]);
const handleChange = (newColor: string) => {
setColor(newColor);
// Convert hex to RGBA (0-1)
const hex = newColor.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
dotLottie.setColorSlot(slotId, [r, g, b, 1]);
};
const invertColor = (hex: string) => {
hex = hex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const rInv = (255 - r).toString(16).padStart(2, '0');
const gInv = (255 - g).toString(16).padStart(2, '0');
const bInv = (255 - b).toString(16).padStart(2, '0');
return `#${rInv}${gInv}${bInv}`;
};
return (
<InputLabel lablel={slotId}>
<button
onClick={() => inputRef.current?.click()}
className="relative w-full p-1 border rounded-lg border-subtle bg-subtle h-9"
>
<div className="flex items-center justify-center h-full rounded-lg" style={{ backgroundColor: color }}>
<span style={{ color: invertColor(color) }}>{color}</span>
</div>
<input
ref={inputRef}
type="color"
className="absolute bottom-0 left-0 invisible"
value={color}
onChange={(e) => handleChange(e.target.value)}
/>
</button>
</InputLabel>
);
}
// Gradient slot input component (simplified: start and end colors)
function GradientSlotInput({ slotId, dotLottie }: { slotId: string; dotLottie: DotLottie }) {
const startInputRef = useRef<HTMLInputElement>(null);
const endInputRef = useRef<HTMLInputElement>(null);
const [startColor, setStartColor] = useState('#000000');
const [endColor, setEndColor] = useState('#ffffff');
useEffect(() => {
const slotValue = dotLottie.getSlot(slotId) as { k?: number[] } | undefined;
// Gradient format: [offset, r, g, b, offset, r, g, b, ...]
if (slotValue?.k && Array.isArray(slotValue.k) && slotValue.k.length >= 8) {
// First color stop (offset, r, g, b)
const r1 = Math.round((slotValue.k[1] ?? 0) * 255);
const g1 = Math.round((slotValue.k[2] ?? 0) * 255);
const b1 = Math.round((slotValue.k[3] ?? 0) * 255);
setStartColor(
`#${r1.toString(16).padStart(2, '0')}${g1.toString(16).padStart(2, '0')}${b1.toString(16).padStart(2, '0')}`,
);
// Second color stop
const r2 = Math.round((slotValue.k[5] ?? 0) * 255);
const g2 = Math.round((slotValue.k[6] ?? 0) * 255);
const b2 = Math.round((slotValue.k[7] ?? 0) * 255);
setEndColor(
`#${r2.toString(16).padStart(2, '0')}${g2.toString(16).padStart(2, '0')}${b2.toString(16).padStart(2, '0')}`,
);
}
}, [dotLottie, slotId]);
const applyGradient = (start: string, end: string) => {
const hexToRgb = (hex: string) => {
hex = hex.replace('#', '');
return [
parseInt(hex.substring(0, 2), 16) / 255,
parseInt(hex.substring(2, 4), 16) / 255,
parseInt(hex.substring(4, 6), 16) / 255,
];
};
const [r1, g1, b1] = hexToRgb(start);
const [r2, g2, b2] = hexToRgb(end);
// Format: [color stops..., opacity stops...] with 2 color stops + 2 opacity stops
dotLottie.setGradientSlot(slotId, [0, r1, g1, b1, 1, r2, g2, b2, 0, 1, 1, 1], 2);
};
return (
<InputLabel lablel={slotId}>
<div className="flex items-center gap-2">
<button
onClick={() => startInputRef.current?.click()}
className="flex-shrink-0 w-12 p-1 border rounded-lg border-subtle h-9"
style={{ backgroundColor: startColor }}
>
<input
ref={startInputRef}
type="color"
className="absolute invisible"
value={startColor}
onChange={(e) => {
setStartColor(e.target.value);
applyGradient(e.target.value, endColor);
}}
/>
</button>
<div
className="flex-grow h-6 rounded"
style={{ background: `linear-gradient(to right, ${startColor}, ${endColor})` }}
/>
<button
onClick={() => endInputRef.current?.click()}
className="flex-shrink-0 w-12 p-1 border rounded-lg border-subtle h-9"
style={{ backgroundColor: endColor }}
>
<input
ref={endInputRef}
type="color"
className="absolute invisible"
value={endColor}
onChange={(e) => {
setEndColor(e.target.value);
applyGradient(startColor, e.target.value);
}}
/>
</button>
</div>
</InputLabel>
);
}
// Text alignment button group component
function TextAlignmentInput({ value, onChange }: { value: 0 | 1 | 2; onChange: (justify: 0 | 1 | 2) => void }) {
const options: Array<{ value: 0 | 1 | 2; icon: React.ReactNode; label: string }> = [
{ value: 0, icon: <FaAlignLeft size={10} />, label: 'Left' },
{ value: 2, icon: <FaAlignCenter size={10} />, label: 'Center' },
{ value: 1, icon: <FaAlignRight size={10} />, label: 'Right' },
];
return (
<div className="flex overflow-hidden border rounded-lg shrink-0 border-subtle">
{options.map((opt) => (
<button
key={opt.value}
title={opt.label}
className={`flex items-center justify-center w-7 h-7 transition-colors ${
value === opt.value ? 'bg-lottie text-white' : 'bg-subtle hover:bg-subtle/60 text-secondary'
}`}
onClick={() => onChange(opt.value)}
>
{opt.icon}
</button>
))}
</div>
);
}
// Text slot input component with alignment and font size controls
function TextSlotInput({ slotId, dotLottie }: { slotId: string; dotLottie: DotLottie }) {
const [text, setText] = useState('');
const [fontSize, setFontSize] = useState<number | undefined>(undefined);
const [justify, setJustify] = useState<0 | 1 | 2>(0);
useEffect(() => {
const slotValue = dotLottie.getSlot(slotId) as
| { k?: Array<{ s?: { t?: string; s?: number; j?: 0 | 1 | 2 } }> }
| undefined;
const textDoc = slotValue?.k?.[0]?.s;
if (textDoc) {
if (textDoc.t) setText(textDoc.t);
if (textDoc.s !== undefined) setFontSize(textDoc.s);
if (textDoc.j !== undefined) setJustify(textDoc.j);
}
}, [dotLottie, slotId]);
const handleTextChange = (newText: string) => {
setText(newText);
dotLottie.setTextSlot(slotId, { t: newText });
};
const handleFontSizeChange = (newSize: number) => {
setFontSize(newSize);
dotLottie.setTextSlot(slotId, { s: newSize });
};
const handleJustifyChange = (newJustify: 0 | 1 | 2) => {
setJustify(newJustify);
dotLottie.setTextSlot(slotId, { j: newJustify });
};
return (
<InputLabel lablel={slotId}>
<div className="flex flex-col gap-2">
<input
type="text"
className="w-full p-2 text-sm border rounded-lg border-subtle bg-subtle h-9"
value={text}
onChange={(e) => handleTextChange(e.target.value)}
placeholder="Enter text..."
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 min-w-0">
<span className="text-xs shrink-0 text-secondary">Size</span>
<input
type="number"
min={1}
step={1}
className="w-16 p-1 text-sm border rounded-lg border-subtle bg-subtle h-7"
value={fontSize ?? ''}
onChange={(e) => {
const val = parseFloat(e.target.value);
if (!Number.isNaN(val) && val > 0) handleFontSizeChange(val);
}}
placeholder="px"
/>
</div>
<TextAlignmentInput value={justify} onChange={handleJustifyChange} />
</div>
</div>
</InputLabel>
);
}
// Vector slot input component
function VectorSlotInput({ slotId, dotLottie }: { slotId: string; dotLottie: DotLottie }) {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
const slotValue = dotLottie.getSlot(slotId) as { k?: number[] } | undefined;
if (slotValue?.k && Array.isArray(slotValue.k) && slotValue.k.length >= 2) {
setX(slotValue.k[0] ?? 0);
setY(slotValue.k[1] ?? 0);
}
}, [dotLottie, slotId]);
const handleChange = (newX: number, newY: number) => {
setX(newX);
setY(newY);
dotLottie.setVectorSlot(slotId, [newX, newY]);
};
return (
<InputLabel lablel={slotId}>
<div className="flex gap-2">
<div className="flex items-center flex-1 gap-1">
<span className="text-xs text-secondary">X</span>
<input
type="number"
className="w-full p-2 text-sm border rounded-lg border-subtle bg-subtle h-9"
value={x}
onChange={(e) => handleChange(parseFloat(e.target.value) || 0, y)}
/>
</div>
<div className="flex items-center flex-1 gap-1">
<span className="text-xs text-secondary">Y</span>
<input
type="number"
className="w-full p-2 text-sm border rounded-lg border-subtle bg-subtle h-9"
value={y}
onChange={(e) => handleChange(x, parseFloat(e.target.value) || 0)}
/>
</div>
</div>
</InputLabel>
);
}
// Scalar slot input component (single numeric value like rotation, opacity, stroke width)
function ScalarSlotInput({ slotId, dotLottie }: { slotId: string; dotLottie: DotLottie }) {
const [value, setValue] = useState(0);
useEffect(() => {
const slotValue = dotLottie.getSlot(slotId) as { k?: number } | undefined;
if (slotValue?.k !== undefined && typeof slotValue.k === 'number') {
setValue(slotValue.k);
}
}, [dotLottie, slotId]);
const handleChange = (newValue: number) => {
setValue(newValue);
dotLottie.setScalarSlot(slotId, newValue);
};
return (
<InputLabel lablel={slotId}>
<input
type="number"
step="any"
className="w-full p-2 text-sm border rounded-lg border-subtle bg-subtle h-9"
value={value}
onChange={(e) => handleChange(parseFloat(e.target.value) || 0)}
/>
</InputLabel>
);
}
export default function SlotController({ dotLottie }: SlotControllerProps) {
const [slots, setSlots] = useState<SlotInfo[]>([]);
const [isExpanded, setIsExpanded] = useState(true);
const loadSlots = useCallback(() => {
if (!dotLottie || !dotLottie.isLoaded) {
setSlots([]);
return;
}
try {
const slotIds = dotLottie.getSlotIds();
const slotInfos: SlotInfo[] = slotIds.map((id) => ({
id,
type: dotLottie.getSlotType(id) || 'unknown',
}));
setSlots(slotInfos);
} catch {
// ignore
}
}, [dotLottie]);
useEffect(() => {
if (!dotLottie) {
return;
}
// Load slots when animation loads
const onLoad = () => {
loadSlots();
};
dotLottie.addEventListener('load', onLoad);
// Also load immediately if already loaded
if (dotLottie.isLoaded) {
loadSlots();
}
return () => {
dotLottie.removeEventListener('load', onLoad);
};
}, [dotLottie, loadSlots]);
const colorSlots = slots.filter((s) => s.type === 'color');
const gradientSlots = slots.filter((s) => s.type === 'gradient');
const textSlots = slots.filter((s) => s.type === 'text');
const vectorSlots = slots.filter((s) => s.type === 'vector' || s.type === 'position');
const scalarSlots = slots.filter((s) => s.type === 'scalar');
const hasSlots = slots.length > 0;
// Only show if we have a dotLottie instance and slots to display
if (!dotLottie) {
return null;
}
if (!hasSlots) {
return null;
}
return (
<div className="bg-white border rounded-lg border-subtle">
<button
className="flex items-center justify-between w-full p-3 text-white transition-colors rounded-t-lg bg-lottie hover:bg-lottie/90"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="text-sm font-bold">Slot Controls ({slots.length})</span>
{isExpanded ? <IoChevronUp /> : <IoChevronDown />}
</button>
{isExpanded && (
<div className="flex flex-col gap-3 p-3 border-t border-subtle">
{colorSlots.length > 0 && (
<CollapsibleSection title={`Colors (${colorSlots.length})`}>
{colorSlots.map((slot) => (
<ColorSlotInput key={slot.id} slotId={slot.id} dotLottie={dotLottie!} />
))}
</CollapsibleSection>
)}
{gradientSlots.length > 0 && (
<CollapsibleSection title={`Gradients (${gradientSlots.length})`}>
{gradientSlots.map((slot) => (
<GradientSlotInput key={slot.id} slotId={slot.id} dotLottie={dotLottie!} />
))}
</CollapsibleSection>
)}
{textSlots.length > 0 && (
<CollapsibleSection title={`Text (${textSlots.length})`}>
{textSlots.map((slot) => (
<TextSlotInput key={slot.id} slotId={slot.id} dotLottie={dotLottie!} />
))}
</CollapsibleSection>
)}
{vectorSlots.length > 0 && (
<CollapsibleSection title={`Vectors (${vectorSlots.length})`}>
{vectorSlots.map((slot) => (
<VectorSlotInput key={slot.id} slotId={slot.id} dotLottie={dotLottie!} />
))}
</CollapsibleSection>
)}
{scalarSlots.length > 0 && (
<CollapsibleSection title={`Scalars (${scalarSlots.length})`}>
{scalarSlots.map((slot) => (
<ScalarSlotInput key={slot.id} slotId={slot.id} dotLottie={dotLottie!} />
))}
</CollapsibleSection>
)}
</div>
)}
</div>
);
}
import { type JSX, useRef } from 'react';
import Dropzone, { ErrorCode, type FileError, type FileRejection } from 'react-dropzone';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { resetUserConfig, setSrc, setUserSrc } from '../store/viewer-slice';
interface TopBarProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
}
const TopBar: React.FC<TopBarProps> = ({ className = '', ...props }) => {
const dispatch = useAppDispatch();
const input = useRef<HTMLInputElement | null>(null);
const userSrc = useAppSelector((state) => state.viewer.userSrc);
function onDrop(acceptedFiles: File[]) {
const file = acceptedFiles[0];
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === 'string') {
dispatch(setSrc(result));
dispatch(setUserSrc(file.name));
}
};
reader.readAsDataURL(file);
}
function onDropRejected(fileRejections: FileRejection[]) {
fileRejections.forEach((fileRejection) => {
const { file, errors } = fileRejection;
errors.forEach((error: FileError) => {
switch (error.code) {
case ErrorCode.FileTooLarge:
alert(`${file.name} is too large, please pick a smaller file`);
break;
case ErrorCode.FileInvalidType:
alert(`${file.name} is not supported, please pick a supported file`);
break;
default:
break;
}
});
});
}
return (
<div className={`flex justify-center items-center gap-2 bg-strong px-4 py-2 rounded-lg ${className}`} {...props}>
{!userSrc ? (
<>
<span className="font-bold">Try it yourself!</span>
<Dropzone onDrop={onDrop} onDropRejected={onDropRejected}>
{(state): JSX.Element => {
return (
<button className="p-2 font-bold rounded-lg bg-subtle hover:bg-hover" {...state.getRootProps()}>
<input {...state.getInputProps()} />
Browse file
</button>
);
}}
</Dropzone>
<span>or</span>
<input ref={input} className="flex-grow p-2 rounded-lg" placeholder="Paste JSON or .lottie URL" />
<button
className="p-2 font-bold rounded-lg bg-subtle hover:bg-hover"
onClick={() => {
if (!input.current) return;
dispatch(setSrc(input.current.value));
dispatch(setUserSrc(input.current.value));
}}
>
Load animation
</button>
</>
) : (
<>
<span className="">{userSrc}</span>
<button
className="p-2 font-bold rounded-lg bg-subtle hover:bg-hover"
onClick={() => {
dispatch(resetUserConfig());
}}
>
Reset
</button>
</>
)}
</div>
);
};
export default TopBar;
import { useCallback, useRef } from 'react';
export interface VirtualAnimationItemProps {
index: number;
animationUrl: string;
onRegisterCanvas: (index: number, canvas: HTMLCanvasElement | null, animationUrl: string) => void;
}
export const VirtualAnimationItem: React.FC<VirtualAnimationItemProps> = ({
index,
animationUrl,
onRegisterCanvas,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const setCanvasRef = useCallback(
(canvas: HTMLCanvasElement | null) => {
canvasRef.current = canvas;
onRegisterCanvas(index, canvas, animationUrl);
},
[index, animationUrl, onRegisterCanvas],
);
return (
<div className="h-[200px] p-2.5 border border-gray-700 bg-gray-900 rounded">
<div className="mb-1 font-mono text-xs text-gray-500">
#{index + 1} | {animationUrl.split('/').pop()?.slice(0, 20)}...
</div>
<canvas ref={setCanvasRef} className="w-full h-[calc(100%-25px)] bg-black rounded-sm" />
</div>
);
};
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { useDotLottiePool } from '../../hooks/useDotLottiePool';
import { VirtualAnimationItem } from './VirtualAnimationItem';
export interface VirtualizedAnimationListProps {
animations: string[];
itemHeight?: number;
}
export const VirtualizedAnimationList: React.FC<VirtualizedAnimationListProps> = ({ animations, itemHeight = 220 }) => {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: animations.length,
getScrollElement: () => parentRef.current,
estimateSize: () => itemHeight,
overscan: 2,
});
const virtualItems = virtualizer.getVirtualItems();
const { registerCanvas, activeCount } = useDotLottiePool();
return (
<div>
<div className="sticky top-0 z-10 p-4 font-mono text-sm border-b bg-gradient-to-b from-gray-950 via-gray-950 to-transparent border-gray-700">
<div className="flex flex-wrap items-center gap-5">
<span className="text-green-400">
Active Instances: <strong>{activeCount}</strong>
</span>
<span className="text-gray-500">
Visible: <strong>{virtualItems.length}</strong>
</span>
<span className="text-gray-600">
Total: <strong>{animations.length}</strong>
</span>
</div>
</div>
<div ref={parentRef} className="h-[600px] overflow-auto border border-gray-600 rounded">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<VirtualAnimationItem
index={virtualItem.index}
animationUrl={animations[virtualItem.index]}
onRegisterCanvas={registerCanvas}
/>
</div>
))}
</div>
</div>
</div>
);
};
import { DotLottie } from 'https://esm.sh/@lottiefiles/dotlottie-web';
// Create canvas element
const canvas = document.createElement('canvas');
canvas.style.width = '400px';
canvas.style.height = '400px';
document.body.appendChild(canvas);
// Basic dotLottie animation
// biome-ignore lint/correctness/noUnusedVariables: example code - variable available for playground interaction
const dotLottie = new DotLottie({
canvas,
src: 'https://lottie.host/779299c1-d174-4359-a66b-6253897b33e7/yRJTT0fCfq.lottie',
autoplay: true,
loop: true,
});
import { DotLottie } from 'https://esm.sh/@lottiefiles/dotlottie-web';
// Create canvas element
const canvas = document.createElement('canvas');
canvas.style.width = '400px';
canvas.style.height = '400px';
document.body.appendChild(canvas);
// Interactive animation - play on hover
const dotLottie = new DotLottie({
canvas,
src: 'https://lottie.host/779299c1-d174-4359-a66b-6253897b33e7/yRJTT0fCfq.lottie',
autoplay: false,
loop: true,
});
// Play when mouse enters
canvas.addEventListener('mouseenter', () => {
dotLottie.play();
console.log('Playing - mouse entered');
});
// Pause when mouse leaves
canvas.addEventListener('mouseleave', () => {
dotLottie.pause();
console.log('Paused - mouse left');
});
console.log('Hover over the canvas to play!');