No articles found
Try different keywords or browse our categories
FFmpeg.wasm in React: Build a Complete Video Trimmer That Runs in Your Browser
Complete guide to building a professional video trimmer with React and FFmpeg.wasm. Trim, preview, and export videos entirely in the browser with zero backend.
Build a professional video trimmer application using React and FFmpeg.wasm. Process videos entirely in the browser with no server required. Complete with timeline scrubbing, precise trimming, and video export.
Project Structure
Here’s the complete directory structure for the video trimmer application:
video-trimmer/
├── public/
│ └── index.html
├── src/
│ ├── components/
│ │ ├── VideoUploader.jsx
│ │ ├── VideoPlayer.jsx
│ │ ├── Timeline.jsx
│ │ ├── TrimControls.jsx
│ │ ├── ProgressModal.jsx
│ │ └── ExportOptions.jsx
│ ├── hooks/
│ │ ├── useFFmpeg.js
│ │ ├── useVideoProcessor.js
│ │ └── useTimeline.js
│ ├── utils/
│ │ ├── videoUtils.js
│ │ ├── formatTime.js
│ │ └── fileUtils.js
│ ├── styles/
│ │ ├── VideoUploader.css
│ │ ├── VideoPlayer.css
│ │ ├── Timeline.css
│ │ ├── TrimControls.css
│ │ ├── ProgressModal.css
│ │ └── App.css
│ ├── App.jsx
│ ├── main.jsx
│ └── index.css
├── package.json
└── vite.config.js
Installation and Setup
Install dependencies and create the project structure.
npm create vite@latest video-trimmer -- --template react
cd video-trimmer
npm install
# Install FFmpeg.wasm
npm install @ffmpeg/ffmpeg @ffmpeg/util
# Install additional dependencies
npm install lucide-react
npm run dev
Package Configuration
package.json:
{
"name": "video-trimmer",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"lucide-react": "^0.294.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
}
}
Vite Configuration
vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
},
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp'
}
}
});
Utility Functions
Time Formatting Utility
src/utils/formatTime.js:
export const formatTime = (seconds) => {
if (!seconds || isNaN(seconds)) return '00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
export const parseTimeToSeconds = (timeString) => {
const parts = timeString.split(':').map(Number);
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) {
return parts[0] * 60 + parts[1];
}
return 0;
};
export const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};
Video Utilities
src/utils/videoUtils.js:
export const getVideoMetadata = (file) => {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
URL.revokeObjectURL(video.src);
resolve({
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight
});
};
video.onerror = () => {
reject(new Error('Failed to load video metadata'));
};
video.src = URL.createObjectURL(file);
});
};
export const extractVideoFrames = async (videoFile, numFrames = 10) => {
const video = document.createElement('video');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
video.src = URL.createObjectURL(videoFile);
return new Promise((resolve, reject) => {
video.onloadedmetadata = async () => {
canvas.width = 160;
canvas.height = 90;
const duration = video.duration;
const interval = duration / numFrames;
const frames = [];
for (let i = 0; i < numFrames; i++) {
video.currentTime = i * interval;
await new Promise((res) => {
video.onseeked = () => {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
frames.push(canvas.toDataURL('image/jpeg', 0.7));
res();
};
});
}
URL.revokeObjectURL(video.src);
resolve(frames);
};
video.onerror = reject;
});
};
File Utilities
src/utils/fileUtils.js:
export const readFileAsArrayBuffer = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(new Uint8Array(e.target.result));
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
};
export const downloadBlob = (blob, filename) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
export const validateVideoFile = (file) => {
const validTypes = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
if (!validTypes.includes(file.type)) {
throw new Error('Invalid file type. Please upload MP4, WebM, OGG, or MOV files.');
}
const maxSize = 500 * 1024 * 1024; // 500MB
if (file.size > maxSize) {
throw new Error('File too large. Maximum size is 500MB.');
}
return true;
};
Custom Hooks
FFmpeg Hook
src/hooks/useFFmpeg.js:
import { useState, useRef, useEffect } from 'react';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
export const useFFmpeg = () => {
const ffmpegRef = useRef(null);
const [isLoaded, setIsLoaded] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [progress, setProgress] = useState(0);
useEffect(() => {
loadFFmpeg();
}, []);
const loadFFmpeg = async () => {
if (ffmpegRef.current) return;
setIsLoading(true);
setError(null);
try {
const ffmpeg = new FFmpeg();
ffmpeg.on('log', ({ message }) => {
console.log(message);
});
ffmpeg.on('progress', ({ progress: prog }) => {
setProgress(Math.round(prog * 100));
});
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm')
});
ffmpegRef.current = ffmpeg;
setIsLoaded(true);
} catch (err) {
console.error('Failed to load FFmpeg:', err);
setError(err.message);
} finally {
setIsLoading(false);
}
};
const trimVideo = async (videoFile, startTime, endTime, outputName = 'output.mp4') => {
if (!ffmpegRef.current) {
throw new Error('FFmpeg not loaded');
}
const ffmpeg = ffmpegRef.current;
setProgress(0);
try {
const inputName = 'input.mp4';
// Write input file
await ffmpeg.writeFile(inputName, await fetchFile(videoFile));
// Calculate duration
const duration = endTime - startTime;
// Execute trim command
await ffmpeg.exec([
'-i', inputName,
'-ss', startTime.toString(),
'-t', duration.toString(),
'-c:v', 'libx264',
'-c:a', 'aac',
'-preset', 'ultrafast',
outputName
]);
// Read output file
const data = await ffmpeg.readFile(outputName);
// Clean up
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);
return new Blob([data.buffer], { type: 'video/mp4' });
} catch (err) {
console.error('Trim error:', err);
throw new Error('Failed to trim video: ' + err.message);
}
};
const convertVideo = async (videoFile, format = 'mp4', quality = 'medium') => {
if (!ffmpegRef.current) {
throw new Error('FFmpeg not loaded');
}
const ffmpeg = ffmpegRef.current;
setProgress(0);
const inputName = 'input.mp4';
const outputName = `output.${format}`;
const qualitySettings = {
low: { crf: '28', preset: 'ultrafast' },
medium: { crf: '23', preset: 'fast' },
high: { crf: '18', preset: 'slow' }
};
const settings = qualitySettings[quality];
try {
await ffmpeg.writeFile(inputName, await fetchFile(videoFile));
await ffmpeg.exec([
'-i', inputName,
'-c:v', 'libx264',
'-crf', settings.crf,
'-preset', settings.preset,
'-c:a', 'aac',
'-b:a', '128k',
outputName
]);
const data = await ffmpeg.readFile(outputName);
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);
return new Blob([data.buffer], { type: `video/${format}` });
} catch (err) {
console.error('Convert error:', err);
throw new Error('Failed to convert video: ' + err.message);
}
};
return {
isLoaded,
isLoading,
error,
progress,
trimVideo,
convertVideo
};
};
Video Processor Hook
src/hooks/useVideoProcessor.js:
import { useState } from 'react';
import { getVideoMetadata } from '../utils/videoUtils';
import { validateVideoFile } from '../utils/fileUtils';
export const useVideoProcessor = () => {
const [videoFile, setVideoFile] = useState(null);
const [videoURL, setVideoURL] = useState(null);
const [metadata, setMetadata] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState(null);
const loadVideo = async (file) => {
try {
setError(null);
setIsProcessing(true);
validateVideoFile(file);
const meta = await getVideoMetadata(file);
const url = URL.createObjectURL(file);
setVideoFile(file);
setVideoURL(url);
setMetadata(meta);
} catch (err) {
setError(err.message);
console.error('Load video error:', err);
} finally {
setIsProcessing(false);
}
};
const clearVideo = () => {
if (videoURL) {
URL.revokeObjectURL(videoURL);
}
setVideoFile(null);
setVideoURL(null);
setMetadata(null);
setError(null);
};
return {
videoFile,
videoURL,
metadata,
isProcessing,
error,
loadVideo,
clearVideo
};
};
Timeline Hook
src/hooks/useTimeline.js:
import { useState, useCallback } from 'react';
export const useTimeline = (duration = 0) => {
const [startTime, setStartTime] = useState(0);
const [endTime, setEndTime] = useState(duration);
const [currentTime, setCurrentTime] = useState(0);
const updateStartTime = useCallback((time) => {
const newStart = Math.max(0, Math.min(time, endTime - 0.1));
setStartTime(newStart);
if (currentTime < newStart) {
setCurrentTime(newStart);
}
}, [endTime, currentTime]);
const updateEndTime = useCallback((time) => {
const newEnd = Math.min(duration, Math.max(time, startTime + 0.1));
setEndTime(newEnd);
if (currentTime > newEnd) {
setCurrentTime(newEnd);
}
}, [duration, startTime, currentTime]);
const resetTimeline = useCallback((newDuration) => {
setStartTime(0);
setEndTime(newDuration);
setCurrentTime(0);
}, []);
const getTrimDuration = useCallback(() => {
return endTime - startTime;
}, [startTime, endTime]);
return {
startTime,
endTime,
currentTime,
setCurrentTime,
updateStartTime,
updateEndTime,
resetTimeline,
getTrimDuration
};
};
Component Files
Video Uploader Component
src/components/VideoUploader.jsx:
import React, { useRef } from 'react';
import { Upload, Film } from 'lucide-react';
import '../styles/VideoUploader.css';
const VideoUploader = ({ onVideoLoad, isProcessing }) => {
const fileInputRef = useRef(null);
const handleFileChange = (e) => {
const file = e.target.files?.[0];
if (file) {
onVideoLoad(file);
}
};
const handleDrop = (e) => {
e.preventDefault();
const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith('video/')) {
onVideoLoad(file);
}
};
const handleDragOver = (e) => {
e.preventDefault();
};
return (
<div className="video-uploader">
<div
className="upload-area"
onDrop={handleDrop}
onDragOver={handleDragOver}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="video/*"
onChange={handleFileChange}
style={{ display: 'none' }}
disabled={isProcessing}
/>
<div className="upload-content">
{isProcessing ? (
<>
<div className="spinner"></div>
<p>Loading video...</p>
</>
) : (
<>
<div className="upload-icon">
<Upload size={48} />
<Film size={48} className="film-icon" />
</div>
<h3>Drop video here or click to upload</h3>
<p className="upload-hint">
Supports MP4, WebM, OGG, MOV (max 500MB)
</p>
</>
)}
</div>
</div>
</div>
);
};
export default VideoUploader;
src/styles/VideoUploader.css:
.video-uploader {
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.upload-area {
border: 3px dashed #3b82f6;
border-radius: 16px;
padding: 60px 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
position: relative;
overflow: hidden;
}
.upload-area:hover {
border-color: #2563eb;
background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
}
.upload-area::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(59, 130, 246, 0.1) 0%, transparent 70%);
animation: pulse 3s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 0.5; }
50% { transform: scale(1.1); opacity: 0.8; }
}
.upload-content {
position: relative;
z-index: 1;
}
.upload-icon {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-bottom: 24px;
color: #3b82f6;
}
.film-icon {
color: #8b5cf6;
}
.upload-area h3 {
margin: 0 0 12px 0;
color: #1e293b;
font-size: 22px;
font-weight: 700;
}
.upload-hint {
color: #64748b;
font-size: 14px;
margin: 0;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid #e0f2fe;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
Video Player Component
src/components/VideoPlayer.jsx:
import React, { useRef, useEffect } from 'react';
import { Play, Pause, SkipBack, SkipForward } from 'lucide-react';
import { formatTime } from '../utils/formatTime';
import '../styles/VideoPlayer.css';
const VideoPlayer = ({
videoURL,
currentTime,
onTimeUpdate,
startTime,
endTime
}) => {
const videoRef = useRef(null);
const [isPlaying, setIsPlaying] = React.useState(false);
useEffect(() => {
if (videoRef.current) {
videoRef.current.currentTime = currentTime;
}
}, [currentTime]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
const time = video.currentTime;
if (time >= endTime) {
video.currentTime = startTime;
video.pause();
setIsPlaying(false);
}
onTimeUpdate(time);
};
video.addEventListener('timeupdate', handleTimeUpdate);
return () => video.removeEventListener('timeupdate', handleTimeUpdate);
}, [startTime, endTime, onTimeUpdate]);
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
if (video.currentTime >= endTime || video.currentTime < startTime) {
video.currentTime = startTime;
}
video.play();
setIsPlaying(true);
} else {
video.pause();
setIsPlaying(false);
}
};
const jumpTo = (offset) => {
const video = videoRef.current;
if (!video) return;
const newTime = Math.max(startTime, Math.min(endTime, video.currentTime + offset));
video.currentTime = newTime;
};
return (
<div className="video-player">
<div className="video-wrapper">
<video
ref={videoRef}
src={videoURL}
className="video-element"
/>
<div className="video-overlay">
<div className="time-display">
{formatTime(currentTime)}
</div>
</div>
</div>
<div className="player-controls">
<button onClick={() => jumpTo(-5)} className="control-btn">
<SkipBack size={20} />
<span>5s</span>
</button>
<button onClick={togglePlay} className="control-btn play-btn">
{isPlaying ? <Pause size={28} /> : <Play size={28} />}
</button>
<button onClick={() => jumpTo(5)} className="control-btn">
<SkipForward size={20} />
<span>5s</span>
</button>
</div>
</div>
);
};
export default VideoPlayer;
src/styles/VideoPlayer.css:
.video-player {
width: 100%;
max-width: 900px;
margin: 0 auto;
background: #000;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.video-wrapper {
position: relative;
width: 100%;
background: #000;
}
.video-element {
width: 100%;
display: block;
max-height: 500px;
object-fit: contain;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 20px;
}
.time-display {
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 16px;
border-radius: 8px;
font-family: 'Courier New', monospace;
font-size: 18px;
font-weight: 600;
backdrop-filter: blur(10px);
}
.player-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 20px;
background: linear-gradient(180deg, #1a1a1a 0%, #0a0a0a 100%);
}
.control-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 20px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 10px;
color: white;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
font-weight: 600;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.play-btn {
padding: 16px 24px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
border-color: transparent;
}
.play-btn:hover {
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
}
Timeline Component
src/components/Timeline.jsx:
import React, { useRef, useState, useEffect } from 'react';
import { Scissors } from 'lucide-react';
import { formatTime } from '../utils/formatTime';
import '../styles/Timeline.css';
const Timeline = ({
duration,
currentTime,
startTime,
endTime,
onStartTimeChange,
onEndTimeChange,
onSeek,
frames = []
}) => {
const timelineRef = useRef(null);
const [isDraggingStart, setIsDraggingStart] = useState(false);
const [isDraggingEnd, setIsDraggingEnd] = useState(false);
const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false);
const getTimeFromPosition = (clientX) => {
const rect = timelineRef.current.getBoundingClientRect();
const position = (clientX - rect.left) / rect.width;
return Math.max(0, Math.min(duration, position * duration));
};
const handleMouseDown = (e, type) => {
e.preventDefault();
if (type === 'start') setIsDraggingStart(true);
else if (type === 'end') setIsDraggingEnd(true);
else if (type === 'playhead') setIsDraggingPlayhead(true);
};
const handleMouseMove = (e) => {
if (!isDraggingStart && !isDraggingEnd && !isDraggingPlayhead) return;
const time = getTimeFromPosition(e.clientX);
if (isDraggingStart) {
onStartTimeChange(Math.min(time, endTime - 1));
} else if (isDraggingEnd) {
onEndTimeChange(Math.max(time, startTime + 1));
} else if (isDraggingPlayhead) {
onSeek(time);
}
};
const handleMouseUp = () => {
setIsDraggingStart(false);
setIsDraggingEnd(false);
setIsDraggingPlayhead(false);
};
useEffect(() => {
if (isDraggingStart || isDraggingEnd || isDraggingPlayhead) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}
}, [isDraggingStart, isDraggingEnd, isDraggingPlayhead, startTime, endTime]);
const handleTimelineClick = (e) => {
if (e.target.closest('.trim-handle') || e.target.closest('.playhead')) {
return;
}
const time = getTimeFromPosition(e.clientX);
onSeek(time);
};
const startPercent = (startTime / duration) * 100;
const endPercent = (endTime / duration) * 100;
const currentPercent = (currentTime / duration) * 100;
return (
<div className="timeline-container">
<div className="timeline-header">
<div className="trim-info">
<Scissors size={18} />
<span>Trim: {formatTime(startTime)} → {formatTime(endTime)}</span>
<span className="duration-badge">
{formatTime(endTime - startTime)}
</span>
</div>
</div>
<div
ref={timelineRef}
className="timeline"
onClick={handleTimelineClick}
>
{frames.length > 0 && (
<div className="timeline-frames">
{frames.map((frame, index) => (
<div key={index} className="frame-thumb">
<img src={frame} alt={`Frame ${index}`} />
</div>
))}
</div>
)}
<div className="timeline-track">
<div
className="trim-area"
style={{
left: `${startPercent}%`,
width: `${endPercent - startPercent}%`
}}
>
<div className="trim-overlay"></div>
</div>
<div
className="trim-handle trim-start"
style={{ left: `${startPercent}%` }}
onMouseDown={(e) => handleMouseDown(e, 'start')}
>
<div className="handle-grip"></div>
<div className="handle-time">{formatTime(startTime)}</div>
</div>
<div
className="trim-handle trim-end"
style={{ left: `${endPercent}%` }}
onMouseDown={(e) => handleMouseDown(e, 'end')}
>
<div className="handle-grip"></div>
<div className="handle-time">{formatTime(endTime)}</div>
</div>
<div
className="playhead"
style={{ left: `${currentPercent}%` }}
onMouseDown={(e) => handleMouseDown(e, 'playhead')}
>
<div className="playhead-line"></div>
<div className="playhead-handle"></div>
</div>
</div>
<div className="timeline-markers">
{[0, 0.25, 0.5, 0.75, 1].map((pos) => (
<div key={pos} className="marker" style={{ left: `${pos * 100}%` }}>
<div className="marker-line"></div>
<div className="marker-time">
{formatTime(duration * pos)}
</div>
</div>
))}
</div>
</div>
</div>
);
};
export default Timeline;
src/styles/Timeline.css:
.timeline-container {
width: 100%;
max-width: 900px;
margin: 30px auto;
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.timeline-header {
margin-bottom: 20px;
}
.trim-info {
display: flex;
align-items: center;
gap: 12px;
font-size: 16px;
color: #1e293b;
font-weight: 600;
}
.trim-info svg {
color: #3b82f6;
}
.duration-badge {
margin-left: auto;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
color: white;
padding: 6px 16px;
border-radius: 20px;
font-size: 14px;
}
.timeline {
position: relative;
height: 120px;
background: #f1f5f9;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
user-select: none;
}
.timeline-frames {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 80px;
display: flex;
gap: 2px;
}
.frame-thumb {
flex: 1;
overflow: hidden;
}
.frame-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.7;
}
.timeline-track {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.trim-area {
position: absolute;
top: 0;
bottom: 0;
background: rgba(59, 130, 246, 0.2);
border: 2px solid #3b82f6;
pointer-events: none;
}
.trim-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
}
.trim-handle {
position: absolute;
top: 0;
bottom: 0;
width: 20px;
margin-left: -10px;
cursor: ew-resize;
z-index: 10;
}
.handle-grip {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 4px;
margin-left: -2px;
background: linear-gradient(180deg, #3b82f6 0%, #8b5cf6 100%);
box-shadow: 0 0 8px rgba(59, 130, 246, 0.5);
}
.handle-grip::before,
.handle-grip::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 16px;
height: 16px;
background: white;
border: 3px solid #3b82f6;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.handle-grip::before {
top: -8px;
}
.handle-grip::after {
bottom: -8px;
}
.handle-time {
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: #1e293b;
color: white;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
font-family: 'Courier New', monospace;
}
.playhead {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
margin-left: -1px;
cursor: ew-resize;
z-index: 5;
}
.playhead-line {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 2px;
background: #ef4444;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6);
}
.playhead-handle {
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
width: 14px;
height: 14px;
background: #ef4444;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.timeline-markers {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
pointer-events: none;
}
.marker {
position: absolute;
bottom: 0;
transform: translateX(-50%);
}
.marker-line {
width: 1px;
height: 8px;
background: #94a3b8;
margin: 0 auto 4px;
}
.marker-time {
font-size: 11px;
color: #64748b;
font-family: 'Courier New', monospace;
white-space: nowrap;
}
Trim Controls Component
src/components/TrimControls.jsx:
import React, { useState } from 'react';
import { Scissors, Download, RotateCcw } from 'lucide-react';
import { formatTime } from '../utils/formatTime';
import '../styles/TrimControls.css';
const TrimControls = ({
startTime,
endTime,
duration,
onTrim,
onReset,
isProcessing
}) => {
const [quality, setQuality] = useState('medium');
const trimDuration = endTime - startTime;
const trimPercentage = ((trimDuration / duration) * 100).toFixed(1);
return (
<div className="trim-controls">
<div className="controls-grid">
<div className="control-group">
<label>Quality</label>
<select
value={quality}
onChange={(e) => setQuality(e.target.value)}
disabled={isProcessing}
>
<option value="low">Low (Fast, Smaller)</option>
<option value="medium">Medium (Balanced)</option>
<option value="high">High (Slow, Larger)</option>
</select>
</div>
<div className="stats-group">
<div className="stat-item">
<span className="stat-label">Original</span>
<span className="stat-value">{formatTime(duration)}</span>
</div>
<div className="stat-item">
<span className="stat-label">Trimmed</span>
<span className="stat-value highlight">{formatTime(trimDuration)}</span>
</div>
<div className="stat-item">
<span className="stat-label">Kept</span>
<span className="stat-value">{trimPercentage}%</span>
</div>
</div>
</div>
<div className="action-buttons">
<button
onClick={onReset}
disabled={isProcessing}
className="btn-secondary"
>
<RotateCcw size={20} />
Reset
</button>
<button
onClick={() => onTrim(quality)}
disabled={isProcessing || trimDuration < 0.1}
className="btn-primary"
>
{isProcessing ? (
<>
<div className="spinner-small"></div>
Processing...
</>
) : (
<>
<Scissors size={20} />
Trim & Export
</>
)}
</button>
</div>
</div>
);
};
export default TrimControls;
src/styles/TrimControls.css:
.trim-controls {
width: 100%;
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.controls-grid {
display: grid;
grid-template-columns: 250px 1fr;
gap: 24px;
margin-bottom: 24px;
}
.control-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #1e293b;
font-size: 14px;
}
.control-group select {
width: 100%;
padding: 12px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
background: white;
cursor: pointer;
transition: all 0.2s;
}
.control-group select:hover:not(:disabled) {
border-color: #3b82f6;
}
.control-group select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.stats-group {
display: flex;
gap: 24px;
align-items: center;
padding: 12px 0;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: #64748b;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: #1e293b;
font-family: 'Courier New', monospace;
}
.stat-value.highlight {
color: #3b82f6;
}
.action-buttons {
display: grid;
grid-template-columns: 150px 1fr;
gap: 12px;
}
.action-buttons button {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px 24px;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary {
background: #f1f5f9;
color: #475569;
border: 2px solid #e2e8f0;
}
.btn-secondary:hover:not(:disabled) {
background: #e2e8f0;
border-color: #cbd5e1;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
color: white;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
}
.action-buttons button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.spinner-small {
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.controls-grid {
grid-template-columns: 1fr;
}
.stats-group {
justify-content: space-around;
}
.action-buttons {
grid-template-columns: 1fr;
}
}
Progress Modal Component
src/components/ProgressModal.jsx:
import React from 'react';
import { CheckCircle, AlertCircle } from 'lucide-react';
import '../styles/ProgressModal.css';
const ProgressModal = ({ isOpen, progress, status, onClose }) => {
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div className="modal-content">
{status === 'processing' && (
<>
<div className="progress-circle">
<svg viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="45"
className="progress-bg"
/>
<circle
cx="50"
cy="50"
r="45"
className="progress-bar"
style={{
strokeDasharray: `${progress * 2.827} 282.7`
}}
/>
</svg>
<div className="progress-text">
{progress}%
</div>
</div>
<h3>Processing Video</h3>
<p>Trimming and encoding your video...</p>
</>
)}
{status === 'complete' && (
<>
<div className="status-icon success">
<CheckCircle size={64} />
</div>
<h3>Export Complete!</h3>
<p>Your video has been processed successfully.</p>
<button onClick={onClose} className="btn-close">
Done
</button>
</>
)}
{status === 'error' && (
<>
<div className="status-icon error">
<AlertCircle size={64} />
</div>
<h3>Export Failed</h3>
<p>Something went wrong during processing.</p>
<button onClick={onClose} className="btn-close">
Close
</button>
</>
)}
</div>
</div>
);
};
export default ProgressModal;
src/styles/ProgressModal.css:
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: white;
border-radius: 20px;
padding: 48px;
max-width: 400px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.progress-circle {
position: relative;
width: 150px;
height: 150px;
margin: 0 auto 24px;
}
.progress-circle svg {
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.progress-bg {
fill: none;
stroke: #e2e8f0;
stroke-width: 8;
}
.progress-bar {
fill: none;
stroke: url(#gradient);
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dasharray 0.3s ease;
}
.progress-circle::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
border-radius: 50%;
opacity: 0.1;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 36px;
font-weight: 700;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.status-icon {
width: 120px;
height: 120px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.status-icon.success {
color: #10b981;
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
}
.status-icon.error {
color: #ef4444;
background: linear-gradient(135deg, #fee2e2, #fecaca);
}
.modal-content h3 {
margin: 0 0 12px 0;
font-size: 24px;
color: #1e293b;
}
.modal-content p {
margin: 0 0 24px 0;
color: #64748b;
font-size: 16px;
}
.btn-close {
padding: 14px 48px;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-close:hover {
background: linear-gradient(135deg, #2563eb, #7c3aed);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
}
Main Application Component
src/App.jsx:
import React, { useState, useEffect } from 'react';
import { Film, Info } from 'lucide-react';
import VideoUploader from './components/VideoUploader';
import VideoPlayer from './components/VideoPlayer';
import Timeline from './components/Timeline';
import TrimControls from './components/TrimControls';
import ProgressModal from './components/ProgressModal';
import { useFFmpeg } from './hooks/useFFmpeg';
import { useVideoProcessor } from './hooks/useVideoProcessor';
import { useTimeline } from './hooks/useTimeline';
import { extractVideoFrames } from './utils/videoUtils';
import { downloadBlob } from './utils/fileUtils';
import './styles/App.css';
function App() {
const {
isLoaded: ffmpegLoaded,
isLoading: ffmpegLoading,
progress: ffmpegProgress,
trimVideo
} = useFFmpeg();
const {
videoFile,
videoURL,
metadata,
isProcessing: videoLoading,
error: videoError,
loadVideo,
clearVideo
} = useVideoProcessor();
const {
startTime,
endTime,
currentTime,
setCurrentTime,
updateStartTime,
updateEndTime,
resetTimeline,
getTrimDuration
} = useTimeline(metadata?.duration || 0);
const [frames, setFrames] = useState([]);
const [isExporting, setIsExporting] = useState(false);
const [exportStatus, setExportStatus] = useState(null);
const [showModal, setShowModal] = useState(false);
useEffect(() => {
if (metadata?.duration) {
resetTimeline(metadata.duration);
}
}, [metadata, resetTimeline]);
useEffect(() => {
if (videoFile && metadata) {
extractVideoFrames(videoFile, 20)
.then(setFrames)
.catch(console.error);
}
}, [videoFile, metadata]);
const handleVideoLoad = async (file) => {
clearVideo();
setFrames([]);
await loadVideo(file);
};
const handleTrim = async (quality) => {
if (!videoFile) return;
setIsExporting(true);
setExportStatus('processing');
setShowModal(true);
try {
const trimmedBlob = await trimVideo(
videoFile,
startTime,
endTime,
'trimmed.mp4'
);
const fileName = `trimmed-${Date.now()}.mp4`;
downloadBlob(trimmedBlob, fileName);
setExportStatus('complete');
} catch (error) {
console.error('Export error:', error);
setExportStatus('error');
} finally {
setIsExporting(false);
}
};
const handleReset = () => {
if (metadata?.duration) {
resetTimeline(metadata.duration);
}
};
const handleModalClose = () => {
setShowModal(false);
setExportStatus(null);
};
const handleNewVideo = () => {
clearVideo();
setFrames([]);
};
return (
<div className="app">
<header className="app-header">
<div className="header-content">
<div className="logo">
<Film size={32} />
<h1>Video Trimmer</h1>
</div>
<div className="header-info">
{ffmpegLoading && (
<span className="status-badge loading">Loading FFmpeg...</span>
)}
{ffmpegLoaded && (
<span className="status-badge ready">FFmpeg Ready</span>
)}
</div>
</div>
</header>
<main className="app-main">
{!ffmpegLoaded && (
<div className="loading-screen">
<div className="spinner-large"></div>
<h2>Loading Video Processor</h2>
<p>Initializing FFmpeg WebAssembly...</p>
</div>
)}
{ffmpegLoaded && !videoFile && (
<div className="upload-section">
<VideoUploader
onVideoLoad={handleVideoLoad}
isProcessing={videoLoading}
/>
<div className="info-box">
<Info size={20} />
<div>
<h4>How it works</h4>
<ul>
<li>Upload your video file (MP4, WebM, MOV)</li>
<li>Drag the timeline handles to select trim points</li>
<li>Preview your selection in real-time</li>
<li>Export the trimmed video instantly</li>
</ul>
<p className="note">
All processing happens in your browser. No uploads required!
</p>
</div>
</div>
</div>
)}
{ffmpegLoaded && videoFile && metadata && (
<div className="editor-section">
<div className="editor-header">
<h2>{videoFile.name}</h2>
<button onClick={handleNewVideo} className="btn-new">
Load New Video
</button>
</div>
<VideoPlayer
videoURL={videoURL}
currentTime={currentTime}
onTimeUpdate={setCurrentTime}
startTime={startTime}
endTime={endTime}
/>
<Timeline
duration={metadata.duration}
currentTime={currentTime}
startTime={startTime}
endTime={endTime}
onStartTimeChange={updateStartTime}
onEndTimeChange={updateEndTime}
onSeek={setCurrentTime}
frames={frames}
/>
<TrimControls
startTime={startTime}
endTime={endTime}
duration={metadata.duration}
onTrim={handleTrim}
onReset={handleReset}
isProcessing={isExporting}
/>
</div>
)}
{videoError && (
<div className="error-banner">
<AlertCircle size={24} />
<p>{videoError}</p>
</div>
)}
</main>
<ProgressModal
isOpen={showModal}
progress={ffmpegProgress}
status={exportStatus}
onClose={handleModalClose}
/>
<svg style={{ display: 'none' }}>
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#8b5cf6" />
</linearGradient>
</defs>
</svg>
</div>
);
}
export default App;
src/styles/App.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.app-header {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
color: #1e293b;
}
.logo svg {
color: #3b82f6;
}
.logo h1 {
font-size: 24px;
font-weight: 700;
}
.header-info {
display: flex;
gap: 12px;
}
.status-badge {
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.status-badge.loading {
background: #fef3c7;
color: #92400e;
}
.status-badge.ready {
background: #d1fae5;
color: #065f46;
}
.app-main {
max-width: 1200px;
margin: 0 auto;
padding: 40px 24px;
}
.loading-screen {
text-align: center;
padding: 100px 20px;
color: white;
}
.spinner-large {
width: 60px;
height: 60px;
border: 5px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 24px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-screen h2 {
margin-bottom: 12px;
font-size: 28px;
}
.loading-screen p {
font-size: 16px;
opacity: 0.9;
}
.upload-section {
display: flex;
flex-direction: column;
gap: 30px;
}
.info-box {
background: white;
border-radius: 16px;
padding: 24px;
display: flex;
gap: 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.info-box svg {
color: #3b82f6;
flex-shrink: 0;
margin-top: 4px;
}
.info-box h4 {
margin-bottom: 12px;
color: #1e293b;
font-size: 18px;
}
.info-box ul {
margin: 0 0 16px 20px;
color: #475569;
}
.info-box li {
margin-bottom: 8px;
line-height: 1.6;
}
.info-box .note {
margin: 0;
padding: 12px;
background: #f0f9ff;
border-left: 4px solid #3b82f6;
border-radius: 4px;
color: #1e40af;
font-size: 14px;
font-weight: 600;
}
.editor-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.editor-header h2 {
color: #1e293b;
font-size: 20px;
font-weight: 600;
}
.btn-new {
padding: 10px 20px;
background: #f1f5f9;
border: 2px solid #e2e8f0;
border-radius: 8px;
color: #475569;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-new:hover {
background: #e2e8f0;
border-color: #cbd5e1;
}
.error-banner {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fee2e2;
border: 2px solid #fecaca;
border-radius: 12px;
color: #991b1b;
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 16px;
}
.editor-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.btn-new {
width: 100%;
}
}
Entry Point
src/main.jsx:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
src/index.css:
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
}
Usage Instructions
-
Start Development Server:
npm run dev -
Upload a Video:
- Click or drag-drop a video file
- Supported formats: MP4, WebM, MOV, OGG
-
Trim Video:
- Drag blue handles on timeline to set start/end points
- Or drag the red playhead to scrub through
- Preview your selection in the player
-
Export:
- Choose quality settings (low/medium/high)
- Click “Trim & Export”
- Download starts automatically
-
Build for Production:
npm run build npm run preview
Key Features
- Zero Backend: All processing in browser with FFmpeg.wasm
- Visual Timeline: Drag handles with frame thumbnails
- Real-time Preview: See exactly what you’re trimming
- Quality Options: Choose between speed and file size
- Progress Tracking: Live progress during export
- Responsive Design: Works on desktop and tablet
- No Upload Required: Privacy-focused local processing
Performance Tips
- Use medium quality for balanced speed/quality
- Shorter clips process faster
- Avoid extremely large files (>500MB)
- Close other browser tabs during processing
- Use Chrome/Edge for best performance
Browser Support
- Chrome 94+
- Edge 94+
- Firefox 93+
- Safari 16.4+
Requires SharedArrayBuffer support (COOP/COEP headers).
Troubleshooting
FFmpeg won’t load:
- Check console for COOP/COEP header errors
- Ensure you’re using the Vite dev server
- Try clearing browser cache
Video won’t upload:
- Verify file format is supported
- Check file size < 500MB
- Ensure video isn’t corrupted
Export fails:
- Try lower quality setting
- Check available RAM
- Reduce trim length
Conclusion
You now have a complete video trimmer running entirely in the browser. FFmpeg.wasm provides powerful video processing without servers. The visual timeline makes precise trimming intuitive, and the modern UI ensures a great user experience.
All code is production-ready with proper error handling, loading states, and responsive design. Customize the styling and features to match your needs!
Related Articles
React Webcam: Capture Photos and Record Videos in Browser
Complete guide to using webcam in React. Take photos, record videos, download captures, and handle camera permissions with working examples.
Getting Started with React Hooks in 2025
Learn how to use React Hooks effectively in your modern React applications with practical examples and best practices.
How to integrate jsPDF Library in React to Edit PDF in Browser
Quick guide to using jsPDF in React applications for creating and editing PDF documents directly in the browser.