You may think that video processing can only be performed on a server, but this is not true. Thanks to WebAssembly (Wasm), you can run high-performance code written in C or C++ in your browser. This opens up a lot of possibilities and gives you the ability to build a client-side video editor. Let’s now learn how to create a Wasm-based video editor in React.

Follow this tutorial and learn how to build the application below:

Upload and crop your video, and convert it to a GIF

This React video editor allows users to upload a video, select a portion of it, convert it to GIF, and download the resulting image file, all of this in your browser.

Updated September 2026.

What is WebAssembly?

WebAssembly (also called Wasm) is a new type of code that modern web browsers can run and understand. Specifically, Wasm provides new functionality to web development and brings significant performance benefits. This applies to both frontend and backend, since Wasm can be used by both clients and servers.

Wasm code should not be written by hand. This is because Wasm is a binary instruction format. So, Wasm is designed to be a compilation target for source languages such as C, C++, Rust, and others. Learn more about Wasm on the WebAssembly MDN Web Docs page.

Wasm provides a way to run code written in several languages on the Web at near-native speed. This means that WebAssembly forms can be easily imported and executed by a Web client or server application. In other words, you can use WebAssembly functions via JavaScript and achieve results that were not previously possible on a browser, especially in terms of performance.

What is ffmpeg.wasm?

As stated on the GitHub page of the project, ffmpeg.wasm is a WebAssembly and JavaScript port of FFmpeg. If you are not familiar with it, FFmpeg is an open-source software suite that includes several libraries and programs for managing audio, video, streams, and other media files. All of these tools can be invoked and executed via the ffmpeg command-line instruction.

Therefore, ffmpeg.wasm enables video and audio processing in JavaScript-based web applications. In detail, it enables video and audio recording, format transcoding, video and audio editing, and video scaling. Even though it is a WebAssembly-based library, you can use it in your JavaScript code just like any other npm library. This is the power of Wasm.

Since it transpiles to Wasm, you can take advantage of ffmpeg.wasm directly in your browser. Keep in mind that it is still slower than native FFmpeg, as WebAssembly does not run as fast as native code yet. This means that ffmpeg.wasm opens the door to client-side audio and video processing. Let’s now learn how to use ffmpeg.wasm to build a client-side video editor in React!

Building a Video Editor in React with WASM

In this step-by-step tutorial, you will learn how to build a Wasm-based video editor in React with ffmpeg.wasm. This React video editor application will allow you to upload a video, trim it, and convert it to GIF in the browser, without using any backend functionality or external API.

If you want to see a complete, runnable setup before you start, check out the official React and Vite example in the ffmpeg.wasm repository. It is built around the multi-thread core and the cross-origin isolation headers that core needs, on React 18 and Vite 4, so treat it as a reference rather than a base for the single-thread project you are about to build.

Let’s waste no more time and see how to build a video editor application in React.

Prerequisites

This is the list of libraries the video editor application you are about to build depends on:

antd is one of the most popular UI libraries for React. In this tutorial, its Slider component will be used to implement the video cutting feature. So, any other UI library including a slider component with a range option will do.

To play the video, you will use the native HTML5 <video> element, so you do not need a separate video player library.

Initializing a React Project

Let’s initialize a new Vite React project called video-editor-wasm-react with the command below:

npm create vite@latest video-editor-wasm-react -- --template react

Your video-editor-wasm-react directory should now contain the following files:

video-editor-wasm-react
├── .gitignore
├── .oxlintrc.json
├── README.md
├── index.html
├── package.json
├── public
│   ├── favicon.svg
│   └── icons.svg
├── src
│   ├── App.css
│   ├── App.jsx
│   ├── assets
│   │   ├── hero.png
│   │   ├── react.svg
│   │   └── vite.svg
│   ├── index.css
│   └── main.jsx
└── vite.config.js

Enter the video-editor-wasm-react folder in your terminal, install the dependencies, and launch a local development server with the commands below:

cd video-editor-wasm-react
npm install
npm run dev

Now, visit the http://localhost:5173/ page in your browser, and you should be seeing the default Vite and React starter page.

Before installing ffmpeg.wasm, you need to choose a core. ffmpeg.wasm comes with a single-thread core (@ffmpeg/core) and a multi-thread core (@ffmpeg/core-mt). This tutorial uses the single-thread core, which does not require any extra server configuration. Both cores are licensed GPL-2.0-or-later, unlike the @ffmpeg/ffmpeg wrapper, which is MIT, so check what that means for your product before you ship one.

The multi-thread core runs around twice as fast, but it uses a lot more memory and CPU, and it depends on SharedArrayBuffer. In the most popular browsers, SharedArrayBuffer is only available to cross-origin isolated webpages. So, if you pick the multi-thread core, you need to set the following headers in your server hosting the React application:

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

In a Vite project, you can add them to the server.headers option in vite.config.js.

The headers are not the only difference. To pick the multi-thread core you point the CDN path you will write further down at @ffmpeg/core-mt instead of @ffmpeg/core, and that core spawns its own pthread workers from an ffmpeg-core.worker.js file shipped next to it. When you hand load() ordinary URLs, it finds that file by itself, swapping the .js on the end of coreURL for .worker.js. The toBlobURL() pattern in this tutorial defeats that, because a blob URL has no .js on the end to swap, so the ffmpeg.load() call needs a third entry, workerURL, built the same way as the other two. Leave it out and load() never settles, even with the headers in place and SharedArrayBuffer available: the editor sits on its loading spinner forever. The single-thread core ships no worker file and takes only the two URLs.

Whichever core you choose, Vite needs one extra setting, which you can also find in the official React and Vite example: the ffmpeg.wasm packages have to be excluded from dependency optimization. Update your vite.config.js file as below:

// vite.config.js

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util'],
  },
});

Your local development server is now ready to use ffmpeg.wasm!

Installing the Project’s Dependencies

It is now time to add the aforementioned required dependencies to your project.

First, install ffmpeg.wasm with the following command:

npm install @ffmpeg/ffmpeg @ffmpeg/util

The @ffmpeg/util package contains helper functions such as fetchFile() and toBlobURL(), which you will use later on.

Now, let’s install Ant Design:

npm install antd

You have now everything you need to start developing your React video editor!

Uploading a Video to the Video Editor

Create a VideoUpload component as below:

// src/components/VideoUpload.jsx

import { Button, Upload } from 'antd';

function VideoUpload({ disabled, onChange = () => {}, onRemove = () => {} }) {
  return (
    <>
      <Upload
        disabled={disabled}
        beforeUpload={() => {
          return false;
        }}
        accept="video/*"
        onChange={(info) => {
          if (info.fileList && info.fileList.length > 0) {
            // antd keeps every file picked since the page loaded,
            // so the last entry is the one the user just chose
            onChange(info.fileList[info.fileList.length - 1].originFileObj);
          }
        }}
        showUploadList={false}
      >
        <Button>Upload Video</Button>
      </Upload>
      <Button
        danger={true}
        disabled={!disabled}
        onClick={() => {
          onRemove(undefined);
        }}
      >
        Remove
      </Button>
    </>
  );
}

export default VideoUpload;

As you can see, this component uses the Ant Design Upload component to allow users to upload a video file. Then, a “Remove” button gives users the ability to remove the uploaded video. Note that when the Upload component is enabled, the “Remove” button is disabled and vice versa. This way, users can deal with only one video at a time. The onChange handler reads the last entry of info.fileList rather than the first because the Upload component is uncontrolled here: antd keeps its own list of every file picked since the page loaded, and the “Remove” button only clears the app’s state, so after a remove the first entry is still the file from before.

Playing a Video in React

You now need a VideoPlayer component to play the uploaded video. You can build it as below:

// src/components/VideoPlayer.jsx

export function VideoPlayer({ ref, src, onChange = () => {} }) {
  const updateState = (event) => {
    const video = event.currentTarget;
    onChange({ currentTime: video.currentTime, duration: video.duration });
  };

  return (
    <div className={'video-player'}>
      <video
        ref={ref}
        src={src}
        controls
        onLoadedMetadata={updateState}
        onTimeUpdate={updateState}
      />
    </div>
  );
}

This is a simple video player component based on the HTML5 <video> element. It plays the video file received with the src prop and reports the current time and duration of the video through the onChange prop. Since React 19 lets function components receive ref as a regular prop, the parent component can use it to control the playback position.

Cutting a Video in React

Your goal is to allow users to select a portion of the video and watch it before converting it into GIF. Implement this with the logic below:

import { Slider } from 'antd';
import { useRef, useState } from 'react';
import { sliderValueToVideoTime } from '../utils/utils';

function VideoEditor() {
  const [sliderValues, setSliderValues] = useState([0, 100]);
  const [videoPlayerState, setVideoPlayerState] = useState();
  const videoPlayerRef = useRef(null);

  // logic to handle videoFile...

  const onSliderChange = (values) => {
    setSliderValues(values);
    // when the slider values are updated, updating the
    // video time
    if (videoPlayerState && videoPlayerRef.current) {
      videoPlayerRef.current.currentTime = sliderValueToVideoTime(
        videoPlayerState.duration,
        values[0]
      );
    }
  };

  const onPlayerChange = (playerState) => {
    setVideoPlayerState(playerState);
    // allowing users to watch only the portion of
    // the video selected by the slider
    const [min, max] = sliderValues;
    const minTime = sliderValueToVideoTime(playerState.duration, min);
    const maxTime = sliderValueToVideoTime(playerState.duration, max);

    if (
      playerState.currentTime < minTime ||
      playerState.currentTime > maxTime
    ) {
      // looping logic
      videoPlayerRef.current.currentTime = minTime;
    }
  };

  return (
    <>
      {/* other video editor components... */}
      <Slider
        disabled={!videoPlayerState}
        value={sliderValues}
        range={true}
        onChange={onSliderChange}
        tooltip={{
          formatter: null,
        }}
      />
    </>
  );
}

This component equips users with the ability to select a portion of the uploaded video with the Ant Design Slider component and watch it in loop, before converting it into a GIF file. The onSliderChange() handler moves the video to the start of the selection, while onPlayerChange() jumps back to the start whenever the playback leaves the selected portion.

Specifically, the logic here uses the sliderValueToVideoTime() function below to transform the slider values into time coordinates:

// src/utils/utils.js

export function sliderValueToVideoTime(duration, sliderValue) {
  return Math.round((duration * sliderValue) / 100);
}

Note that the actual video processing slider will be performed by the next component through ffmpeg.

Converting a Video to GIF in React

To convert a video to GIF, create a VideoConversionButton component as follows:

// src/components/VideoConversionButton.jsx

import { Button, message } from 'antd';
import { fetchFile } from '@ffmpeg/util';
import { sliderValueToVideoTime } from '../utils/utils';

function VideoConversionButton({
  videoPlayerState,
  sliderValues,
  videoFile,
  ffmpeg,
  onConversionStart = () => {},
  onConversionEnd = () => {},
  onGifCreated = () => {},
}) {
  const convertToGif = async () => {
    // starting the conversion process
    onConversionStart(true);

    const inputFileName = 'gif.mp4';
    const outputFileName = 'output.gif';

    try {
      // writing the video file to memory
      await ffmpeg.writeFile(inputFileName, await fetchFile(videoFile));

      const [min, max] = sliderValues;
      const minTime = sliderValueToVideoTime(videoPlayerState.duration, min);
      const maxTime = sliderValueToVideoTime(videoPlayerState.duration, max);

      // cutting the video and converting it to GIF with an FFmpeg command
      await ffmpeg.exec([
        '-i',
        inputFileName,
        '-ss',
        `${minTime}`,
        '-to',
        `${maxTime}`,
        '-f',
        'gif',
        outputFileName,
      ]);

      // reading the resulting file
      const data = await ffmpeg.readFile(outputFileName);

      // converting the GIF file created by FFmpeg to a valid image URL
      const gifUrl = URL.createObjectURL(
        new Blob([data], { type: 'image/gif' })
      );
      onGifCreated(gifUrl);
    } catch (error) {
      // exec() resolves even when FFmpeg writes no output, so the
      // throw comes from readFile(); show it instead of swallowing it
      message.error(`Conversion failed: ${error}`);
    } finally {
      // ending the conversion process, even when a step above throws
      onConversionEnd(false);
    }
  };

  return (
    <Button
      disabled={!videoFile || !videoPlayerState}
      onClick={() => convertToGif()}
    >
      Convert to GIF
    </Button>
  );
}

export default VideoConversionButton;

This component uses ffmpeg.wasm to launch an ffmpeg command. This command takes care of clipping the video to the time limits defined by sliderValues and converting it to GIF. Note that all these operations are performed client-side in the browser. Since ffmpeg.wasm runs FFmpeg in a web worker, writeFile(), exec(), and readFile() are all asynchronous, so you need to await each of them.

Two details keep the button honest. It stays disabled until a video has been uploaded and the player has reported its duration, because videoPlayerState is undefined before that and reading duration off it would throw. And the conversion body sits in a try/catch/finally: the catch shows the error in an antd message, so a failed conversion does not end as a silent unhandled rejection, and onConversionEnd() runs in the finally even when one of the awaited calls throws. That is not a hypothetical: exec() does not reject on a file FFmpeg cannot handle, it resolves with a non-zero exit code, and the throw arrives one line later from readFile() looking for an output that was never written. Without the finally, the loading overlay you are about to add would stay up and block every other control on the page.

In detail, the ffmpeg -ss flag defines the timestamp the output starts at, while -to defines the timestamp it stops at. Both sit after -i here, which makes them output options: FFmpeg decodes the input from the beginning and discards frames until it reaches -ss. Placing -ss before -i instead would seek in the input file, which is faster on a long video, but it also resets the output timestamps, so a -to left behind after -i would have to carry the length of the selection rather than its end time. Move -to in front of -i as well and it goes on meaning an end time, because it is an input option too. Then, the gif option specifies that you want to produce a GIF file. Learn more about the flags and options offered by ffmpeg here.

The resulting GIF image is stored in the output.gif memory file, loaded as a valid "image/gif" Blob, and finally converted to a valid image URL with the native URL.createObjectURL() function.

Putting It All Together

Now, let’s see the full code of the VideoEditor component:

// src/components/VideoEditor.jsx

import { FFmpeg } from '@ffmpeg/ffmpeg';
import { toBlobURL } from '@ffmpeg/util';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button, Slider, Spin } from 'antd';
import { VideoPlayer } from './VideoPlayer';
import { sliderValueToVideoTime } from '../utils/utils';
import VideoUpload from './VideoUpload';
import VideoConversionButton from './VideoConversionButton';

const ffmpeg = new FFmpeg();
// the single-thread ffmpeg.wasm core, served from a CDN
const baseURL = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm';
let ffmpegLoading;

function loadFFmpeg() {
  // loading ffmpeg only once, even if the component renders twice
  if (!ffmpegLoading) {
    ffmpeg.on('log', ({ message }) => console.log(message));
    ffmpegLoading = (async () => {
      await ffmpeg.load({
        coreURL: await toBlobURL(
          `${baseURL}/ffmpeg-core.js`,
          'text/javascript'
        ),
        wasmURL: await toBlobURL(
          `${baseURL}/ffmpeg-core.wasm`,
          'application/wasm'
        ),
      });
    })();
  }
  return ffmpegLoading;
}

function VideoEditor() {
  const [ffmpegLoaded, setFFmpegLoaded] = useState(false);
  const [videoFile, setVideoFile] = useState();
  const [videoPlayerState, setVideoPlayerState] = useState();
  const videoPlayerRef = useRef(null);
  const [gifUrl, setGifUrl] = useState();
  const [sliderValues, setSliderValues] = useState([0, 100]);
  const [processing, setProcessing] = useState(false);

  // creating the video URL once per uploaded file
  const videoUrl = useMemo(
    () => (videoFile ? URL.createObjectURL(videoFile) : undefined),
    [videoFile]
  );

  useEffect(() => {
    // loading ffmpeg on startup
    loadFFmpeg().then(() => {
      setFFmpegLoaded(true);
    });
  }, []);

  const onSliderChange = (values) => {
    setSliderValues(values);
    // when the slider values are updated, updating the
    // video time
    if (videoPlayerState && videoPlayerRef.current) {
      videoPlayerRef.current.currentTime = sliderValueToVideoTime(
        videoPlayerState.duration,
        values[0]
      );
    }
  };

  const onPlayerChange = (playerState) => {
    setVideoPlayerState(playerState);
    // allowing users to watch only the portion of
    // the video selected by the slider
    const [min, max] = sliderValues;
    const minTime = sliderValueToVideoTime(playerState.duration, min);
    const maxTime = sliderValueToVideoTime(playerState.duration, max);

    if (
      playerState.currentTime < minTime ||
      playerState.currentTime > maxTime
    ) {
      // looping logic
      videoPlayerRef.current.currentTime = minTime;
    }
  };

  const onVideoRemove = () => {
    // when the current videoFile is removed,
    // restoring the default state
    setVideoFile(undefined);
    setVideoPlayerState(undefined);
    setSliderValues([0, 100]);
    setGifUrl(undefined);
  };

  return (
    <div>
      <Spin
        spinning={processing || !ffmpegLoaded}
        description={
          !ffmpegLoaded ? 'Waiting for FFmpeg to load...' : 'Processing...'
        }
      >
        <div>
          {videoFile ? (
            <VideoPlayer
              ref={videoPlayerRef}
              src={videoUrl}
              onChange={onPlayerChange}
            />
          ) : (
            <h1>Upload a video</h1>
          )}
        </div>
        <div className={'upload-div'}>
          <VideoUpload
            disabled={!!videoFile}
            onChange={(videoFile) => {
              setVideoFile(videoFile);
            }}
            onRemove={onVideoRemove}
          />
        </div>
        <div className={'slider-div'}>
          <h3>Cut Video</h3>
          <Slider
            disabled={!videoPlayerState}
            value={sliderValues}
            range={true}
            onChange={onSliderChange}
            tooltip={{
              formatter: null,
            }}
          />
        </div>
        <div className={'conversion-div'}>
          <VideoConversionButton
            onConversionStart={() => {
              setProcessing(true);
            }}
            onConversionEnd={() => {
              setProcessing(false);
            }}
            ffmpeg={ffmpeg}
            videoPlayerState={videoPlayerState}
            sliderValues={sliderValues}
            videoFile={videoFile}
            onGifCreated={(gifUrl) => {
              setGifUrl(gifUrl);
            }}
          />
        </div>
        {gifUrl && (
          <div className={'gif-div'}>
            <h3>Resulting GIF</h3>
            <img
              src={gifUrl}
              className={'gif'}
              alt={'GIF file generated in the client side'}
            />
            <Button href={gifUrl} download={'test.gif'}>
              Download
            </Button>
          </div>
        )}
      </Spin>
    </div>
  );
}

export default VideoEditor;

The loadFFmpeg() function downloads the single-thread core, which is about 31 MB, from a CDN. toBlobURL() is used to bypass CORS issues, while URLs on your own domain can be passed to load() directly. As React’s Strict Mode runs effects twice in development, loadFFmpeg() keeps track of the loading process so that the core is only loaded once.

Since ffmpeg takes time to load, you need to let users know that they need to wait a while before they can use the video editor component. Also, ffmpeg operations take time to execute. This wait logic is implemented by using the Ant Design Spin component.

Preventing users from using the video editor component before ffmpeg is loaded

Also, note that the ffmpeg.on('log', ...) listener prints FFmpeg’s log messages in the browser console, which is useful to debug your commands.

Finally, render the VideoEditor component in your src/App.jsx file:

// src/App.jsx

import VideoEditor from './components/VideoEditor';

function App() {
  return <VideoEditor />;
}

export default App;

What the VideoEditor component does is use all the components presented earlier to allow users to upload a video, select a portion through a slider, convert it to GIF, and download the resulting GIF file. The video-player, upload-div, slider-div, conversion-div, gif-div and gif class names are there as styling hooks only. No CSS ships with this tutorial, so the layout is yours to write. Put your rules in index.css, which main.jsx still imports: the App.jsx above replaces the one the scaffold generated, and with it the import './App.css' line, so anything you write in App.css never reaches the page.

Et voilà! You just learned how to build a client-side video editor in React without using any backend functionality!

Commercial Alternative

IMG.LY’s Video Editor SDK for Web provides a ready-made video editor with powerful video editing features, including cropping and trimming videos in your project. You will receive staples of video editing, including straightening videos, filters, exposure and color adjustments, and more.

Conclusion

Building a client-side video processing and editing application that uses only the browser is possible. With WebAssembly, you can run high-performance code written in C or C++ in JavaScript. This allows you to perform complex operations directly in the browser, without having to delegate the work to a server.

In this tutorial, you learned how to use ffmpeg.wasm to build a video editor in React. ffmpeg.wasm is the Wasm port of ffmpeg and enables video and audio processing and editing directly in the browser. Using it to build a web application to upload a video, cut it, and export it to GIF requires only a few lines of code, and here we have seen how to implement such a video editing application. To compare ffmpeg.wasm with other browser technologies such as WebCodecs, read our JavaScript video editing guide.

If your app goes beyond merely displaying video, and you want to allow your users to also edit video or create video based templates in the browser, explore our Video Editor SDK for Web, a video editing solution that renders client-side with WebCodecs and WebGL. Try the editor for yourself in action with our showcases.

FAQ

Does ffmpeg.wasm need SharedArrayBuffer? Only the multi-thread core does. The single-thread core used in this tutorial works without it, while the multi-thread core requires SharedArrayBuffer, so your page has to be cross-origin isolated with the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers.

What is the maximum file size ffmpeg.wasm can process? 2 GB. That ceiling is compiled into the published core rather than imposed by WebAssembly: the @ffmpeg/core module declares a maximum memory of 32768 pages, which is the Emscripten MAXIMUM_MEMORY default, while a 32-bit WebAssembly memory can be declared up to 4 GB. The ffmpeg.wasm maintainers say the published limit might become 4 GB in the future.

Is ffmpeg.wasm as fast as native FFmpeg? No, and the gap is wide. In the benchmark the ffmpeg.wasm maintainers publish, a ten-second WebM to MP4 transcode took 5.2 seconds natively and 128.8 seconds on the single-thread core, which they report as 0.04x native. The multi-thread core came in at 60.4 seconds in the same run, around twice as fast as the single thread, at the cost of a lot more memory and CPU. The figures are for core 0.12.3 on Chrome 116 against FFmpeg n5.1.2, and your own ratio will depend on the command and the file, but the order of magnitude is the point when you are deciding between the browser and a server.

Can I use ffmpeg.wasm in Node.js? No. ffmpeg.wasm dropped Node.js support in version 0.12.0 and only runs in the browser. On a server, native FFmpeg is the better choice.

How do I migrate from ffmpeg.wasm 0.11 to 0.12? Replace createFFmpeg() with new FFmpeg(), run() with exec(), and the ffmpeg.FS() calls with the asynchronous writeFile() and readFile() methods. The fetchFile() helper now lives in the @ffmpeg/util package, and logging moves to ffmpeg.on(‘log’).

Thanks for reading! Let us know what you think on X! To stay in the loop, subscribe to our Newsletter.