ElementMirror
React 19 · canvas

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.

Source
by ref

Chasing Static

Renoun · Mirror Sessions

1:042:44
Mirror
30 fps
30/s
live
2×
320px
160px

Not a prop. There is one fit: the source's box, scaled uniformly to whatever box CSS gave the mirror.

background={null}

Keeps the last frame on screen.

<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.

One source, five sizing strategies
Every mirror below names this element with the same CSS selector, and they share one capture between them.

Live source

#sizing-source

0.0s

Every pixel here is painted into a canvas elsewhere on the page.

Width only
Height follows the source's aspect ratio, exactly like an <img>.
<ElementMirror
  source="#sizing-source"
  style={{ width: 220, height: 'auto' }}
/>
No size at all
Falls back to the source's own width and height, the way an <img> falls back to its natural size. The bitmap is still captured at pixelRatio, so it stays crisp on retina.
<ElementMirror source="#sizing-source" />
Both dimensions
A box the source's shape does not fit. There is no fit to choose: the source's box is scaled to the largest that fits inside, and the leftover space is left alone.
<ElementMirror
  source="#sizing-source"
  style={{ width: 320, height: 120 }}
/>
objectPosition="left top"
Which is all objectPosition has left to decide: where the source's box sits in the space it did not fill.
<ElementMirror
  source="#sizing-source"
  objectPosition="left top"
  style={{ width: 320, height: 120 }}
/>
Filling a box it does not fit
Covering, done the way you would fill a box with any oversized element: size the mirror to the smallest box that covers, and let the container crop it. The mirror keeps its ratio throughout, which is what makes min-width and min-height enough to say it.
<div className="h-[120px] w-[320px] overflow-hidden">
  <ElementMirror
    source="#sizing-source"
    className="min-h-full min-w-full"
    style={{ width: 'auto', height: 'auto' }}
  />
</div>

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.

A 9:16 video in a landscape frame
There is one video element here. Everything around it is a mirror of that same element, blown up to cover and blurred. Its controls are painted by the browser rather than the DOM, so the capture never picks them up.
1 decode
28px

Off leaves the bars empty, the way the video alone would look.

Pausing the video stops the mirror too: a paused video advances no frames, so there is nothing new to capture and the loop goes quiet on its own.

<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.

In progress3
SE

Session refresh

api · 3 files

apip162%
BO

Bottom sheet gestures

mobile · 8 files

ui34%
QU

Query audit log

db · 2 files

infrap212%
Shipped1
CO

Colour tokens

design · 41 files

ui100%

Drag a card between the columns. The card under the cursor is a mirror, not a clone: one <canvas> the size of the original, pixel for pixel, still ticking along with it.

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.

Source
One element, mirrored 3 times at 12fps.

Live source

#sharing-source / sourceRef

0.0s

Every pixel here is painted into a canvas elsewhere on the page.

Mirrors
Each one draws from the same captured bitmap. They alternate between naming the source by selector and by ref, which changes nothing: sharing keys on the element they resolve to.
3 × 12fps
3

Watch blit/s climb while captures/s stays flat.

Turn it off and the source holds still, so there is nothing to capture and the loop goes quiet.

captures/s

0

Shared across every mirror.

blits/s

0

One drawImage per mirror per frame.

ms/capture

0

Grows with the source's subtree.

main thread

0%

captures/s × ms. Each source is paced to stay under 45% of it.

Counts cover every mirror on the page, but mirrors scrolled out of view stop capturing, so these numbers are effectively this section's.

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.

Source
Its clock is the giveaway: every mirror reads a different time.

Live source

#delay-source

0.0s

Every pixel here is painted into a canvas elsewhere on the page.

Mirrors, each further behind
One capture per frame feeds all of them. A delayed mirror costs history, not captures.
4 × 20fps

live

−250ms

−500ms

−750ms

250ms

At 0 they collapse onto the source. Widen it and they fan out into the past, up to 750ms behind.

History is kept only as far back as the furthest mirror needs, so this slider decides the memory: 20 frames a second for 750ms is about 15 frames of the source held at a time.

captures/s

0

Flat, however far the trail reaches.

blits/s

0

One per mirror per frame.

ms/capture

0

Unchanged by delay.

main thread

0%

Delay buys latency with memory, not CPU.

{[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.

Source
The clock and the sweep bar never stop.

Live source

#frame-rate-source

0.0s

Every pixel here is painted into a canvas elsewhere on the page.

1 fps
1000ms
fps={1}
8 fps
125ms
fps={8}
24 fps
42ms
fps={24}

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.

Source, still running
Change the counter or the text, then compare.

Live source

#snapshot-source

0.0s

Counter 0

Held frame
Its clock stopped where the capture did.
paused
<ElementMirror source="#snapshot-source" paused />

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.

PropTypeDefaultDescription
sourceElement | RefObject | stringThe element to mirror, as a DOM element, a React ref, or a CSS selector. Mirrors that resolve to the same element share a capture.
fpsnumber | (() => number)30Maximum captures per second, up to the display refresh rate. Within a shared source, the highest fps sets the capture rate. A function is read every cycle, so a rate can rise for the length of an interaction without re-subscribing.
delaynumber0Milliseconds behind the source to run. Mirrors of one source share its capture history, so a trail of delayed mirrors costs no extra captures.
pixelRationumberdevicePixelRatioBitmap pixels captured per CSS pixel. Lower is cheaper, higher is sharper when displayed large.
objectPositionstring'center'Where the source’s box sits when CSS gives the mirror a box of a different ratio. Keywords, percentages and pixels, as in CSS.
backgroundstring | nullnullFill painted behind the element. null preserves transparency.
pausedbooleanfalseSuspends capturing. The last frame stays on screen, and a mirror paused before it has one still captures a single frame to hold.