This file tracks architectural patterns, lessons learned, and key insights for the remotion-bits project.
When building similar components, extract shared logic into reusable utilities FIRST.
This principle was applied when implementing MotionTransition alongside TextTransition:
- Identify duplication: Both components needed frame-based timing, transform building, keyframe interpolation, and easing
- Extract early: Created
src/utils/motion/framework withuseMotionTiminghook,buildTransformString, andbuildMotionStylesutilities - Refactor existing code: Updated TextTransition to use the new framework, eliminating ~150 lines of duplicated logic
- Build new features: MotionTransition automatically inherits all animation capabilities without reimplementing them
- Result: Single source of truth for animation logic, easier testing, consistent behavior across components
Red flags: "I'll copy-paste and modify this code", "Both components need slightly different things", "I'll refactor later"
Green flags: "What logic is truly shared?", "Can this be a reusable utility?", "How would a third component use this?"
Code duplication compounds technical debt exponentially. Shared utilities compound knowledge linearly. Choose wisely.
Always investigate existing robust solutions before implementing custom code. This principle saved hours on the BackgroundTransition implementation:
- Research first: Found Granim.js (5.3k+ stars), studied their gradient interpolation algorithms
- Learn from proven patterns: Extracted angle wraparound logic, position normalization, edge case handling
- Adapt intelligently: Used their math but adapted to Remotion's frame-based architecture + culori's Oklch colors
- Result: Avoided bugs like wrong angle direction (270° vs 90°), discovered edge cases (180° ambiguity, mismatched stops)
Red flags: "I'll figure it out myself", "How hard can it be?", rejecting research as "not invented here"
Green flags: "How do established libraries solve this?", "What can I learn from their source code?", "Can I reuse proven algorithms?"
Standing on the shoulders of giants is engineering wisdom, not laziness. Build on proven foundations, then innovate on top.
Concrete Example from BackgroundTransition:
Process Followed:
- Identified the problem domain: CSS gradient interpolation for smooth animations
- Researched existing solutions: Found Granim.js, a battle-tested gradient animation library with 5.3k+ GitHub stars
- Analyzed their approach: Studied Granim.js source code for:
- Angle interpolation algorithm (shortest-path wraparound)
- Color stop position normalization (auto-distribution)
- Gradient state transition handling
- Adapted proven patterns: Extracted mathematical concepts while adapting to Remotion's frame-based architecture
- Enhanced with modern techniques: Replaced Granim's RGB interpolation with culori's Oklch for perceptually uniform colors
Why This Matters:
- Avoid reinventing the wheel: Granim.js solved angle wraparound (350°→10° via 0°) through years of real-world use
- Learn from edge cases: Their code handles scenarios we wouldn't discover until production (negative angles, 180° ambiguity)
- Build on proven foundations: Mathematical algorithms like shortest-path angle interpolation are well-established
- Adapt, don't copy: Used their insights but tailored to Remotion's frame-based, not time-based, architecture
Red Flags vs. Green Flags:
- ❌ "I'll figure out angle interpolation myself" → Led to initial bug (270° instead of 90°)
- ❌ "How hard can CSS parsing be?" → Very hard (nested commas, "at" keyword, multiple syntaxes)
- ✅ "Let me see how established libraries handle this" → Discovered wraparound math, position normalization patterns
- ✅ "Can I reuse existing solutions?" → Used culori for Oklch colors instead of building color space conversion
Concrete Benefits:
- Angle interpolation: Granim's
((diff % 360) + 360) % 360with ±180 adjustment saved hours of debugging - Position normalization: Their auto-distribution algorithm handles undefined stop positions elegantly
- Edge case handling: Their code revealed scenarios like 180° ambiguity, mismatched stop counts
- Code quality: Well-tested patterns from production use (vs. untested custom logic)
When Custom Implementation Makes Sense:
- ✅ No external library fits the requirements (Remotion's frame-based vs. requestAnimationFrame)
- ✅ Bundle size concerns (gradient-parser is 50KB, our custom parser is ~600 lines)
- ✅ Learning from existing solutions first, then adapting their proven algorithms
- ❌ "Not Invented Here" syndrome - rejecting research because it's not original
Context: Implementing a particle system that needs iterative physics (velocity, acceleration, drag) but must run in Remotion's deterministic, frame-independent environment.
Problem:
- Particles are inherently stateful (
$P_{t} = P_{t-1} + V$ ) - Remotion renders frames in any order/parallel
- Standard React state (
useState,useRef) is reset on generic renders and doesn't support seeking
Solution: "Replay Pattern"
-
Deterministic Birth: Particle
$I$ is always born at Frame$T_{birth}$ (calculated via seed/rate) -
On-the-fly Simulation: On Frame
$F$ , if a particle is "alive" ($T_{birth} < F < T_{death}$ ):- Re-initialize state at
$T_{birth}$ with deterministic seed - Run a micro-simulation loop for
$(F - T_{birth})$ steps inside the render pass
- Re-initialize state at
- Performance: For N < 1000 and Life < 100, checking N particles and running average 50 loop steps is negligible in JS (~50k ops)
Result:
- Fully deterministic physics without pre-rendering
- Frame-perfect seeking
- Supports complex, state-dependent behaviors (e.g., bouncing, logic constraints) unlike closed-form math equations
Lessons Learned:
- Research before building: Granim.js taught us angle wraparound, position normalization, and edge cases we wouldn't discover alone
- Parser complexity: CSS gradient syntax is more complex than expected (nested commas, "at" keyword, various shapes)
- isColorStop heuristic: Had to use negative checks (NOT "at", NOT "circle") before positive checks
- Angle interpolation direction: Simple diff calculation gives wrong path; Granim's wraparound logic with ±180 adjustment is the correct approach
- TypeScript strictness: Non-null assertions (
!) after normalization cleaner than defensive checks everywhere - Test-driven development: Writing 60 tests first caught edge cases early (angle direction, position parsing)
- Adapt proven patterns: Granim's math + culori's Oklch + Remotion's frame-based architecture = best of all worlds
Context: Color transitions in animations need to appear smooth and natural to the human eye. Traditional RGB interpolation produces muddy intermediate colors and uneven brightness (e.g., red→blue transitions through dark purple). TextTransition component had a crude binary color switch.
Problem:
- RGB interpolation is not perceptually uniform
- RGB red→blue goes through dark muddy colors
- HSL is better but still has brightness inconsistencies
- Original implementation:
return localProgress < 0.5 ? fromColor : toColor;(binary switch, no actual interpolation)
Solution: Use Oklch color space via the culori library for professional-quality color transitions.
Why Oklch:
- Perceptually uniform (consistent perceived brightness)
- Modern standard (CSS, Figma, Tailwind CSS v4)
- Better hue uniformity than LAB/LCH
- Designed specifically for graphics/web use
- Minimal out-of-gamut issues
Implementation:
// src/utils/color.ts
import { interpolate as culoriInterpolate, formatRgb } from "culori";
export function interpolateColorKeyframes(
colors: string[],
progress: number,
easingFn?: EasingFunction
): string {
// Multi-keyframe support matching interpolate.ts pattern
const interpolator = culoriInterpolate([fromColor, toColor], "oklch");
const result = interpolator(easedProgress);
return formatRgb(result) || "transparent";
}Key Features:
- Multi-keyframe support (not just two colors)
- Easing function integration (consistent with numeric interpolation)
- Graceful error handling for invalid colors
- Returns RGB strings for CSS compatibility
- Matches the API pattern of
interpolateKeyframesfrom interpolate.ts
Benefits:
- Professional-quality color transitions for video/animation work
- No muddy intermediate colors
- Consistent perceived brightness
- Reusable across all components (not just TextTransition)
- Future-proof (Oklch is the modern standard)
Trade-offs:
- Bundle size: ~12KB for culori (acceptable for video rendering)
- No native TypeScript types (created custom declarations in src/culori.d.ts)
- Considered alternatives: colord (~5KB), pure implementation (~2KB), but culori chosen for robustness and quality
Files Created:
src/utils/color.ts- Oklch color interpolation utilitysrc/utils/__tests__/color.test.ts- Comprehensive test coverage (22 tests)src/culori.d.ts- TypeScript type definitions
Files Modified:
src/utils/index.ts- Exported color utilitiessrc/components/TextTransition.tsx- Removed binary interpolateColor function, imported and used interpolateColorKeyframespackage.json- Added culori dependency
Testing Strategy:
- Edge cases (empty array, single color, invalid colors)
- Two-color interpolation (hex, rgb, rgba, hsl, named colors)
- Multi-keyframe transitions (3-4 colors with correct segment boundaries)
- Easing integration (linear, easeIn, easeOut, custom functions)
- Oklch perceptual uniformity validation
- Real-world use cases (gradients, high contrast, pastels)
Usage Example:
// In TextTransition
<TextTransition
transition={{
color: ['#ff0000', '#ffff00', '#0000ff'], // red → yellow → blue
frames: [0, 60],
easing: 'easeInOut'
}}
>
Smooth Colors!
</TextTransition>Future Considerations:
- Could expose hue interpolation direction (shorter/longer/increasing/decreasing) for rainbow effects
- Could add LAB mode as alternative for backward compatibility with older tools
Context: Components need to support both static values and frame-based animations without breaking backward compatibility.
Solution: Created a discriminated union type:
export type InterpolateValue =
| number
| [inputRange: number[], outputRange: number[], options?];Implementation:
- Helper function
resolveInterpolateValue(value, frame)evaluates the union at runtime - Components accept
InterpolateValuefor animatable numeric props - Default values use the array form to define animations:
opacity = [[0, 20], [0, 1]] - Static numbers work as-is for backward compatibility
Benefits:
- Declarative animation definitions at prop level
- Type-safe with full IDE autocomplete
- Backward compatible with existing code
- Centralizes interpolation logic
- Supports custom interpolate implementation (non-monotonic ranges, easing, etc.)
Applied to:
TextTransition:offset(can now animate over time)
Testing Strategy:
- Test both static and array forms
- Test with/without options (easing, extrapolation)
- Verify backward compatibility with static values
Files Modified:
src/utils/interpolate.ts- Type and helpersrc/components/*.tsx- All componentssrc/utils/__tests__/interpolate.test.ts- Test coverage
Context: The project had duplicate code in src/components/ (for npm library) and templates/components/ (for jsrepo registry), with different import patterns. This created maintenance burden and risk of divergence.
Problem:
src/components/used barrel exports:import { ... } from "../utils"templates/components/used direct imports:import { ... } from "../utils/interpolate"- Duplicate code had to be kept in sync manually
Solution: Use jsrepo transforms to automatically rewrite imports when copying files to user projects.
Implementation:
- Keep components only in
src/(single source of truth) - Point jsrepo.config.ts to
src/files instead oftemplates/ - Create custom transform to rewrite barrel imports to direct file imports
- Use manual dependency resolution to avoid import resolution warnings
- Suppress InvalidImportWarning for barrel imports that will be transformed
Key Code:
function rewriteUtilsImports(): Transform {
return {
transform: async (code, fileName) => {
// Rewrite imports from "../utils" to "../utils/interpolate"
return { code: code.replace(/from\s+["']\.\.\/utils["']/g, 'from "../utils/interpolate"') };
},
};
}Benefits:
- Single source of truth eliminates duplication
- No manual sync between src/ and templates/
- Library and registry use the same tested code
- Transform handles import differences automatically
- Reduced maintenance burden
Files Modified:
jsrepo.config.ts- Added transform, updated paths, manual dependenciespackage.json- Removed "templates" from files array- Deleted entire
templates/directory
Registry Build: Successfully builds with 4 items and 4 files from src/
Context: Particles component had a global startFrame prop that offset all spawners simultaneously, but individual Spawner components had a non-functional startFrame prop that was defined but never used. This prevented independent control of when different particle emitters would begin their simulation.
Problem:
- Spawner interface included
startFrame?: numberbut it wasn't being applied - In a previous fix attempt, calling
simulateParticlesseparately per spawner broke particle display - The simulator used a single frame value for all spawners, preventing per-spawner frame offsets
- Users couldn't create staggered particle effects (e.g., left emitter starts at frame 0, right emitter starts at frame 50)
Requirements:
- Each spawner needs its own independent frame offset
- Spawner's
startFrameshould override Particles' globalstartFrame - Must not break particle display (previous attempt broke this)
- Must remain fully deterministic (Remotion requirement)
Solution: Apply Per-Spawner Frame Offset in Simulator
Rather than calling simulateParticles multiple times (Option A, which broke before), modify the simulator to apply each spawner's frame offset individually within a single simulation pass (Option B).
Implementation:
-
Merge Spawner-level and Particles-level startFrame (Particles.tsx):
// In useMemo where spawners are extracted from children extractedSpawners.push({ ...props, id: props.id || `spawner-${spawnerCount++}`, // Spawner's startFrame takes precedence over Particles' startFrame startFrame: props.startFrame !== undefined ? props.startFrame : startFrame, children: props.children });
-
Apply Per-Spawner Frame Offset in Simulator (simulator.ts):
for (const spawner of spawners) { // Apply spawner-specific startFrame offset const spawnerStartFrame = spawner.startFrame || 0; const spawnerFrame = frame + spawnerStartFrame; // Use spawnerFrame instead of global frame for all calculations totalBorn = Math.floor(Math.max(0, spawnerFrame) * rate); const age = spawnerFrame - birthFrame; // Pass spawnerFrame to behaviors and movement behavior.handler(particle, t, { frame: spawnerFrame, fps }); movement(particle, t, { frame: spawnerFrame, fps }); }
-
Remove Global Frame Offset (Particles.tsx):
// Before: Applied global offset const simulationFrame = frame + startFrame; return simulateParticles({ frame: simulationFrame, ... }); // After: Let each spawner handle its own offset return simulateParticles({ frame, ... });
Why This Works:
- Single simulation pass: All spawners processed in one call (no particle ID conflicts)
- Independent timelines: Each spawner calculates
spawnerFrame = frame + spawnerStartFrame - Deterministic: Same frame input always produces same particle output
- Backward compatible: If
startFrameis undefined, defaults to 0 (no offset) - Proper override behavior: Spawner's
startFrametakes precedence when set, otherwise inherits from Particles
Key Architectural Insight:
The previous broken approach likely called simulateParticles multiple times:
// DON'T DO THIS - causes particle ID conflicts and rendering issues
spawners.forEach(spawner => {
const particles = simulateParticles({ frame: frame + spawner.startFrame, spawners: [spawner], ... });
allParticles.push(...particles);
});The correct approach is to handle frame offsets inside the simulator loop:
// DO THIS - single pass, per-spawner frame calculation
for (const spawner of spawners) {
const spawnerFrame = frame + (spawner.startFrame || 0);
// Use spawnerFrame for all spawner-specific calculations
}Testing:
Added comprehensive test suite (142 tests total, all passing):
-
Per-spawner startFrame independence:
- Spawner A (startFrame=0) at frame 10 → 10 particles
- Spawner B (startFrame=50) at frame 10 → 60 particles (acts as if frame 60)
-
Burst spawning with offset:
- Spawner A (burst=10, startFrame=0) at frame 0 → 10 particles alive
- Spawner B (burst=10, startFrame=10, lifespan=5) at frame 0 → 0 particles (all dead from age 10)
-
Multiple spawners with different offsets:
- Spawner A (startFrame=0) at frame 5 → 10 particles (rate=2)
- Spawner B (startFrame=10) at frame 5 → 30 particles (rate=2, acts as frame 15)
- Spawner C (startFrame=20) at frame 5 → 50 particles (rate=2, acts as frame 25)
-
Default to 0 when undefined:
- Spawner without
startFrameprop behaves as ifstartFrame=0
- Spawner without
Usage Examples:
// Global offset for all spawners
<Particles startFrame={100}>
<Spawner rate={1} /> {/* Starts at frame 100 */}
<Spawner rate={1} /> {/* Starts at frame 100 */}
</Particles>
// Per-spawner offset (overrides Particles startFrame)
<Particles startFrame={50}>
<Spawner rate={1} startFrame={0} /> {/* Starts at frame 0 */}
<Spawner rate={1} startFrame={100} /> {/* Starts at frame 100 */}
<Spawner rate={1} /> {/* Starts at frame 50 (inherits) */}
</Particles>
// Staggered particle effects
<Particles>
<Spawner position={{ x: 0, y: 500 }} startFrame={0}>
<div>❄️</div>
</Spawner>
<Spawner position={{ x: 500, y: 500 }} startFrame={30}>
<div>❄️</div>
</Spawner>
<Spawner position={{ x: 1000, y: 500 }} startFrame={60}>
<div>❄️</div>
</Spawner>
</Particles>Files Modified:
- src/components/Particles/Particles.tsx - Merge spawner startFrame with global startFrame
- src/utils/particles/simulator.ts - Apply per-spawner frame offset in simulation loop
- src/utils/particles/tests/simulator.test.ts - Added 4 new tests for per-spawner startFrame
Test Results:
- All 142 tests passing (12 in simulator.test.ts, 130 in other test files)
- TypeScript compilation passes with no errors
- No regressions in existing particle behavior
Benefits:
- ✅ Per-spawner frame control enables complex choreographed particle effects
- ✅ Maintains deterministic simulation (Remotion requirement)
- ✅ Particles still render correctly (fixed the previous broken approach)
- ✅ Clean API: Spawner's
startFramenaturally overrides Particles'startFrame - ✅ Fully backward compatible (undefined startFrame defaults to 0)
- ✅ Efficient: Single simulation pass, no duplicate calculations
Key Learnings:
-
Apply frame offsets inside the simulation loop, not by calling the simulator multiple times
- Multiple simulator calls can cause particle ID conflicts and accumulation issues
- Single pass with per-spawner frame calculations is cleaner and more efficient
-
Frame offset inheritance pattern:
// In parent component, merge child prop with parent default startFrame: childProp !== undefined ? childProp : parentDefault
-
Deterministic simulation requires frame-based offsets, not time-based:
- Each spawner gets its own "virtual frame" (
spawnerFrame = frame + spawnerStartFrame) - All calculations (birth, age, behaviors) use
spawnerFrameinstead of globalframe
- Each spawner gets its own "virtual frame" (
-
Test both independence and interaction:
- Test each spawner works independently with its own offset
- Test multiple spawners with different offsets in same simulation
- Test inheritance (undefined spawner startFrame uses Particles default)
- Test override (defined spawner startFrame ignores Particles default)
Context: Needed a static documentation site with live, interactive Remotion Player examples, searchable docs, blog support, and markdown authoring. The site must deploy to CloudFlare Pages with zero server costs.
Problem:
- Traditional static site generators (Jekyll, Hugo) lack React component support
- Next.js/Remix require server runtime (not truly static)
- VitePress is Vue-focused, awkward for React components
- Need to embed
@remotion/player(React component requiring hydration) in static docs - Want to reuse existing showcase components from
demo/src/showcases/without duplication
Solution: Astro + Starlight with React Islands architecture - static HTML with selective client-side hydration only where needed.
Why Astro + Starlight:
- Zero JS by default: Pages ship pure HTML/CSS, JavaScript only loads for interactive components
- React Islands:
client:visibledirective hydrates components when scrolled into view - True static output:
npm run buildgenerates pure HTML (no Node.js server needed) - Starlight features: Built-in search (Pagefind), sidebar, dark mode, mobile-responsive
- MDX support: Import and use React components directly in markdown
- Best performance: ~50KB base bundle vs. 300KB+ (Docusaurus) or 200KB+ (Nextra)
Implementation:
- Project Structure:
docs/
├── src/
│ ├── pages/
│ │ └── index.astro # Custom marketing landing page (/)
│ ├── content/
│ │ └── docs/ # Documentation under /docs route
│ │ ├── getting-started.mdx
│ │ ├── components/
│ │ │ ├── text-transition.mdx
│ │ │ ├── background-transition.mdx
│ │ │ └── motion-transition.mdx
│ │ ├── utilities/
│ │ └── examples/
│ └── components/
│ ├── ShowcasePlayer.tsx # Reusable Player wrapper
│ └── showcases/ # Copied from demo/, imports updated
│ ├── TextTransitionShowcaseItem.tsx
│ ├── BackgroundTransitionShowcaseItem.tsx
│ └── Center.tsx
├── astro.config.mjs # Astro + Starlight config
├── package.json
└── dist/ # Build output (deploy this)
- ShowcasePlayer Wrapper Pattern:
// docs/src/components/ShowcasePlayer.tsx
import { Player } from '@remotion/player';
export const ShowcasePlayer: React.FC<{
component: React.ComponentType;
duration: number;
fps?: number;
width?: number;
height?: number;
}> = ({ component, duration, fps = 30, width = 1920, height = 1080 }) => (
<div style={{ maxWidth: '800px', margin: '2rem auto' }}>
<Player
component={component}
durationInFrames={duration}
compositionWidth={width}
compositionHeight={height}
fps={fps}
controls
loop
style={{ width: '100%', aspectRatio: `${width}/${height}` }}
/>
</div>
);- Importing Showcases in MDX:
---
title: TextTransition
description: Animated text with smooth transitions
---
import { ShowcasePlayer } from '@components/ShowcasePlayer';
import { FadeInShowcase, SlideFromLeftShowcase } from '@showcases/TextTransitionShowcaseItem';
# TextTransition
## Examples
### Fade In Animation
<ShowcasePlayer client:visible component={FadeInShowcase} duration={60} />
```tsx
<TextTransition transition={{ opacity: [0, 1] }}>
Hello World
</TextTransition>
4. **Astro Configuration**:
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import react from '@astrojs/react';
export default defineConfig({
integrations: [
react(), // Must come before Starlight
starlight({
title: 'Remotion Bits',
sidebar: [
{ label: 'Getting Started', items: [...] },
{ label: 'Components', items: [...] },
{ label: 'Utilities', items: [...] },
],
}),
],
});
- Custom Landing Page at Root:
<!-- docs/src/pages/index.astro -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Remotion Bits - Beautiful Animation Components</title>
</head>
<body>
<div class="hero">
<h1>Remotion Bits</h1>
<p>Ready-made animation components for Remotion</p>
<a href="/docs/getting-started">Get Started</a>
</div>
<!-- Marketing content, feature grid, code snippets -->
</body>
</html>Key Features:
-
Client Hydration Directives:
client:load- Hydrates immediately (above-the-fold examples)client:visible- Hydrates when scrolled into view (below-fold, best for performance)client:idle- Hydrates when browser is idleclient:only="react"- Only renders on client (skip SSG entirely)
-
Import Path Aliases (tsconfig.json):
{ "compilerOptions": { "paths": { "@components/*": ["./src/components/*"], "@showcases/*": ["./src/components/showcases/*"] } } } -
Showcase Component Adaptation:
- Copied showcase files from
demo/src/showcases/todocs/src/components/showcases/ - Updated imports from
"../../../src/components"to"remotion-bits"(published package) - Maintains single source of truth (showcases reference the library, not local src/)
- Copied showcase files from
Performance Characteristics:
| Metric | Value |
|---|---|
| Total build size | 2.6MB |
| Base page (no Player) | ~50-60KB JS |
| Page with Player | ~350KB JS (Player + React runtime) |
| Initial HTML load | ~5-10KB (pure HTML) |
| Build time | ~2-3 seconds |
| Pages built | 15 (landing + 14 docs) |
Deployment (CloudFlare Pages):
cd docs
npm run build
# Upload dist/ folder to CloudFlare Pages
# Build command: npm run build
# Output directory: dist
# Environment: NODE_VERSION=18Benefits:
- ✅ True static output: No server runtime, no edge functions needed
- ✅ Best performance: Minimal JS bundle, only loads where needed
- ✅ Live examples: Full Remotion Player functionality in static docs
- ✅ Reusable showcases: Imported from demo/ without duplication
- ✅ Built-in search: Pagefind indexes all content automatically
- ✅ Blog-ready: Astro Content Collections support blog structure
- ✅ Free hosting: CloudFlare Pages, Netlify, Vercel, GitHub Pages
- ✅ Zero traffic costs: Static files, no serverless function invocations
- ✅ SEO-friendly: Pre-rendered HTML for search engines
- ✅ Fast builds: 2-3 seconds for 15 pages
Trade-offs:
⚠️ Two-build process: Library (npm run buildin root) + Docs (npm run buildin docs/)⚠️ Manual showcase sync: Showcase files copied to docs/, must update imports to useremotion-bitspackage⚠️ Bundle size for interactive pages: Pages with Player add ~300KB (acceptable for video documentation)⚠️ Remotion license notice: Player shows license message in console (addacknowledgeRemotionLicenseprop to suppress)
Alternative Frameworks Considered:
| Framework | Why Not Chosen |
|---|---|
| Nextra (Next.js) | Larger bundle (~200KB base), requires static export mode, less optimized |
| Docusaurus | Heaviest bundle (~300KB base), Webpack-based (slower), more opinionated |
| VitePress | Vue-focused, React support is awkward/hacky, not ideal for React libraries |
| Jekyll/Hugo | No React component support, would need iframe embeds or video exports |
When to Use This Pattern:
- ✅ React component library needing interactive examples in docs
- ✅ Video/animation tools where showing live output is critical
- ✅ Static hosting requirement (CloudFlare Pages, Netlify, GitHub Pages)
- ✅ Performance-critical (minimize JS bundle size)
- ✅ Need professional docs features (search, sidebar, dark mode)
When NOT to Use:
- ❌ Server-side features needed (authentication, real-time data, API routes)
- ❌ Heavy interactive app (not a documentation site)
- ❌ Team unfamiliar with Astro (steeper learning curve than Next.js)
- ❌ Need versioned docs (Docusaurus has better version support)
Files Created:
docs/- Entire Astro documentation sitedocs/src/pages/index.astro- Custom landing pagedocs/src/components/ShowcasePlayer.tsx- Reusable Player wrapperdocs/src/components/showcases/*- Copied showcase componentsdocs/src/content/docs/**/*.mdx- 14 documentation pagesdocs/astro.config.mjs- Astro + Starlight configuration
Key Learnings:
- Island Architecture is Perfect for Docs: Most doc pages are static text/code; interactive examples are isolated islands
- client:visible is Key for Performance: Lazy-load Players below fold to keep initial page load fast
- Reuse Showcase Components: Don't rewrite examples, import existing showcase components from demo/
- Update Imports to Published Package: Showcases in docs should import
"remotion-bits", not relative paths tosrc/ - Single Git Repo: Remove
.gitfrom docs/ folder to maintain single top-level repository - Custom Landing + Docs Structure:
src/pages/index.astrofor marketing, Starlight handles/docsroute automatically - Built-in Search Works Great: Pagefind indexes everything, no Algolia API key needed
- MDX is Powerful: Import React components directly in markdown, full TypeScript support
Testing Strategy:
- Build succeeds:
npm run buildin docs/ completes without errors - Search works: Pagefind builds index for 15 pages
- Showcases load: Player components render when scrolled into view
- Navigation works: Sidebar links, breadcrumbs, page navigation all functional
- Responsive: Mobile menu, responsive layouts tested
- Bundle sizes: Verify base pages stay under 100KB, Player pages under 400KB
Future Enhancements:
- Add blog section with release notes and tutorials
- Create interactive playground with prop editors (using Zod schemas)
- Add video recordings as fallback for no-JS scenarios
- Set up automated deployment on git push
- Add sitemap and SEO metadata (needs
siteconfig in astro.config.mjs) - Create OpenGraph images for social sharing
Static doesn't mean boring. Astro's island architecture lets you build documentation that's both performant (pure HTML) and interactive (React components where needed). Perfect for component libraries that need live examples without server costs.
Context: Both TextTransition and MotionTransition needed identical animation logic (timing, transforms, keyframe interpolation). Rather than duplicate ~150 lines of code, extracted a shared motion framework.
Problem:
- TextTransition had complex animation logic embedded inline (~200 lines)
- MotionTransition needed same capabilities but for generic React children
- Code duplication leads to inconsistent behavior and harder maintenance
- Future animation components would need the same foundation
Solution: Created src/utils/motion/ with reusable hooks and utilities that both components consume.
Implementation:
- Shared Type Definitions (
src/utils/motion/index.ts):
export type AnimatedValue = number | [number, number] | number[];
export interface TransformProps { x?, y?, z?, scale?, rotate?, ... }
export interface VisualProps { opacity?, color?, backgroundColor?, blur? }
export interface TimingProps { frames?, duration?, delay?, easing? }- Core Utilities (extracted from TextTransition):
// Keyframe interpolation (supports arrays for multi-stop animation)
export function interpolateKeyframes(value: AnimatedValue, progress: number, easingFn?): number
// Easing function resolution (name string or custom function)
export function getEasingFunction(easing?: EasingFunction | EasingName): EasingFunction | undefined
// Transform string builder (combines all transform properties)
export function buildTransformString(transforms: TransformProps, progress: number, easingFn?): string
// Complete style generator (transforms + visual props → React.CSSProperties)
export function buildMotionStyles(config: MotionStyleConfig): React.CSSProperties- Timing Hook (frame-based progress calculation):
export function useMotionTiming(config: MotionTimingConfig): number
// Handles: frames/duration, delay, stagger per unit, cycle offset
// Returns: progress value 0-1 (clamped)- Component Usage Pattern:
// TextTransition (refactored)
const renderUnit = (unit: string, index: number) => {
const progress = useMotionTiming({ frames, duration, delay, stagger: splitStagger, unitIndex: index, easing, cycleOffset });
const unitStyle = buildMotionStyles({ progress, transforms: { x, y, scale, ... }, styles: { opacity, color, ... }, easing: easingFn, baseStyle });
return <span style={unitStyle}>{unit}</span>;
};
// MotionTransition (new component)
const renderChild = (child: ReactNode, index: number) => {
const staggerIndex = calculateStaggerIndex(index, totalChildren, staggerDirection);
const progress = useMotionTiming({ frames, duration, delay, stagger, unitIndex: staggerIndex, easing });
const motionStyle = buildMotionStyles({ progress, transforms, styles, easing: easingFn });
return React.cloneElement(child, { style: { ...child.props.style, ...motionStyle } });
};Key Features:
- Single source of truth: Animation logic lives in one place
- Consistent behavior: Both components use identical timing/interpolation
- Easy testing: Utilities are pure functions, hook is isolated
- Extensible: Future components can import and use immediately
- Type-safe: Shared types ensure API consistency
Architectural Insight:
- TextTransition: Wraps text units in
<span>withdisplay: inline-blockandwhiteSpace: pre - MotionTransition: Uses
React.cloneElement()to merge styles into existing children - Both leverage same motion framework, different DOM manipulation strategies
Files Created:
- src/utils/motion/index.ts - Motion framework
Files Modified:
- src/components/TextTransition.tsx - Refactored to use motion framework (~150 lines reduced)
Files Created:
- src/components/MotionTransition.tsx - New component using motion framework
- src/components/tests/MotionTransition.test.tsx - Comprehensive tests
- demo/src/showcases/MotionTransitionShowcase.tsx - Visual demos
MotionTransition Unique Features:
- Stagger direction:
forward,reverse,center(animates from middle outward) - Style merging: Preserves existing child styles, applies animation on top
- Generic children: Works with any React elements, not just text
- Custom components: Children can be custom components that forward
styleprop to underlying DOM
Usage Examples:
// Forward stagger (default)
<MotionTransition transition={{ opacity: [0, 1], y: [50, 0], stagger: 5, staggerDirection: "forward" }}>
<div>First</div>
<div>Second</div>
<div>Third</div>
</MotionTransition>
// Reverse stagger (last animates first)
<MotionTransition transition={{ x: [100, 0], stagger: 5, staggerDirection: "reverse" }}>
<Card>A</Card>
<Card>B</Card>
<Card>C</Card>
</MotionTransition>
// Center stagger (middle animates first, spreads outward)
<MotionTransition transition={{ scale: [0.5, 1], stagger: 4, staggerDirection: "center" }}>
<Icon name="star" />
<Icon name="heart" />
<Icon name="rocket" />
</MotionTransition>
// Custom component (forwards style prop)
const Card = ({ style, children }) => <div style={{ ...baseStyle, ...style }}>{children}</div>;
<MotionTransition transition={{ opacity: [0, 1], y: [30, 0] }}>
<Card>Content</Card>
</MotionTransition>Context: Users need to experiment with component props in real-time without editing code. Remotion Studio provides a UI for tweaking props when schemas are defined.
Solution: Created individual showcase compositions for each component with Zod schemas:
Pattern:
// 1. Define Zod schema for props
export const componentSchema = z.object({
propName: z.string().default("value"),
numericProp: z.number().min(0).max(100).default(50),
enumProp: z.enum(["option1", "option2"]).default("option1"),
});
// 2. Infer TypeScript type
export type ComponentShowcaseProps = z.infer<typeof componentSchema>;
// 3. Register composition with schema
<Composition
id="ComponentName"
component={ComponentShowcase}
schema={componentSchema}
defaultProps={{...}}
/>Benefits:
- Real-time prop editing in Remotion Studio UI
- Type-safe props with IDE support
- Built-in validation with min/max/enum constraints
- Isolated testing of individual components
- Better developer experience for exploring component APIs
Implementation:
- Created
/demo/src/showcases/directory - Individual showcases:
TextTransitionShowcase - Each has its own schema and composition registration
- Original combined
Playgroundcomposition preserved for backward compatibility
InterpolateValue Consideration:
- For UI simplicity, showcases use
staticValueprops alongsideuseAnimationtoggles - This allows users to experiment with both static and animated values
- Full InterpolateValue arrays can still be edited in code
Files Created:
demo/src/showcases/TextTransitionShowcase.tsxdemo/src/showcases/index.ts
Files Modified:
demo/src/Root.tsx- Added composition registrationsdemo/package.json- Added zod dependency
Context: TextTransition component supports a cycle feature that rotates through different text strings. Each text should animate in with the same transition properties.
Problem:
- The first cycled text animated correctly (frames 0-45)
- Subsequent texts appeared instantly without animation
- Root cause: Animation progress was calculated from global frame, not per-cycle-item frame
- After first cycle completed,
progresswas always ≥1, showing final animation state immediately
Solution: Track frame position within the current cycle item using modulo arithmetic.
Implementation:
// Calculate frame offset within current cycle item
cycleFrameOffset = relativeFrame % itemDuration;
// Use cycle offset instead of global frame for progress calculation
const baseFrame = cycle ? cycleFrameOffset : Math.max(0, frame - delay);
const relativeFrame = baseFrame - (index * splitStagger);
const progress = Math.min(Math.max((relativeFrame - startFrame) / totalDuration, 0), 1);Key Insight: When implementing repeating/cycling animations, always normalize the frame counter to restart from 0 at the beginning of each cycle. Global frame counters only work for single-run animations.
Pattern Application:
- Use modulo (
%) to reset frame counter per cycle:frame % cycleDuration - Apply to any component with repeating states (carousels, slideshows, alternating content)
- Maintain separate logic paths for one-time vs. cycling animations
Files Modified:
Testing: All existing tests pass, including cycle-specific tests that verify text content changes.
Context: TextTransition component had split property constrained to literal union "none" | "word" | "character" | "line", limiting flexibility for custom split behaviors.
Problem:
- Users wanted to split by arbitrary strings (e.g.,
"|",";", any custom delimiter) - Strict literal union prevented this extension
- Needed to maintain backward compatibility with existing usage
Solution: Broaden type to accept string while preserving special handling for known keywords.
Implementation:
// Before: Strict literal union
split?: "none" | "word" | "character" | "line";
// After: Flexible string type
split?: string;
// splitText function handles both cases
function splitText(text: string, mode: string): string[] {
// Special handling for known keywords
if (mode === "none") return [text];
if (mode === "word") return text.split(/(\s+)/);
if (mode === "character") return text.split("");
if (mode === "line") return text.split("\n");
// Custom separator: split by the provided string
return text.split(mode);
}Key Principles:
- Expand type from specific to general (literal union → string)
- Preserve keyword behavior via explicit checks before fallback
- Default to intuitive behavior for custom inputs (string.split())
- Maintain 100% backward compatibility (existing code continues to work)
Benefits:
- Users can now use any string as separator:
split: "|",split: "::" - No breaking changes to existing code using
"word","character", etc. - Intuitive API - custom split uses JavaScript's built-in
String.split() - Type system allows but doesn't enforce literal values (good IDE autocomplete)
Testing Strategy:
- Verify predefined keywords still work (
"word","character","line","none") - Test custom separators (
"|",",",";;") - Test that
split: "\n"works both as keyword"line"and custom separator - All tests pass, no regression
When to Apply:
- Component APIs with enum-like properties that could benefit from user extensibility
- When you want to guide users toward standard options but not restrict them
- Type narrowing becomes a constraint rather than a safety feature
Files Modified:
Usage Examples:
// Predefined keywords (still work)
<TextTransition transition={{ split: "word" }}>Hello World</TextTransition>
<TextTransition transition={{ split: "character" }}>ABC</TextTransition>
// Custom separators (new capability)
<TextTransition transition={{ split: "|" }}>One|Two|Three</TextTransition>
<TextTransition transition={{ split: "::" }}>A::B::C</TextTransition>
<TextTransition transition={{ split: "\n" }}>Line1\nLine2</TextTransition>The project uses a custom interpolate function instead of Remotion's built-in version to support:
- Non-monotonic input ranges (e.g.,
[0, 1, 0]) - Hold frames with duplicate values (e.g.,
[30, 30]) - Rich easing function library
- Consistent extrapolation behavior
This custom implementation is foundational and should be used throughout all components.
- When implementing similar components, extract shared logic into utilities FIRST
- Create a dedicated module (e.g.,
src/utils/motion/) for reusable animation logic - Use hooks for React context-dependent logic (frame/fps access)
- Use pure functions for stateless calculations (interpolation, transforms)
- Both TextTransition and MotionTransition share ~70% of their animation code via the motion framework
Context: Added staggerDirection: "random" to MotionTransition component to shuffle child animation order randomly.
Problem:
- Video rendering requires deterministic output (same render = same result)
- JavaScript's
Math.random()is non-deterministic (different on each render) - Random stagger needs to be "random enough" but perfectly reproducible
Solution: Use Remotion's built-in random(seed) function for all randomization needs.
Key Learnings:
-
Never use
Math.random()in Remotion components- Will cause flickering/inconsistent exports
- Break preview reliability
- Fail determinism requirements for video export
-
Use
random(seed)with meaningful seedsimport { random } from "remotion"; // Good: Stable seed for consistent randomization const shuffled = items.map((item, i) => { const randomValue = random(`stagger-${i}`); return { item, randomValue }; });
-
Fisher-Yates shuffle with Remotion's random
const indices = Array.from({ length: total }, (_, i) => i); for (let i = indices.length - 1; i > 0; i--) { const j = Math.floor(random(`stagger-${i}`) * (i + 1)); [indices[i], indices[j]] = [indices[j], indices[i]]; }
-
Test determinism explicitly
- Mock
random()in tests with consistent hash function - Verify multiple renders produce identical results
- Test pattern variation (ensure "random enough")
- Mock
Implementation Pattern:
- Added
"random"toStaggerDirectionunion type - Enhanced
calculateStaggerIndex()with random case - Used stable seed pattern (
stagger-${i}) for shuffle - Added tests for both randomness and determinism
Red flags: Using Math.random(), Date.now(), or any non-deterministic source
Green flags: random(seed), stable seeds, determinism tests
In video rendering, "random" means "unpredictable yet perfectly reproducible." Remotion's random() achieves both.
- Source files live in
src/ - Template files in
templates/must mirror source exactly - Templates are distributed via jsrepo for direct copying into user projects
- Always update both locations when making changes
- Use vitest for unit tests
- Test each component in isolation
- Ensure backward compatibility with existing APIs
- Add edge case tests for new patterns
When working on this codebase:
- Check this log first for established patterns
- Shared motion framework: Use
src/utils/motion/utilities for any new animation components - Reuse over rewrite: Import
useMotionTiming,buildMotionStylesinstead of reimplementing - Use
InterpolateValuefor any numeric animatable props - Keep source and template files synchronized
- Maintain backward compatibility unless explicitly breaking change
- Add test coverage for new functionality
- Update this log with new insights
- Documentation updates: Add new components/utilities to docs site in
docs/src/content/docs/ - Showcase reuse: Create showcase in
demo/src/showcases/, then import into docs with updated imports
When creating new animation components:
- Import motion framework utilities from
src/utils/motion/ - Use
useMotionTiming()for progress calculation (handles frames, duration, delay, stagger, easing) - Use
buildMotionStyles()for style generation (handles all transforms + visual props) - Define component-specific behavior (e.g., text splitting, child cloning, DOM strategy)
- Export types:
AnimatedValue,TransformProps,VisualProps,TimingProps - Write tests covering timing, stagger, transforms, and edge cases
- Create showcase demo with multiple examples in
demo/src/showcases/ - Update exports in
src/components/index.tsandsrc/utils/index.ts - Add documentation page in
docs/src/content/docs/components/ - Import showcase into docs with
client:visibledirective