Remotion for Educational Content: Building eLearning Videos Programmatically
Remotion for Educational Content: Building eLearning Videos Programmatically
The eLearning industry has a quality problem. Producing consistent, professional-grade instructional video at scale is expensive enough that most organisations end up with a patchwork: a few polished hero lessons, a long tail of hastily recorded screencasts, and a mismatch of visual styles that undermines learner trust.
Remotion — the library that lets you write video in React — is uniquely suited to solving this problem. Educational content is repetitive by nature: every lesson has a title card, a set of concept slides, a summary, and possibly a quiz or a certificate. That structure is ideal for a programmatic approach, where a single template handles the visual treatment and per-lesson data drives the content.
This guide covers the complete workflow: lesson slide sequences, animated concept diagrams, voiceover synchronisation with <Audio>, quiz interaction mockups, and personalised certificate generation from student data.
Why Programmatic Video Suits Education
Before the implementation, it is worth understanding why Remotion is a particularly strong fit for educational content — beyond the obvious productivity argument.
Consistency. Every lesson produced from the same Remotion template has identical typography, spacing, colour, and animation timing. Learners do not notice this consciously — they notice the absence of it when quality varies. Consistent production reinforces trust in the content.
Easy updates. Text on a traditional video is baked in. When a product changes, a statistic becomes outdated, or a UI screenshot is superseded, re-editing a video is expensive. A Remotion lesson keeps its content in a JSON or TypeScript data file. Updating a fact means changing a string and re-rendering the relevant sequence — a five-minute process versus a five-hour one.
Data-driven personalisation. You can address the learner by name, show their department, their cohort, or their progress data within the video. This is trivial in React (a prop) and impossible in traditional video production without expensive per-user renders in specialised tools.
Branching and variants. You can render language variants, difficulty variants, or role-specific content by passing different props to the same template — no new editing sessions, no diverging source files.
Structuring Lesson Data
The most important architectural decision in a Remotion eLearning system is how you model your lesson content. Define this data structure first and let the template consume it.
// types/Lesson.ts
export interface LessonSlide {
type: 'title' | 'concept' | 'diagram' | 'quiz' | 'summary';
durationSeconds: number;
heading: string;
body?: string;
bulletPoints?: string[];
diagramType?: 'flow' | 'comparison' | 'cycle';
quizQuestion?: QuizSlide;
imageUrl?: string;
}
export interface QuizSlide {
question: string;
options: string[];
correctIndex: number;
explanation: string;
}
export interface Lesson {
id: string;
title: string;
module: string;
instructor: string;
voiceoverUrl?: string;
brandColor: string;
slides: LessonSlide[];
}
A complete lesson is then a JSON file that conforms to this shape:
{
"id": "react-hooks-intro",
"title": "Introduction to React Hooks",
"module": "React Fundamentals",
"instructor": "Jordan Lee",
"brandColor": "#6366f1",
"slides": [
{
"type": "title",
"durationSeconds": 4,
"heading": "React Hooks",
"body": "Module 3 · React Fundamentals"
},
{
"type": "concept",
"durationSeconds": 8,
"heading": "What is a Hook?",
"body": "Hooks let you use React features — like state and lifecycle methods — inside function components.",
"bulletPoints": [
"Introduced in React 16.8",
"Always start with the word 'use'",
"Cannot be called conditionally"
]
},
{
"type": "quiz",
"durationSeconds": 10,
"heading": "Quick Check",
"quizQuestion": {
"question": "Which of the following is a valid Hook name?",
"options": ["fetchData", "useDataFetcher", "DataFetcher", "getState"],
"correctIndex": 1,
"explanation": "Hooks must begin with 'use' — this is a convention that React's linter enforces."
}
}
]
}
Building the Lesson Composition
The main composition iterates over the slides array and places each one in a <Series.Sequence>. <Series> automatically positions each slide immediately after the previous one, so you never calculate cumulative frame offsets manually.
// compositions/LessonVideo.tsx
import { AbsoluteFill, Series, useVideoConfig } from 'remotion';
import type { Lesson, LessonSlide } from '../types/Lesson';
import { TitleSlide } from './slides/TitleSlide';
import { ConceptSlide } from './slides/ConceptSlide';
import { QuizSlide } from './slides/QuizSlide';
import { SummarySlide } from './slides/SummarySlide';
import { LessonAudio } from './LessonAudio';
export const LessonVideo: React.FC<Lesson> = (lesson) => {
const { fps } = useVideoConfig();
function renderSlide(slide: LessonSlide) {
switch (slide.type) {
case 'title': return <TitleSlide {...slide} brandColor={lesson.brandColor} />;
case 'concept': return <ConceptSlide {...slide} brandColor={lesson.brandColor} />;
case 'quiz': return <QuizSlide {...slide} brandColor={lesson.brandColor} />;
case 'summary': return <SummarySlide {...slide} brandColor={lesson.brandColor} />;
default: return null;
}
}
return (
<AbsoluteFill style={{ background: '#0f172a' }}>
{/* Voiceover audio synced to the full lesson */}
{lesson.voiceoverUrl && (
<LessonAudio src={lesson.voiceoverUrl} />
)}
<Series>
{lesson.slides.map((slide, i) => (
<Series.Sequence
key={i}
durationInFrames={Math.round(slide.durationSeconds * fps)}
name={`Slide-${i + 1}-${slide.type}`}
>
{renderSlide(slide)}
</Series.Sequence>
))}
</Series>
</AbsoluteFill>
);
};
Each slide component receives its own data and brand colour as props. It knows nothing about global timing — when a slide’s sequence starts, local frame 0 is frame 0 for that slide, so every slide’s entrance animation starts fresh.
Slide Components
Title Slide
// compositions/slides/TitleSlide.tsx
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from 'remotion';
interface TitleSlideProps {
heading: string;
body?: string;
brandColor: string;
}
export const TitleSlide: React.FC<TitleSlideProps> = ({
heading,
body,
brandColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const headlineProgress = spring({
frame,
fps,
config: { mass: 0.9, stiffness: 90, damping: 14 },
});
const bodyProgress = spring({
frame: Math.max(0, frame - 12),
fps,
config: { mass: 0.9, stiffness: 80, damping: 16, overshootClamping: true },
});
return (
<AbsoluteFill
style={{
background: `linear-gradient(135deg, #0f172a 0%, ${brandColor}22 100%)`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '0 120px',
}}
>
{/* Accent bar */}
<div
style={{
width: interpolate(headlineProgress, [0, 1], [0, 80]),
height: 4,
background: brandColor,
borderRadius: 2,
marginBottom: 32,
}}
/>
<h1
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 72,
fontWeight: 800,
color: '#f8fafc',
textAlign: 'center',
lineHeight: 1.2,
margin: 0,
opacity: headlineProgress,
transform: `translateY(${interpolate(
headlineProgress,
[0, 1],
[24, 0]
)}px)`,
}}
>
{heading}
</h1>
{body && (
<p
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 28,
fontWeight: 400,
color: 'rgba(248, 250, 252, 0.55)',
textAlign: 'center',
marginTop: 24,
opacity: bodyProgress,
transform: `translateY(${interpolate(
bodyProgress,
[0, 1],
[16, 0]
)}px)`,
}}
>
{body}
</p>
)}
</AbsoluteFill>
);
};
Concept Slide with Bullet Points
// compositions/slides/ConceptSlide.tsx
import {
AbsoluteFill,
Sequence,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from 'remotion';
interface ConceptSlideProps {
heading: string;
body?: string;
bulletPoints?: string[];
brandColor: string;
}
export const ConceptSlide: React.FC<ConceptSlideProps> = ({
heading,
body,
bulletPoints = [],
brandColor,
}) => {
return (
<AbsoluteFill
style={{
background: '#0f172a',
padding: '80px 120px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
{/* Heading */}
<Sequence from={0} durationInFrames={Infinity} name="ConceptHeading" layout="none">
<ConceptHeading text={heading} brandColor={brandColor} />
</Sequence>
{/* Body text */}
{body && (
<Sequence from={10} durationInFrames={Infinity} name="ConceptBody" layout="none">
<ConceptBody text={body} />
</Sequence>
)}
{/* Bullet points — staggered entry */}
{bulletPoints.map((point, i) => (
<Sequence
key={i}
from={18 + i * 8}
durationInFrames={Infinity}
name={`Bullet-${i}`}
layout="none"
>
<BulletPoint text={point} brandColor={brandColor} />
</Sequence>
))}
</AbsoluteFill>
);
};
const ConceptHeading: React.FC<{ text: string; brandColor: string }> = ({
text,
brandColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame, fps, config: { damping: 14, stiffness: 90 } });
return (
<h2
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 52,
fontWeight: 700,
color: '#f8fafc',
borderLeft: `5px solid ${brandColor}`,
paddingLeft: 24,
margin: '0 0 24px',
opacity: progress,
transform: `translateX(${interpolate(progress, [0, 1], [-20, 0])}px)`,
}}
>
{text}
</h2>
);
};
const ConceptBody: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame,
fps,
config: { damping: 16, stiffness: 80, overshootClamping: true },
});
return (
<p
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 28,
lineHeight: 1.6,
color: 'rgba(248, 250, 252, 0.75)',
margin: '0 0 32px',
opacity: progress,
}}
>
{text}
</p>
);
};
const BulletPoint: React.FC<{ text: string; brandColor: string }> = ({
text,
brandColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame,
fps,
config: { mass: 0.8, damping: 14, stiffness: 100 },
});
return (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 16,
marginBottom: 16,
opacity: progress,
transform: `translateX(${interpolate(progress, [0, 1], [-16, 0])}px)`,
}}
>
<div
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: brandColor,
marginTop: 10,
flexShrink: 0,
}}
/>
<p
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 26,
color: 'rgba(248, 250, 252, 0.85)',
margin: 0,
lineHeight: 1.5,
}}
>
{text}
</p>
</div>
);
};
Voiceover Sync with <Audio>
Remotion’s <Audio> component makes voiceover synchronisation straightforward. Place the component at the top level of the composition (outside the <Series>) and it plays from the beginning of the lesson:
// compositions/LessonAudio.tsx
import { Audio, staticFile } from 'remotion';
interface LessonAudioProps {
src: string;
volume?: number;
}
export const LessonAudio: React.FC<LessonAudioProps> = ({
src,
volume = 1,
}) => {
return (
<Audio
src={src.startsWith('http') ? src : staticFile(src)}
volume={volume}
/>
);
};
For voiceover files stored locally, place the .mp3 files in public/audio/ and reference them as staticFile('audio/lesson-001.mp3'). The render pipeline bundles them with the composition automatically.
Syncing Slides to Voiceover Segments
For precise sync between slides and voiceover segments, predefine your voiceover timecodes and set slide durationSeconds to match:
// data/lessons/react-hooks.ts
export const lesson: Lesson = {
slides: [
{ type: 'title', durationSeconds: 4, heading: 'React Hooks', /* ... */ },
{ type: 'concept', durationSeconds: 12, heading: 'What is a Hook?', /* ... */ },
{ type: 'concept', durationSeconds: 10, heading: 'useState', /* ... */ },
// Total: 26 seconds — matches the voiceover recording length
],
};
If you are generating voiceover with a TTS API, record the duration of each segment first, then use those durations to set the slide lengths.
Quiz Slide Mockup
A quiz slide in a video cannot accept actual user input — it is a one-way medium. But a well-designed quiz mockup serves the same pedagogical purpose: it prompts the learner to think before revealing the answer. The animation sequence is: question appears → options appear one by one → after a pause, the correct answer highlights.
// compositions/slides/QuizSlide.tsx
import {
AbsoluteFill,
Sequence,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from 'remotion';
import type { QuizSlide as QuizSlideData } from '../../types/Lesson';
interface QuizSlideProps {
quizQuestion: QuizSlideData;
heading: string;
brandColor: string;
durationSeconds: number;
}
export const QuizSlide: React.FC<QuizSlideProps> = ({
quizQuestion,
heading,
brandColor,
durationSeconds,
}) => {
const { fps } = useVideoConfig();
// Reveal answer in the second half of the slide
const revealFrame = Math.round((durationSeconds / 2) * fps);
return (
<AbsoluteFill
style={{
background: '#0f172a',
padding: '80px 120px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
{/* Quiz label */}
<Sequence from={0} name="QuizLabel" layout="none">
<QuizLabel brandColor={brandColor} />
</Sequence>
{/* Question */}
<Sequence from={8} name="Question" layout="none">
<QuizQuestion text={quizQuestion.question} />
</Sequence>
{/* Options — staggered */}
{quizQuestion.options.map((option, i) => (
<Sequence
key={i}
from={18 + i * 6}
name={`Option-${i}`}
layout="none"
>
<QuizOption
text={option}
index={i}
isCorrect={i === quizQuestion.correctIndex}
revealFrame={revealFrame}
brandColor={brandColor}
/>
</Sequence>
))}
{/* Explanation — appears with the reveal */}
<Sequence from={revealFrame} name="Explanation" layout="none">
<QuizExplanation text={quizQuestion.explanation} brandColor={brandColor} />
</Sequence>
</AbsoluteFill>
);
};
const QuizLabel: React.FC<{ brandColor: string }> = ({ brandColor }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame, fps, config: { damping: 14 } });
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
background: `${brandColor}22`,
border: `1px solid ${brandColor}44`,
borderRadius: 6,
padding: '6px 16px',
marginBottom: 24,
opacity: progress,
alignSelf: 'flex-start',
}}
>
<span
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 14,
fontWeight: 600,
color: brandColor,
textTransform: 'uppercase',
letterSpacing: '0.1em',
}}
>
Knowledge Check
</span>
</div>
);
};
const QuizOption: React.FC<{
text: string;
index: number;
isCorrect: boolean;
revealFrame: number;
brandColor: string;
}> = ({ text, index, isCorrect, revealFrame, brandColor }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enterProgress = spring({
frame,
fps,
config: { mass: 0.8, damping: 14, stiffness: 100 },
});
const isRevealed = frame >= revealFrame;
const bgColor = isRevealed
? isCorrect
? `${brandColor}33`
: 'transparent'
: 'transparent';
const borderColor = isRevealed
? isCorrect
? brandColor
: 'rgba(248, 250, 252, 0.12)'
: 'rgba(248, 250, 252, 0.12)';
const letters = ['A', 'B', 'C', 'D'];
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '16px 20px',
borderRadius: 10,
border: `1.5px solid ${borderColor}`,
background: bgColor,
marginBottom: 12,
opacity: enterProgress,
transform: `translateX(${interpolate(
enterProgress,
[0, 1],
[-16, 0]
)}px)`,
}}
>
<span
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 14,
fontWeight: 700,
color: isRevealed && isCorrect ? brandColor : 'rgba(248, 250, 252, 0.4)',
width: 24,
textAlign: 'center',
flexShrink: 0,
}}
>
{letters[index]}
</span>
<span
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 24,
color: isRevealed && isCorrect
? '#f8fafc'
: 'rgba(248, 250, 252, 0.7)',
fontWeight: isRevealed && isCorrect ? 600 : 400,
}}
>
{text}
</span>
</div>
);
};
const QuizQuestion: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame, fps, config: { damping: 14, stiffness: 90 } });
return (
<h3
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 32,
fontWeight: 600,
color: '#f8fafc',
margin: '0 0 24px',
lineHeight: 1.4,
opacity: progress,
}}
>
{text}
</h3>
);
};
const QuizExplanation: React.FC<{ text: string; brandColor: string }> = ({
text,
brandColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame,
fps,
config: { damping: 16, overshootClamping: true },
});
return (
<div
style={{
marginTop: 16,
padding: '12px 20px',
background: `${brandColor}15`,
borderLeft: `3px solid ${brandColor}`,
borderRadius: '0 8px 8px 0',
opacity: progress,
}}
>
<p
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 20,
color: 'rgba(248, 250, 252, 0.7)',
margin: 0,
lineHeight: 1.5,
}}
>
{text}
</p>
</div>
);
};
Personalised Certificate Generation
Certificates are one of the most valuable personalised video outputs for eLearning. A certificate that addresses the learner by name, shows the course they completed, and carries the completion date is far more meaningful than a generic static graphic.
// compositions/Certificate.tsx
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from 'remotion';
export interface CertificateProps {
studentName: string;
courseName: string;
completionDate: string; // ISO date string: "2026-06-05"
instructorName: string;
brandColor: string;
}
export const Certificate: React.FC<CertificateProps> = ({
studentName,
courseName,
completionDate,
instructorName,
brandColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const containerProgress = spring({
frame,
fps,
from: 0.9,
to: 1,
config: { mass: 1, stiffness: 80, damping: 18, overshootClamping: true },
});
const contentProgress = spring({
frame: Math.max(0, frame - 10),
fps,
config: { damping: 16, stiffness: 85 },
});
const formattedDate = new Date(completionDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
<AbsoluteFill
style={{
background: '#f8fafc',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{/* Certificate card */}
<div
style={{
width: 820,
background: '#ffffff',
borderRadius: 16,
boxShadow: '0 32px 80px rgba(0,0,0,0.12)',
border: `3px solid ${brandColor}`,
padding: '64px 80px',
transform: `scale(${containerProgress})`,
opacity: interpolate(containerProgress, [0.9, 1], [0, 1]),
textAlign: 'center',
}}
>
{/* Top accent */}
<div
style={{
width: interpolate(contentProgress, [0, 1], [0, 120]),
height: 4,
background: brandColor,
borderRadius: 2,
margin: '0 auto 32px',
}}
/>
<p
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 14,
fontWeight: 600,
color: brandColor,
textTransform: 'uppercase',
letterSpacing: '0.15em',
margin: '0 0 16px',
opacity: contentProgress,
}}
>
Certificate of Completion
</p>
<p
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 20,
color: '#64748b',
margin: '0 0 12px',
opacity: contentProgress,
}}
>
This certifies that
</p>
{/* Student name — prominent */}
<h1
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 56,
fontWeight: 800,
color: '#0f172a',
margin: '0 0 12px',
opacity: contentProgress,
transform: `translateY(${interpolate(
contentProgress,
[0, 1],
[20, 0]
)}px)`,
}}
>
{studentName}
</h1>
<p
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 20,
color: '#64748b',
margin: '0 0 12px',
opacity: contentProgress,
}}
>
has successfully completed
</p>
<h2
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 32,
fontWeight: 700,
color: brandColor,
margin: '0 0 32px',
opacity: contentProgress,
}}
>
{courseName}
</h2>
<div
style={{
width: '60%',
height: 1,
background: '#e2e8f0',
margin: '0 auto 24px',
}}
/>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
opacity: contentProgress,
}}
>
<div style={{ textAlign: 'left' }}>
<p
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 13,
color: '#94a3b8',
margin: '0 0 4px',
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
Instructor
</p>
<p
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 18,
fontWeight: 600,
color: '#0f172a',
margin: 0,
}}
>
{instructorName}
</p>
</div>
<div style={{ textAlign: 'right' }}>
<p
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 13,
color: '#94a3b8',
margin: '0 0 4px',
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
Date Issued
</p>
<p
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 18,
fontWeight: 600,
color: '#0f172a',
margin: 0,
}}
>
{formattedDate}
</p>
</div>
</div>
</div>
</AbsoluteFill>
);
};
Batch Certificate Generation
With student data in a CSV or database, generate personalised certificates in one script:
// scripts/renderCertificates.ts
import { renderMedia, selectComposition } from '@remotion/renderer';
import path from 'path';
const students = [
{ name: 'Alice Chen', courseName: 'React Fundamentals', completionDate: '2026-06-05' },
{ name: 'Ben Watanabe', courseName: 'React Fundamentals', completionDate: '2026-06-05' },
// ... hundreds more
];
const BUNDLE_URL = '/path/to/bundle';
const CONCURRENCY = 8;
async function renderCertificate(student: typeof students[0], i: number) {
const props = {
...student,
instructorName: 'Jordan Lee',
brandColor: '#6366f1',
};
const composition = await selectComposition({
serveUrl: BUNDLE_URL,
id: 'Certificate',
inputProps: props,
});
await renderMedia({
composition,
serveUrl: BUNDLE_URL,
codec: 'h264',
outputLocation: path.join('output', 'certificates', `${student.name.replace(/\s+/g, '-')}.mp4`),
inputProps: props,
});
console.log(`[${i + 1}/${students.length}] Certificate for ${student.name}`);
}
async function run() {
for (let i = 0; i < students.length; i += CONCURRENCY) {
await Promise.all(
students.slice(i, i + CONCURRENCY).map((s, j) => renderCertificate(s, i + j))
);
}
}
run();
Composition Settings for eLearning Platforms
| Platform | Width | Height | fps | Format | Notes |
|---|---|---|---|---|---|
| Udemy | 1920 | 1080 | 30 | H.264 MP4 | Min 720p required |
| Coursera | 1920 | 1080 | 30 | H.264 MP4 | 16:9 required |
| Teachable | 1920 | 1080 | 30 | H.264 MP4 | Max 4GB per file |
| YouTube (unlisted) | 1920 | 1080 | 30 | H.264 MP4 | Standard |
Register your compositions accordingly in Root.tsx:
<Composition
id="LessonVideo"
component={LessonVideo}
durationInFrames={calculateTotalFrames(lesson, 30)}
fps={30}
width={1920}
height={1080}
defaultProps={sampleLesson}
/>
FAQ
Q: How do I calculate the total composition duration from the lesson data?
Sum the durationSeconds of all slides and multiply by fps. A helper function makes this clean: slides.reduce((acc, s) => acc + s.durationSeconds, 0) * fps. Pass this value as durationInFrames when registering the composition.
Q: Can I sync slides to specific timestamps in the voiceover audio rather than using fixed durations?
Yes. Define voiceover cue points as a startSeconds property on each slide, then compute each slide’s durationInFrames as (nextSlide.startSeconds - thisSlide.startSeconds) * fps. This requires you to have the voiceover recorded first and the cue points measured — it is the most precise approach for complex narration.
Q: How do I add animated diagrams like flowcharts or concept maps?
Animated diagrams in Remotion are SVG elements with animated strokeDasharray and strokeDashoffset properties to simulate drawing, combined with opacity springs for node appearances. Each node and edge is its own <Sequence> with a staggered start frame. This is a larger topic — a future guide will cover animated diagram components in depth.
Q: Can the quiz slide accept real user interaction?
Not in a rendered video. Remotion produces a static video file. For real interactive quizzes, use the @remotion/player component in a React web application — the <Player> component renders the Remotion composition inside a web page, and you can overlay HTML buttons on top of it that respond to click events.
Q: What is the best way to handle multiple languages for international courses?
Pass a locale prop to your lesson composition. Each slide component reads localised strings from a messages object keyed by locale. Render the same composition twice with different locale props — once for each language variant. The visual template and timing stay identical; only the text content changes.
Q: Is there a performance penalty for long lessons with many slides?
Rendering performance scales with the number of frames, not the number of slides — a 20-minute lesson at 30fps is 36,000 frames regardless of whether it has 10 slides or 100. Each frame is rendered independently. Remotion Lambda distributes this work across many parallel workers, so longer lessons benefit significantly from cloud rendering.
Start Faster with RenderComp eLearning Templates
Building the full lesson template system from scratch — slides, quiz components, certificate layout, audio sync — takes significant time even when you know exactly what you are doing. The RenderComp library includes ready-to-use eLearning video templates with all of these components already built and tested.
Each template ships with full TypeScript types, a sample lesson data file, the <Audio> integration wired up, and a batch rendering script. You supply the lesson content and brand colour — the template handles the rest.
Visit rendercomp.com to explore the eLearning template collection and start producing consistent, professional-grade educational video in hours, not weeks.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →