Drag & Drop in the Remotion Player
The Remotion Player supports reacting to mouse events allowing for building interactions on the canvas.
Try to drag and resize the elements below.
Pointer events work just like in regular React. Make these Remotion-specific considerations:
- Disable the
controls
prop to disable any obstructing elements. You can render Playback controls outside of the Player. - The Player might have CSS
scale()
applied to it.
If you measure elements, you need to divide by the scale obtained byuseCurrentScale()
. - You can pass state update functions via
inputProps
to the Player. Alternatively, using React Context also works.
Example
Let's build the demo above.
The following features have been considered:
- Freely position and resizing the squares with the mouse.
- One item can be selected at a time. Clicking on an empty space will deselect the item.
- Items can not overflow the container, however the blue outlines can overflow the container.
In this example we store an identifier id
, start and end frame from
and
durationInFrames
, the position left
, top
, width
and height
and a color
color
.
item.tstsx
export typeItem = {id : number;durationInFrames : number;from : number;height : number;left : number;top : number;width : number;color : string;};
item.tstsx
export typeItem = {id : number;durationInFrames : number;from : number;height : number;left : number;top : number;width : number;color : string;};
If you like to support different types of items (solid, video, image), see here.
Layer.tsxtsx
importReact , {useMemo } from 'react';import {Sequence } from 'remotion';import type {Item } from './item';export constLayer :React .FC <{item :Item ;}> = ({item }) => {conststyle :React .CSSProperties =useMemo (() => {return {backgroundColor :item .color ,position : 'absolute',left :item .left ,top :item .top ,width :item .width ,height :item .height ,};}, [item .color ,item .height ,item .left ,item .top ,item .width ]);return (<Sequence key ={item .id }from ={item .from }durationInFrames ={item .durationInFrames }layout ="none"><div style ={style } /></Sequence >);};
Layer.tsxtsx
importReact , {useMemo } from 'react';import {Sequence } from 'remotion';import type {Item } from './item';export constLayer :React .FC <{item :Item ;}> = ({item }) => {conststyle :React .CSSProperties =useMemo (() => {return {backgroundColor :item .color ,position : 'absolute',left :item .left ,top :item .top ,width :item .width ,height :item .height ,};}, [item .color ,item .height ,item .left ,item .top ,item .width ]);return (<Sequence key ={item .id }from ={item .from }durationInFrames ={item .durationInFrames }layout ="none"><div style ={style } /></Sequence >);};
By using a <Sequence>
component, the item is only displayed from from
until from + durationInFrames
.
By adding layout="none"
, the <div>
is mounted as a direct child to the DOM.
SelectionOutline.tsxtsx
importReact , {useCallback ,useMemo } from 'react';import {useCurrentScale } from 'remotion';import {ResizeHandle } from './ResizeHandle';import type {Item } from './item';export constSelectionOutline :React .FC <{item :Item ;changeItem : (itemId : number,updater : (item :Item ) =>Item ) => void;setSelectedItem :React .Dispatch <React .SetStateAction <number | null>>;selectedItem : number | null;}> = ({item ,changeItem ,setSelectedItem ,selectedItem }) => {constscale =useCurrentScale ();constscaledBorder =Math .ceil (2 /scale );const [hovered ,setHovered ] =React .useState (false);constonMouseEnter =useCallback (() => {setHovered (true);}, []);constonMouseLeave =useCallback (() => {setHovered (false);}, []);constselected =item .id ===selectedItem ;conststyle :React .CSSProperties =useMemo (() => {return {width :item .width ,height :item .height ,left :item .left ,top :item .top ,position : 'absolute',outline :hovered ||selected ? `${scaledBorder }px solid #0B84F3` :undefined ,userSelect : 'none',};}, [hovered ,item ,scaledBorder ,selected ]);conststartDragging =useCallback ((e :PointerEvent |React .MouseEvent ) => {constinitialX =e .clientX ;constinitialY =e .clientY ;constonPointerMove = (pointerMoveEvent :PointerEvent ) => {constoffsetX = (pointerMoveEvent .clientX -initialX ) /scale ;constoffsetY = (pointerMoveEvent .clientY -initialY ) /scale ;changeItem (item .id , (i ) => {constupdatedItem :Item = {...(i asItem ),left :Math .round (item .left +offsetX ),top :Math .round (item .top +offsetY ),};returnupdatedItem asItem ;});};constonPointerUp = () => {window .removeEventListener ('pointermove',onPointerMove );};window .addEventListener ('pointermove',onPointerMove , {passive : true});window .addEventListener ('pointerup',onPointerUp , {once : true,});},[item ,scale ,changeItem ],);constonPointerDown =useCallback ((e :React .MouseEvent ) => {e .stopPropagation ();if (e .button !== 0) {return;}setSelectedItem (item .id );startDragging (e );},[item .id ,setSelectedItem ,startDragging ],);return (<div onPointerDown ={onPointerDown }onPointerEnter ={onMouseEnter }onPointerLeave ={onMouseLeave }style ={style }>{selected ? (<><ResizeHandle item ={item }setItem ={changeItem }type ="top-left" /><ResizeHandle item ={item }setItem ={changeItem }type ="top-right" /><ResizeHandle item ={item }setItem ={changeItem }type ="bottom-left" /><ResizeHandle item ={item }setItem ={changeItem }type ="bottom-right" /></>) : null}</div >);};
SelectionOutline.tsxtsx
importReact , {useCallback ,useMemo } from 'react';import {useCurrentScale } from 'remotion';import {ResizeHandle } from './ResizeHandle';import type {Item } from './item';export constSelectionOutline :React .FC <{item :Item ;changeItem : (itemId : number,updater : (item :Item ) =>Item ) => void;setSelectedItem :React .Dispatch <React .SetStateAction <number | null>>;selectedItem : number | null;}> = ({item ,changeItem ,setSelectedItem ,selectedItem }) => {constscale =useCurrentScale ();constscaledBorder =Math .ceil (2 /scale );const [hovered ,setHovered ] =React .useState (false);constonMouseEnter =useCallback (() => {setHovered (true);}, []);constonMouseLeave =useCallback (() => {setHovered (false);}, []);constselected =item .id ===selectedItem ;conststyle :React .CSSProperties =useMemo (() => {return {width :item .width ,height :item .height ,left :item .left ,top :item .top ,position : 'absolute',outline :hovered ||selected ? `${scaledBorder }px solid #0B84F3` :undefined ,userSelect : 'none',};}, [hovered ,item ,scaledBorder ,selected ]);conststartDragging =useCallback ((e :PointerEvent |React .MouseEvent ) => {constinitialX =e .clientX ;constinitialY =e .clientY ;constonPointerMove = (pointerMoveEvent :PointerEvent ) => {constoffsetX = (pointerMoveEvent .clientX -initialX ) /scale ;constoffsetY = (pointerMoveEvent .clientY -initialY ) /scale ;changeItem (item .id , (i ) => {constupdatedItem :Item = {...(i asItem ),left :Math .round (item .left +offsetX ),top :Math .round (item .top +offsetY ),};returnupdatedItem asItem ;});};constonPointerUp = () => {window .removeEventListener ('pointermove',onPointerMove );};window .addEventListener ('pointermove',onPointerMove , {passive : true});window .addEventListener ('pointerup',onPointerUp , {once : true,});},[item ,scale ,changeItem ],);constonPointerDown =useCallback ((e :React .MouseEvent ) => {e .stopPropagation ();if (e .button !== 0) {return;}setSelectedItem (item .id );startDragging (e );},[item .id ,setSelectedItem ,startDragging ],);return (<div onPointerDown ={onPointerDown }onPointerEnter ={onMouseEnter }onPointerLeave ={onMouseLeave }style ={style }>{selected ? (<><ResizeHandle item ={item }setItem ={changeItem }type ="top-left" /><ResizeHandle item ={item }setItem ={changeItem }type ="top-right" /><ResizeHandle item ={item }setItem ={changeItem }type ="bottom-left" /><ResizeHandle item ={item }setItem ={changeItem }type ="bottom-right" /></>) : null}</div >);};
ResizeHandle.tsxtsx
importReact , {useCallback ,useMemo } from 'react';import {useCurrentScale } from 'remotion';import type {Item } from './item';constHANDLE_SIZE = 8;export constResizeHandle :React .FC <{type : 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';setItem : (itemId : number,updater : (item :Item ) =>Item ) => void;item :Item ;}> = ({type ,setItem ,item }) => {constscale =useCurrentScale ();constsize =Math .round (HANDLE_SIZE /scale );constsizeStyle :React .CSSProperties =useMemo (() => {return {position : 'absolute',height :size ,width :size ,backgroundColor : 'white',border : '1px solid #0B84F3',};}, [size ]);constmargin = -size / 2 - 1 /scale ;conststyle :React .CSSProperties =useMemo (() => {if (type === 'top-left') {return {...sizeStyle ,marginLeft :margin ,marginTop :margin ,cursor : 'nwse-resize',};}if (type === 'top-right') {return {...sizeStyle ,marginTop :margin ,marginRight :margin ,right : 0,cursor : 'nesw-resize',};}if (type === 'bottom-left') {return {...sizeStyle ,marginBottom :margin ,marginLeft :margin ,bottom : 0,cursor : 'nesw-resize',};}if (type === 'bottom-right') {return {...sizeStyle ,marginBottom :margin ,marginRight :margin ,right : 0,bottom : 0,cursor : 'nwse-resize',};}throw newError ('Unknown type: ' +JSON .stringify (type ));}, [margin ,sizeStyle ,type ]);constonPointerDown =useCallback ((e :React .MouseEvent ) => {e .stopPropagation ();constinitialX =e .clientX ;constinitialY =e .clientY ;constonPointerMove = (pointerMoveEvent :PointerEvent ) => {constoffsetX = (pointerMoveEvent .clientX -initialX ) /scale ;constoffsetY = (pointerMoveEvent .clientY -initialY ) /scale ;setItem (item .id , (i ) => {constnewWidth =Math .max (1,Math .round (item .width +(type === 'bottom-left' ||type === 'top-left'? -offsetX :offsetX ),),);constnewHeight =Math .max (1,Math .round (item .height +(type === 'top-left' ||type === 'top-right'? -offsetY :offsetY ),),);constnewLeft =Math .min (item .left +item .width - 1,Math .round (item .left +(type === 'bottom-left' ||type === 'top-left' ?offsetX : 0),),);constnewTop =Math .min (item .top +item .height - 1,Math .round (item .top +(type === 'top-left' ||type === 'top-right' ?offsetY : 0),),);constupdatedItem :Item = {...i ,width :newWidth ,height :newHeight ,left :newLeft ,top :newTop ,};returnupdatedItem ;});};constonPointerUp = () => {window .removeEventListener ('pointermove',onPointerMove );};window .addEventListener ('pointermove',onPointerMove , {passive : true});window .addEventListener ('pointerup',onPointerUp , {once : true,});},[item ,scale ,setItem ,type ],);return <div onPointerDown ={onPointerDown }style ={style } />;};
ResizeHandle.tsxtsx
importReact , {useCallback ,useMemo } from 'react';import {useCurrentScale } from 'remotion';import type {Item } from './item';constHANDLE_SIZE = 8;export constResizeHandle :React .FC <{type : 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';setItem : (itemId : number,updater : (item :Item ) =>Item ) => void;item :Item ;}> = ({type ,setItem ,item }) => {constscale =useCurrentScale ();constsize =Math .round (HANDLE_SIZE /scale );constsizeStyle :React .CSSProperties =useMemo (() => {return {position : 'absolute',height :size ,width :size ,backgroundColor : 'white',border : '1px solid #0B84F3',};}, [size ]);constmargin = -size / 2 - 1 /scale ;conststyle :React .CSSProperties =useMemo (() => {if (type === 'top-left') {return {...sizeStyle ,marginLeft :margin ,marginTop :margin ,cursor : 'nwse-resize',};}if (type === 'top-right') {return {...sizeStyle ,marginTop :margin ,marginRight :margin ,right : 0,cursor : 'nesw-resize',};}if (type === 'bottom-left') {return {...sizeStyle ,marginBottom :margin ,marginLeft :margin ,bottom : 0,cursor : 'nesw-resize',};}if (type === 'bottom-right') {return {...sizeStyle ,marginBottom :margin ,marginRight :margin ,right : 0,bottom : 0,cursor : 'nwse-resize',};}throw newError ('Unknown type: ' +JSON .stringify (type ));}, [margin ,sizeStyle ,type ]);constonPointerDown =useCallback ((e :React .MouseEvent ) => {e .stopPropagation ();constinitialX =e .clientX ;constinitialY =e .clientY ;constonPointerMove = (pointerMoveEvent :PointerEvent ) => {constoffsetX = (pointerMoveEvent .clientX -initialX ) /scale ;constoffsetY = (pointerMoveEvent .clientY -initialY ) /scale ;setItem (item .id , (i ) => {constnewWidth =Math .max (1,Math .round (item .width +(type === 'bottom-left' ||type === 'top-left'? -offsetX :offsetX ),),);constnewHeight =Math .max (1,Math .round (item .height +(type === 'top-left' ||type === 'top-right'? -offsetY :offsetY ),),);constnewLeft =Math .min (item .left +item .width - 1,Math .round (item .left +(type === 'bottom-left' ||type === 'top-left' ?offsetX : 0),),);constnewTop =Math .min (item .top +item .height - 1,Math .round (item .top +(type === 'top-left' ||type === 'top-right' ?offsetY : 0),),);constupdatedItem :Item = {...i ,width :newWidth ,height :newHeight ,left :newLeft ,top :newTop ,};returnupdatedItem ;});};constonPointerUp = () => {window .removeEventListener ('pointermove',onPointerMove );};window .addEventListener ('pointermove',onPointerMove , {passive : true});window .addEventListener ('pointerup',onPointerUp , {once : true,});},[item ,scale ,setItem ,type ],);return <div onPointerDown ={onPointerDown }style ={style } />;};