How CSS3, Canvas API, and MediaRecorder enable powerful video creation without server-side processing
For years, video editing and export capabilities were the exclusive domain of desktop applications or server-side processing pipelines. The conventional wisdom was that browsers simply couldn’t handle the computational demands of video manipulation. Today, that assumption is fundamentally wrong.
In this post, I’ll walk through building a complete slideshow video creator that runs entirely in the browser—no server required for video processing. We’ll explore how modern frontend technologies like CSS3 animations, Canvas API, and MediaRecorder can be combined to create a powerful, production-ready video creation tool.
The Architecture: Client-Side Video Production
Our application follows a straightforward but powerful architecture:
flowchart TD
A[User Input<br/>Images/Text/Audio] --> B
subgraph B [State Management - Zustand]
B1[Upload Store]
B2[Settings Store]
B3[Preview Store]
end
B --> C[Real-time Preview<br/>CSS3 + React]
C --> D[Video Export<br/>Canvas + MediaRecorder]
D --> E[Downloadable<br/>Video File]
B -.-> C
C -.-> B
style A fill:#e1f5fe,stroke:#01579b
style B fill:#fff3e0,stroke:#ff6f00
style C fill:#f3e5f5,stroke:#4a148c
style D fill:#e8f5e8,stroke:#1b5e20
style E fill:#fff9c4,stroke:#f57f17flowchart LR
subgraph Input [Input Layer]
I1[Images]
I2[Text]
I3[Audio]
end
subgraph State [State Management - Zustand]
S1[(Upload Store)]
S2[(Settings Store)]
S3[(Preview Store)]
end
subgraph Preview [Preview Layer]
P1[React Components]
P2[CSS3 Styling]
P3[Real-time Render]
end
subgraph Export [Export Layer]
E1[Canvas Drawing]
E2[MediaRecorder API]
E3[Video Encoding]
end
Output[Downloadable MP4/WebM]
Input --> State
State <--> Preview
Preview --> Export
Export --> Output
style Input fill:#e3f2fd
style State fill:#fff3e0
style Preview fill:#f3e5f5
style Export fill:#e8f5e8
style Output fill:#fff9c4The key insight is that we use two parallel rendering paths:
- Preview Mode: Uses CSS3 animations for smooth, real-time preview
- Export Mode: Uses Canvas API for frame-by-frame video capture
This separation allows us to optimize for different goals—interactivity during editing, and precise control during export.
Technology Stack
Let’s break down the core technologies and why they matter:
Next.js 16 with React 19
The App Router provides excellent developer experience, while React 19’s concurrent features help maintain smooth UI updates during heavy rendering operations.
Zustand for State Management
Video editing requires managing complex state—multiple slides, effects, audio tracks, timing, and export settings. Zustand provides a simple, performant solution:
interface SlideshowState {
slides: Slide[];
selectedSlideId: string | null;
isPlaying: boolean;
aspectRatio: AspectRatio;
audioSettings: AudioSettings;
// Actions
addSlide: (slide: Omit<Slide, 'id'>) => void;
updateSlide: (id: string, updates: Partial<Slide>) => void;
applyToAllSlides: (updates: Partial<Slide>) => void;
}
The key pattern here is immutability with selective updates—essential for preventing unnecessary re-renders during playback.
Tailwind CSS for Styling
Beyond simple styling, Tailwind’s utility-first approach shines when building dynamic UIs. We can conditionally apply classes based on state without complex CSS-in-JS solutions.
The Power of CSS3 Animations
Here’s where things get interesting. For the preview, we leverage CSS3 animations extensively. Why? Because CSS animations are:
- GPU-accelerated: Transform and opacity changes run on the GPU
- Frame-perfect: The browser handles timing, ensuring smooth 60fps
- Declarative: Easy to reason about and modify
Ken Burns Effect Implementation
The Ken Burns effect—that subtle pan and zoom that makes static images feel cinematic—is a perfect example:
@keyframes kenBurnsZoomInContinuous {
0% { transform: scale(1.05); }
100% { transform: scale(1.25); }
}
@keyframes kenBurnsPanLeftContinuous {
0% { transform: scale(1.1) translateX(5%); }
100% { transform: scale(1.1) translateX(-5%); }
}
.ken-burns-active img {
width: 110%;
height: 110%;
object-fit: cover;
margin: -5%;
}
The animation duration is dynamically set via CSS custom properties:
<div
style={{
'--kb-duration': `${slide.duration}s`,
animation: `kenBurnsZoomInContinuous var(--kb-duration) ease-out forwards`,
}}
>
This approach allows each slide to have its own animation timing while using the same CSS keyframes.
Transition Effects
We implemented 21 different transition styles, from simple fades to complex 3D flips:
@keyframes flipXPreview {
from { transform: perspective(800px) rotateX(90deg); opacity: 0; }
to { transform: perspective(800px) rotateX(0deg); opacity: 1; }
}
@keyframes wipeLeftPreview {
0% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 0 0 0); }
}
The clip-path property enables sophisticated wipe transitions that were previously only possible with canvas masking.
Overlay Effects: Particle Systems in CSS
One of the most impressive demonstrations of CSS capabilities is our particle effect system. Effects like snow, rain, confetti, and sparkles are rendered entirely with CSS:
.snow-container .snowflake {
position: absolute;
top: -10px;
width: 8px;
height: 8px;
background: white;
border-radius: 50%;
animation: snowfall linear infinite;
}
@keyframes snowfall {
0% { transform: translateY(-10px) rotate(0deg); opacity: 0; }
10% { opacity: 0.8; }
100% { transform: translateY(100vh) rotate(360deg); opacity: 0.8; }
}
Each particle is a DOM element, dynamically generated based on intensity settings. While this might seem inefficient, modern browsers handle hundreds of animated elements smoothly thanks to compositing optimizations.
The Canvas API: Frame-by-Frame Control
While CSS excels at preview, video export requires precise control over every frame. This is where the Canvas API becomes essential.
The Export Pipeline
async function exportVideo() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const stream = canvas.captureStream(fps);
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9',
videoBitsPerSecond: 8000000,
});
mediaRecorder.start();
// Render each frame
for (const slide of slides) {
for (let frame = 0; frame < slide.duration * fps; frame++) {
renderFrame(ctx, slide, frame);
await new Promise(r => setTimeout(r, 1000 / fps));
}
}
mediaRecorder.stop();
}
Replicating CSS Effects on Canvas
The challenge is translating CSS effects to canvas. Here’s how we handle overlay effects:
function renderOverlayEffects(ctx, effects, width, height, frameNumber) {
effects.forEach(effect => {
switch (effect.type) {
case 'snow': {
const count = Math.floor(effect.intensity / 2);
for (let i = 0; i < count; i++) {
const seed = seededRandom(i);
const x = seed * width;
const y = ((frameNumber * 2 + seed * height * 10) % (height + 20)) - 10;
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.fill();
}
break;
}
case 'vignette': {
const gradient = ctx.createRadialGradient(
width / 2, height / 2, 0,
width / 2, height / 2, Math.max(width, height) * 0.7
);
gradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.7)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
break;
}
}
});
}
The key insight is using seeded random numbers for consistent particle positions across frames:
function seededRandom(seed: number) {
const x = Math.sin(seed * 12.9898 + seed * 78.233) * 43758.5453;
return x - Math.floor(x);
}
This ensures that if we re-render the same frame, particles appear in identical positions—essential for video consistency.
Ken Burns on Canvas
Translating the Ken Burns effect to canvas requires interpolating transformation values across frames:
function applyKenBurns(ctx, slide, progress, width, height) {
const intensity = slide.kenBurns.intensity / 5;
let scale = 1;
let panX = 0;
let panY = 0;
switch (slide.kenBurns.type) {
case 'zoomIn':
scale = 1 + progress * 0.2 * intensity;
break;
case 'panLeft':
panX = -progress * 10 * intensity;
scale = 1.1;
break;
case 'zoomInPanLeft':
scale = 1 + progress * 0.15 * intensity;
panX = -progress * 5 * intensity;
break;
}
ctx.translate(width / 2, height / 2);
ctx.scale(scale, scale);
ctx.translate(-width / 2 + panX, -height / 2 + panY);
}
Audio Integration with Web Audio API
A video editor isn’t complete without audio. The Web Audio API allows us to:
- Load and decode audio files
- Synchronize playback with video
- Capture audio during export
async function setupAudioExport(stream, audioSettings) {
const audioContext = new AudioContext();
const audioElement = new Audio(audioSettings.track.url);
audioElement.crossOrigin = 'anonymous';
const source = audioContext.createMediaElementSource(audioElement);
const destination = audioContext.createMediaStreamDestination();
source.connect(destination);
// Add audio track to video stream
destination.stream.getAudioTracks().forEach(track => {
stream.addTrack(track);
});
await audioElement.play();
}
This approach combines the audio stream with our canvas video stream, resulting in a complete video file with synchronized audio.
Intelligent Image Fitting
One challenge in video creation is handling images with different aspect ratios. We implemented intelligent detection:
function detectOptimalImageFit(
imageWidth: number,
imageHeight: number,
containerRatio: AspectRatio
) {
const imageRatio = imageWidth / imageHeight;
const targetRatio = parseAspectRatio(containerRatio);
const ratioDiff = Math.abs(imageRatio - targetRatio) / targetRatio;
if (ratioDiff > 0.3) {
// Image aspect ratio differs significantly
return {
fit: 'cover',
scale: 1.1, // Slight zoom to prevent edge gaps
panX: 0,
panY: 0,
};
}
return { fit: 'cover', scale: 1, panX: 0, panY: 0 };
}
This automatically adjusts the image to fit well within the chosen aspect ratio.
Blurred Background Effect
For images that don’t fill the frame, we use a blurred version of the image as background:
// In preview (CSS)
<div className="absolute inset-0 scale-125 blur-xl opacity-80">
<img src={slide.imageUrl} className="w-full h-full object-cover" />
</div>
// In export (Canvas)
if (slide.useBlurredBackground) {
ctx.save();
ctx.filter = 'blur(20px)';
ctx.globalAlpha = 0.8;
ctx.scale(1.3, 1.3);
ctx.drawImage(img, -width * 0.15, -height * 0.15, width * 1.3, height * 1.3);
ctx.restore();
}
This creates a professional look without revealing black bars.
Performance Considerations
Frame Rate Management
During export, we control frame rate explicitly:
const fps = 30;
for (let frame = 0; frame < totalFrames; frame++) {
renderFrame(ctx, frame);
await new Promise(r => setTimeout(r, 1000 / fps));
}
This ensures consistent timing regardless of actual render speed.
Memory Management
For long videos, we need to be mindful of memory:
// Use smaller images for timeline thumbnails
const thumbnailUrl = await createThumbnail(imageUrl, 320, 180);
// Clear references after export
mediaRecorder.onstop = () => {
chunksRef.current = [];
setVideoBlob(new Blob(chunks));
};
Progressive Loading
We load images on demand during export:
for (const slide of slides) {
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise(resolve => {
img.onload = resolve;
img.src = slide.imageUrl;
});
// Render frames...
}
The “Apply to All” Pattern
A common need in video editing is applying changes to multiple slides. We implemented this as a store action:
applyToAllSlides: (updates) => {
set((state) => ({
slides: state.slides.map(slide => ({ ...slide, ...updates })),
}));
}
This allows users to quickly apply effects, transitions, or styling across their entire project.
Lessons Learned
1. CSS is Surprisingly Powerful
For preview and real-time effects, CSS animations outperform manual canvas rendering. The browser’s rendering engine is highly optimized.
2. Canvas Requires Explicit State
Unlike CSS’s declarative model, canvas requires managing animation state explicitly. A seeded random function is essential for reproducible effects.
3. MediaRecorder Has Limitations
- Only WebM format is widely supported for encoding
- Frame-perfect timing requires careful management
- Audio sync can be tricky—test across browsers
4. Performance Varies by Effect
Particle effects with hundreds of elements work fine in CSS preview but can slow canvas export. Consider reducing complexity during export.
Conclusion
Building this slideshow video creator demonstrated that modern frontend technologies are more than capable of handling complex video creation tasks. By combining:
- CSS3 for smooth, GPU-accelerated previews
- Canvas API for precise frame-by-frame rendering
- MediaRecorder for video encoding
- Web Audio API for audio integration
We created a complete video production pipeline that runs entirely in the browser.
The implications are significant: video creation tools can now be delivered as web applications, accessible from any device without installation. This opens up possibilities for content creation platforms, marketing tools, and creative applications that were previously desktop-only.
The full source code is available in the repository. I encourage you to explore the implementation and consider how these techniques might apply to your own projects.
What frontend technologies have you used for unexpected applications? Share your experiences in the comments.
0 Comments