A live mirror of any DOM element
ElementMirror repaints another element into a <canvas> a few times a second. It sizes like an <img> of the source: leave it alone and it takes the source's own size, give it a width and it keeps the ratio, give it both dimensions and it keeps the ratio anyway, since a mirror is a picture of a box the source drew for itself.
Playground
Every prop, wired to a control. The snippet underneath is the component rendering on the right, and its source is the ref held by the card on the left.
<ElementMirror
source={sourceRef}
fps={30}
pixelRatio={2}
style={{ width: 320, height: 160 }}
/>It sizes like an image
The same source, mirrored several ways. There is no sizing prop: CSS decides, and the source only supplies the natural size and ratio to fall back on.
Filling a frame the video does not fit
A portrait video in a landscape container leaves empty bars. The usual fix is to fill them with a scaled-up, blurred copy of the video, and a mirror is that copy: sized past the container the way you would size any oversized image, and it is a canvas, so a CSS filter blurs it.
<div className="relative aspect-video overflow-hidden">
<ElementMirror
source={videoRef}
pixelRatio={0.5}
className="absolute top-1/2 left-1/2 min-h-full min-w-full"
style={{
width: 'auto',
height: 'auto',
filter: 'blur(28px)',
transform: 'translate(-50%, -50%) scale(1.15)',
}}
/>
<video ref={videoRef} className="relative mx-auto h-full w-auto" ... />
</div>Doing this with a second <video> costs a second decode of the same file, and the two copies drift out of sync. A mirror is one decode presented twice.
Drag ghosts
The thing under the cursor while you drag is usually a clone of the element, or a bitmap frozen at drag start. A mirror is neither: given no CSS size it comes out at the card's exact size, a transform moves it, and it keeps mirroring the real card the whole way across.
const [drag, setDrag] = useState<HTMLElement | null>(null)
<div onPointerDown={(event) => setDrag(event.currentTarget)} …>…</div>
{drag ? (
<div ref={ghostRef} className="pointer-events-none fixed top-0 left-0">
<ElementMirror source={drag} fps={15} />
</div>
) : null}The alternatives cost more than they look. Cloning the node copies a whole subtree, along with its ids, its React-owned state, and its animations restarting from zero. The drag-and-drop API's setDragImage takes a bitmap once at drag start, so it cannot keep up with a card that changes mid-drag. A mirror is a single canvas that follows the pointer with a transform.
A capture takes a few milliseconds to arrive, which is long enough for a ghost mounted and shown in the same instant to appear empty for a frame. The fix is to separate the two: the mirror mounts hidden on pointer down and is revealed only once the pointer has travelled far enough to count as a drag. It spends that time capturing, so it has a frame ready by the time anyone sees it, and a plain click no longer lifts the card.
The ghost is deliberately not tilted, which is the one thing a clone does better. Rotate a DOM node and the browser re-rasterises its text at the new angle; rotate a canvas and there is nothing left to rasterise, only pixels to resample, so the type goes soft. Kept upright and snapped to whole device pixels, the ghost is indistinguishable from the card underneath it.
One more thing worth knowing: the lifted card has to stay on screen, since it is what the ghost is capturing, and its own opacity is captured along with everything else. Fading the card to leave a gap behind would fade the ghost with it. The empty slot above is an overlay sitting on top of the card rather than a style on the card.
Many mirrors, one capture
Capturing walks the source's subtree, which costs far more than drawing the result. So mirrors of the same element share a single capture and each draw from it, the fastest one sets the pace, and a source that holds still is not captured at all.
Mirrors that run behind
delay shows the source as it was some milliseconds ago. Mirrors of one element share a single capture history, so a trail of them costs the same captures as one, and each is drawn whichever frame has aged into its own past. Skipping captures on a still source costs nothing here: a frame stands for every moment until the next one, so a gap in the history is not a gap in time.
{[0, 250, 500, 750].map((delay) => (
<ElementMirror key={delay} source="#delay-source" fps={20} delay={delay} />
))}Frame rate is the cost dial
fps trades smoothness for CPU per mirror, and a group runs at its fastest member rather than the sum. Mirrors scrolled out of view stop capturing on their own, as do mirrors on a hidden tab, and the loop backs off on its own if captures turn out to be expensive.
One frame and stop
paused holds a running mirror on its last frame until you let it go again. A mirror that has no frame yet takes one first, so mounting it paused paints a single frame and retires, the way a video shows its poster. That makes a cheap before-and-after snapshot of a component you are about to change.
Props
Everything else is forwarded to the element that holds the mirror's box, and the ref points at it. The canvas inside is out of flow, since what it paints is not what the mirror occupies.