# Draggable windows at 60 fps in React: Media Folder's compositor-only drag and seven traps

> How Media Folder drags windows with no React renders, no layout and no paint, and seven traps that cost frames: SVG animations, two videos at once, glass blur, first-reveal uploads.

Performance, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Media Folder — macOS-Style Draggable Window Desktop for React & Framer Motion](https://upfork.dev/products/macos-folder-draggable-windows-react) (Component, comes with the Pro plan). Published 2026-10-26. 7 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Media Folder turns a collection into a small macOS desktop for React. A folder sits on a wallpaper; hover it and its front panel hinges open. Open it and everything inside bursts out as windows that land around it, nearest first: photos, videos, a working music player, the weather, a colour palette, notes and links. The windows drag, tilt with your speed, glide when you let go, and have real traffic lights: red puts a window back in the folder, yellow minimises it, green expands it. Light and dark themes, keyboard and touch, a grid on tablets and a card stack on phones.

[Live preview of Media Folder — macOS-Style Draggable Window Desktop for React & Framer Motion](https://upfork.dev/products/macos-folder-draggable-windows-react)

This article is for anyone, person or AI, building something that moves under the pointer in React: how a window drags with no React render, no layout and no paint, and seven traps that cost us frames, each measured, with its fix. The numbers come from an Intel Iris Plus laptop, the kind of integrated GPU most visitors have.

## A drag that never renders

Every window's position, scale, tilt and shadow live in Motion values, and one `pointermove` listener on the desktop writes them. React never hears about a drag until it ends:

`components/media-folder/hooks/useDraggableWindow.ts`, lines 41–46:

```ts
/**
 * Dragging a window: 1:1 with the pointer from anywhere but its controls, a 2% lift, a tilt of up to 2° that
 * follows the horizontal speed, Shift to lock an axis, and on release a short glide (12% of the speed, 160px at
 * most) that springs to a stop on the stage. Everything runs on MotionValues through the desktop's single
 * pointermove listener: no React render while it moves. Arrow keys nudge 8px (Shift 32px).
 */
```

A trace of hover and drag shows zero React commits. But no renders is not the same as a cheap frame: the first drag trace still showed 66 layouts and 208 paints over 60 pointer moves. The traps below are how that went to no layout and no paint at all, with drag frames at a median of 16.4–16.8 ms, which is 60 fps.

## Trap 1: animations inside an SVG

The weather window draws its conditions as small icons: the sun's rays turn, rain and snow fall, a bolt flickers.

> **Lesson: CSS animations on elements inside an SVG repainted the page during a drag: animate HTML layers over a still SVG** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** animating parts of an SVG icon with CSS (the rays turning, the drops falling) cost no more than animating any other element.
> **What happened:** the drag trace's invalidations pointed at the icon's SVG elements ("path.md-wx-drop: Style changed"): Chrome restyled and repainted them every frame, on the main thread, while a window was being dragged across the desktop.
> **The fix:** the moving parts are HTML layers (a `span` around a still SVG) animated with `transform`, which the compositor runs on its own; small icons don't animate at all. With this and one more change (desktops scrolled off screen skip rendering, below), the same trace showed 1 layout and 31 paints instead of 66 and 208.
> **Evidence:** WeatherContent.tsx lines 18–27; styles.css lines 1113–1115.

`components/media-folder/content/WeatherContent.tsx`, lines 18–27:

```tsx
/**
 * The five conditions as small drawings. The moving parts (turning rays, falling rain and snow, a flickering bolt)
 * are HTML layers over a still SVG, so the compositor animates them without repainting; small icons stay still.
 */
export function WeatherIcon({ condition, size = 40 }: { condition: MediaItemWeatherCondition; size?: number }) {
  const live = size >= 24;
  return (
    <span className={`md-wx md-wx--${condition}`} data-live={live || undefined} style={{ width: size, height: size }} aria-hidden>
      {condition === "sun" && <span className="md-wx-rays">{RAYS}</span>}
      <svg viewBox="0 0 40 40" width={size} height={size}>
```

`components/media-folder/styles.css`, lines 1113–1115:

```css
.md-wx[data-live] .md-wx-rays {
  animation: md-spin 60s linear infinite;
}
```

## Trap 2: two videos at once

With the folder open and its videos paused, the page held 60 fps. With two videos playing, it dropped to about 30. Each clip alone held 60 fps, so it wasn't the files.

> **Lesson: Two videos at once halved Chrome's frame rate on a laptop GPU, even on an empty page: play one while dragging** (Not in the docs; learned by the AI (Claude Opus 5.5))
> **I assumed:** two small muted videos playing side by side were cheap, since each one alone ran at 60 fps.
> **What happened:** a baseline with nothing of ours (two plain `<video>` elements on an empty page, in Chrome on the Iris Plus laptop) ran at a median of 33.3 ms a frame, 30 fps, whether anything else moved or not. One video on the same page: 16.6 ms, 60 fps.
> **The fix:** one controller decides which videos play: only those on screen, uncovered and already landed, and while a window is being dragged, just one. The rest pause and pick up again when the drag ends.
> **Evidence:** useVisibilityPlayback.ts lines 48–59.

`components/media-folder/hooks/useVisibilityPlayback.ts`, lines 48–59:

```ts
    // Windows still flying in wait: decoding starts once they land (they schedule another check then).
    const eligible = order.filter((k) => {
      const v = vids.get(k);
      return v && v.seen && !hold && !s.expanded && geo.get(k)?.el?.dataset.moving === undefined && !covered(k);
    });
    // While a window is dragged, one video keeps playing: two at once halve the frame rate on many laptop GPUs.
    const playing = new Set(eligible.slice(0, busy ? 1 : max));
    for (const [k, v] of vids) {
      if (playing.has(k)) {
        if (v.el.paused) v.el.play()?.catch(() => {});
      } else if (!v.el.paused) v.el.pause();
    }
```

## Trap 3: glass and borders over video

The windows have a frosted title bar (`backdrop-filter`) and a hairline border.

> **Lesson: A blurred title bar and a border drawn over a video cost frames while moving: skip the blur, put the border behind** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** a blurred title bar and a 1 px border layer on top of each window were cheap decoration.
> **What happened:** a window that moves has to blur a new backdrop every frame, and a layer drawn over a playing video costs an extra render pass every frame on an integrated GPU. In an A/B of cheaper designs, the build as it was dragged at a median of 24–31 ms a frame; the variants without those costs, at 16.5–16.8 ms.
> **The fix:** the title bar skips its blur while the window is in flight or under the pointer and gets it back when it rests, and the hairline border is drawn by the shadow layer behind the window, so nothing sits over its content. Three runs of the final build: medians of 16.4, 16.7 and 16.8 ms, with no layout or paint during the drag.
> **Evidence:** styles.css lines 612–631.

`components/media-folder/styles.css`, lines 612–631:

```css
.md-win[data-moving] {
  will-change: transform;
}
/* A window in flight or under the pointer re-blurs a new backdrop every frame: it skips the blur until it rests. */
.md-win[data-moving] .md-bar,
.md-win[data-dragging] .md-bar {
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}
/* The hairline border rides on the shadow layers, behind the window: nothing is drawn over its content (a layer
   over a video costs a render pass every frame on integrated GPUs). */
.md-shadow {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow:
    0 0 0 1px var(--md-glass-border),
    var(--md-shadow-rest);
  pointer-events: none;
}
```

## Trap 4: windows that fly

Opening the folder sends up to twelve windows flying out of it, each scaling up and turning as it goes. On the laptop, that open stalled for about 700 ms, and a CPU profile showed JavaScript nearly idle: the time went to the browser's own rendering work.

> **Lesson: Windows that scale and turn as they fly stalled the browser's rendering: cache each as a layer only while it flies** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** windows animated with transforms would be composited, and cost nothing to draw as they moved.
> **What happened:** the browser kept drawing each window's content again as its scale and angle changed. A dozen windows full of photos, with their blurred title bars, made opening the folder stall.
> **The fix:** while a window flies it carries `data-moving`, which sets `will-change: transform` (so it moves as one cached bitmap) and drops the title bar's blur; both come back when it lands. Photos are decoded before the burst, and videos start only after landing. Closing and reopening then ran with a worst frame gap of about 40 ms. In later tests a permanent layer for every window did no better (worst gaps of 47–109 ms, against 35–76 ms), so the hint is only there during the flight.
> **Evidence:** MediaWindow.tsx lines 122–133; useVisibilityPlayback.ts line 48.

`components/media-folder/MediaWindow.tsx`, lines 122–133:

```tsx
  /**
   * While a window flies it is drawn as one cached bitmap (will-change) and its title bar skips the backdrop blur;
   * both come back when it lands, so a burst of twelve stays on the compositor.
   */
  const flyTimer = useRef(0);
  const flying = (seconds: number) => {
    const node = el.current;
    if (!node) return;
    node.dataset.moving = "";
    clearTimeout(flyTimer.current);
    flyTimer.current = window.setTimeout(() => delete node.dataset.moving, seconds * 1000);
  };
```

## Trap 5: the very first reveal

After trap 4, closing and reopening were smooth, but the very first open on a page still stalled for about 550 ms.

> **Lesson: The first reveal of a window full of pictures stalls on GPU uploads: draw each window once, nearly invisible, on hover** (Not in the docs; learned by the AI (Claude Opus 5.5))
> **I assumed:** with the images loaded and decoded, the first time a window appeared would cost the same as any other.
> **What happened:** a trace of the first open showed about a second of GPU raster work on the first reveal. The pictures were going up to the GPU the first time they were actually drawn, which was the first frame of the burst.
> **The fix:** the windows mount as soon as their folder is hovered, focused or selected, and each is drawn once, at 1% opacity and at its resting size, for 300 ms, so the uploads happen before the click. In three later runs, the worst frame gap of a first open was 35–76 ms.
> **Evidence:** MediaWindow.tsx lines 231–249.

`components/media-folder/MediaWindow.tsx`, lines 231–249:

```tsx
  // Windows mount while their folder is only hovered, focused or selected. Each is drawn once, at 1% opacity, at
  // its resting size: the GPU uploads its pictures now instead of on the first frame of the burst.
  useIso(() => {
    if (visible || d.reduced) return;
    const r = resting();
    if (!r) return;
    x.set(r.x);
    y.set(r.y);
    scale.set(r.scale);
    rotate.set(r.rotate);
    opacity.set(0.01);
    vis.set("visible");
    const t = window.setTimeout(() => {
      if (wm.isVisible(wkey)) return;
      vis.set("hidden");
      opacity.set(0);
    }, 300);
    return () => clearTimeout(t);
  }, []);
```

Two more things keep idle desktops cheap: a desktop scrolled off screen gets `content-visibility: auto`, so it skips rendering and its animations altogether, and the mesh wallpaper's slow drift pauses while it's out of view.

## Trap 6: the keys a browser keeps

A focused window answers the keyboard like a Mac window: arrows move it, Enter expands it, Escape hands focus back to its folder.

> **Lesson: Browsers keep Ctrl+W for closing the tab: give a focused window another close key** (Not in the docs; learned by the AI (Claude Opus 5.5))
> **I assumed:** Cmd+W or Ctrl+W on a focused window could close it, as it does on a Mac.
> **What happened:** browsers keep Ctrl+W for closing the tab; a page can't count on ever receiving it.
> **The fix:** Delete and Backspace also close the focused window. Cmd/Ctrl+W is still handled for the browsers that pass it on.
> **Evidence:** MediaWindow.tsx lines 359–366.

`components/media-folder/MediaWindow.tsx`, lines 359–366:

```tsx
    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "w") {
      e.preventDefault();
      return close();
    }
    if (e.target !== e.currentTarget) return;
    if (e.key === "Delete" || e.key === "Backspace") {
      e.preventDefault();
      close();
```

## Trap 7: judging motion on the dev server

> **Lesson: Spring animations judged on a Next.js dev server look wrong: the dev build starves the main thread, measure production** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **I assumed:** the dev server was good enough to check how the burst moved.
> **What happened:** the windows reached their resting places in under 230 ms, much faster than the springs allow. Sampling every frame showed frames arriving 200 ms apart: the development build was starving the main thread, so each frame jumped the springs far ahead.
> **The fix:** every judgement of motion, and every measurement in this article, comes from a production build (`next build`, then `next start`).
> **Evidence:** the build notes; the traces and frame timings above were all taken on the production build.

## Checked

50 unit tests; ESLint and TypeScript clean; axe finds no violations in light and dark, with the folder open, expanded and with a menu open. Lighthouse desktop: 98 for Performance and 100 for Accessibility, Best Practices and SEO, with CLS 0. Zero React commits during hover and drag; the drag is composite-only (no layout, no paint) at a median of 60 fps in Chrome on the Iris Plus laptop. 16.0 kB gzipped at first load; the windows' code and the expanded view load when they're needed (26.6 kB for every part). On UpFork it passed the importer and the preview builder with all three demos.

## Make it yours

Put one or more `<MediaFolder />` inside a `<MediaDesktop />`. A folder takes its `label` and `items` (images, videos, audio, weather, a palette, notes, links, or your own `custom` window with a `render` function), plus `color`, `icon`, a `layout` (scatter, grid or ring) with a `seed`, `openOn` (click or double-click), `defaultOpen`, `sound`, `persistLayout` to remember where windows were left, and `renderImage` to draw pictures with your own image component (`next/image`, say). The desktop takes a `wallpaper` (any CSS background, a two-colour gradient or the animated mesh) and a `theme`: light, dark or auto. Through a ref you can `open()`, `close()`, `toggle()`, `focus(itemId)` or `tidy()` a folder.

```bash
npx shadcn@latest add @upfork/macos-folder-draggable-windows-react
```

---

On UpFork: https://upfork.dev/articles/draggable-windows-at-60-fps-in-react-media-folder-s-compositor-only-drag-and
