# Set Transform on the Element, Not a Parent Variable

**NEVER** · **ID:** `performance-transform-not-css-variable` · **Category:** performance
**Source:** [Emil Kowalski](https://emilkowalski.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/performance-transform-not-css-variable

> NEVER: Drive child transforms by writing a CSS variable on the parent during a gesture (`el.style.setProperty('--swipe-amount', …)`) — it invalidates every descendant reading that variable, recalculating styles across the subtree on each pointer move. Set `transform` directly on the one element that moves, and prefer `animate={{ transform: "translateX(100px)" }}` over Motion's main-thread `x`/`y`/`scale` shorthands.

Never drive child transforms by mutating a CSS variable on their parent during a gesture

Writing element.style.setProperty('--swipe-amount', ...) on a container invalidates every descendant that reads that variable, so the browser recalculates styles across the whole subtree on every pointer move — a recalc storm that shows up as gesture jank long before paint does. Set element.style.transform on the single element that actually moves and let its children ride along inside one composited layer. The same trap exists in Framer Motion: the x / y / scale shorthand props are driven on the main thread via requestAnimationFrame rather than being hardware-accelerated the way a full transform string is, so they drop frames under load — prefer animate={{ transform: "translateX(100px)" }}.

## Bad — do not do this

`performance-transform-not-css-variable-bad`

```tsx
import { useRef } from 'react';

const CHILDREN = Array.from({ length: 240 }, (_, i) => i);
const MAX = 140;

export function TransformNotCssVariableBad() {
  const gridRef = useRef<HTMLDivElement>(null);
  const readoutRef = useRef<HTMLDivElement>(null);
  const worstRef = useRef(0);
  const startX = useRef(0);
  const dragging = useRef(false);

  const move = (clientX: number) => {
    const grid = gridRef.current;
    if (!grid) return;
    const dx = Math.max(0, Math.min(MAX, clientX - startX.current));

    const t0 = performance.now();
    // Mutating a custom property on the parent dirties every descendant that reads it:
    // the browser must recalculate styles for all 240 children on every pointer move.
    grid.style.setProperty('--drag', `${dx}px`);
    // Force the style + layout flush now so we can actually measure what the frame will cost.
    void grid.getBoundingClientRect().width;
    const cost = performance.now() - t0;

    if (cost > worstRef.current) worstRef.current = cost;
    if (readoutRef.current) {
      readoutRef.current.textContent = `style flush: ${cost.toFixed(2)} ms · worst: ${worstRef.current.toFixed(2)} ms`;
    }
  };

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="space-y-3 rounded-lg border border-border bg-card p-4">
        <div
          className="cursor-grab touch-none rounded-md bg-muted p-2 active:cursor-grabbing"
          onPointerDown={(e) => {
            e.currentTarget.setPointerCapture(e.pointerId);
            dragging.current = true;
            startX.current = e.clientX;
          }}
          onPointerMove={(e) => {
            if (dragging.current) move(e.clientX);
          }}
          onPointerUp={() => {
            dragging.current = false;
          }}
        >
          <div className="mb-2 text-xs text-muted-foreground">Drag left → right in this panel</div>
          <div ref={gridRef} className="grid grid-cols-12 gap-0.5 overflow-hidden">
            {CHILDREN.map((i) => (
              <div
                key={i}
                // Every child reads the parent's variable.
                style={{ transform: 'translateX(var(--drag, 0px))' }}
                className="h-2 rounded-sm bg-primary/60"
              />
            ))}
          </div>
        </div>

        <div ref={readoutRef} className="text-xs tabular-nums text-error">
          style flush: — · worst: —
        </div>
      </div>
      <p className="text-xs text-error">
        Driving 240 child transforms through <code>--drag</code> on the parent: each pointer move
        triggers a style recalc across the whole subtree. The measured flush above is the cost you
        pay every frame of the gesture.
      </p>
    </div>
  );
}
```

## Good — do this

`performance-transform-not-css-variable-good`

```tsx
import { useRef } from 'react';

const CHILDREN = Array.from({ length: 240 }, (_, i) => i);
const MAX = 140;

export function TransformNotCssVariableGood() {
  const gridRef = useRef<HTMLDivElement>(null);
  const readoutRef = useRef<HTMLDivElement>(null);
  const worstRef = useRef(0);
  const startX = useRef(0);
  const dragging = useRef(false);

  const move = (clientX: number) => {
    const grid = gridRef.current;
    if (!grid) return;
    const dx = Math.max(0, Math.min(MAX, clientX - startX.current));

    const t0 = performance.now();
    // Set transform directly on the one element that actually animates. The 240 children ride
    // along inside it — no descendant's style is invalidated.
    grid.style.transform = `translateX(${dx}px)`;
    // Same forced style + layout flush as the bad example, so the numbers are comparable.
    void grid.getBoundingClientRect().width;
    const cost = performance.now() - t0;

    if (cost > worstRef.current) worstRef.current = cost;
    if (readoutRef.current) {
      readoutRef.current.textContent = `style flush: ${cost.toFixed(2)} ms · worst: ${worstRef.current.toFixed(2)} ms`;
    }
  };

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="space-y-3 rounded-lg border border-border bg-card p-4">
        <div
          className="cursor-grab touch-none rounded-md bg-muted p-2 active:cursor-grabbing"
          onPointerDown={(e) => {
            e.currentTarget.setPointerCapture(e.pointerId);
            dragging.current = true;
            startX.current = e.clientX;
          }}
          onPointerMove={(e) => {
            if (dragging.current) move(e.clientX);
          }}
          onPointerUp={() => {
            dragging.current = false;
          }}
        >
          <div className="mb-2 text-xs text-muted-foreground">Drag left → right in this panel</div>
          <div className="overflow-hidden">
            <div ref={gridRef} className="grid grid-cols-12 gap-0.5 will-change-transform">
              {CHILDREN.map((i) => (
                <div key={i} className="h-2 rounded-sm bg-primary/60" />
              ))}
            </div>
          </div>
        </div>

        <div ref={readoutRef} className="text-xs tabular-nums text-success">
          style flush: — · worst: —
        </div>
      </div>
      <p className="text-xs text-success">
        One <code>transform</code> on the element that moves. Identical motion, but only a single
        element&apos;s style is invalidated per frame — compare the flush numbers against the bad
        example on the same drag.
      </p>
    </div>
  );
}
```

## References

- [Emil Kowalski — animation STANDARDS.md](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/STANDARDS.md)
- [CSS and JavaScript animation performance](https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/CSS_JavaScript_animation_performance)
