# Transform Order Is Not Cosmetic

**MUST** · **ID:** `animations-transform-order-changes-result` · **Category:** animations
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-transform-order-changes-result

> MUST: Compose transform as translate → rotate → scale, and keep that order everywhere in the system. transform is a right-to-left matrix chain, so a translate written after a scale is measured in the scaled space: scale(0.5) translateY(60px) travels 30px, and the distance grows as the scale animates, bending the path into an easing you never wrote. Hoist the order with the rest of the motion constants; this bug never throws.

translate-then-scale is a different result from scale-then-translate — the second scales the distance, so a 60px lift at scale 0.5 travels 30px

The catalog does not argue this in prose — it pins the order in a machine-readable `rendering_contract` field on every one of its 24 effects, which is the tell: reproductions diverged until the order became part of the contract. The reason is that `transform` is a chain of matrix multiplications applied right to left, so every function operates inside the coordinate space the functions after it have already established. Write `scale(0.5) translateY(60px)` and the translation happens in a space that is already half size, so the glyph rises 30px rather than 60 — and as the scale animates toward 1 the effective distance GROWS, which bends the path into an easing you never authored and cannot find in your easing constant. Write `translateY(60px) scale(0.5)` and the two are independent: 60px is 60px at every scale, which is why the catalog puts translation first and scale last. The discipline generalises past text: pick one order, hoist it with the rest of the motion constants (animations-named-timing-constants), and never let two components in the same system disagree, because this bug does not throw — it just makes the motion quietly wrong in a way that reads as "the animation feels off". Distinct from animations-correct-transform-origin, which fixes WHERE a scale starts from; this fixes what every other function in the same chain means once a scale is in it. The individual `translate` / `rotate` / `scale` properties avoid the ambiguity — the spec fixes their order — but they still compose with `transform` in a defined sequence, so mixing the two forms needs the same care.

## Rule snippet

```tsx
// bad — translate lives in the scaled space; 60px becomes 30px at scale 0.5
transform: scale(0.5) translateY(60px);
// good — translation first, scale last: 60px is 60px at every scale
transform: translate3d(0, 60px, 0) scale(0.5);
```

## Bad — do not do this

`animations-transform-order-changes-result-bad`

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

const TRAVEL_PX = 60;
const SCALE = 0.5;
// The translate is applied inside the already-halved space, so the word starts
// TRAVEL_PX * SCALE below rest — and the gap grows as the scale animates to 1.
const EFFECTIVE_PX = TRAVEL_PX * SCALE;

export function TransformOrderChangesResultBad() {
  const [run, setRun] = useState(0);

  return (
    <div className="space-y-4">
      <button
        onClick={() => setRun((v) => v + 1)}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90"
      >
        Replay
      </button>

      <div className="relative h-32 overflow-hidden rounded-lg bg-muted px-4">
        <div className="absolute inset-x-4 top-4 border-t border-border" />
        <span className="absolute right-4 top-[1.15rem] text-[10px] text-muted-foreground">
          rest
        </span>

        <div className="absolute inset-x-4 top-[76px] border-t border-dashed border-destructive" />
        <span className="absolute right-4 top-[80px] text-[10px] text-destructive">
          intended start ({TRAVEL_PX}px)
        </span>

        <span key={run} className="order-bad-word absolute left-4 top-4 text-2xl font-semibold">
          Rise
        </span>
      </div>

      <style>{`
        .order-bad-word {
          transform-origin: top left;
          animation: orderBad 1100ms linear 800ms both;
        }
        @keyframes orderBad {
          /* scale first: every function after it is measured in the shrunk space */
          from { transform: scale(${SCALE}) translateY(${TRAVEL_PX}px); }
          to   { transform: scale(1) translateY(0); }
        }
      `}</style>

      <p className="text-xs text-destructive">
        <code>
          scale({SCALE}) translateY({TRAVEL_PX}px)
        </code>{' '}
        starts the word at {EFFECTIVE_PX}px, well short of the marker — and the distance grows
        as the scale animates, bending the path into an easing that is in no easing constant
      </p>
    </div>
  );
}
```

## Good — do this

`animations-transform-order-changes-result-good`

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

const TRAVEL_PX = 60;
const SCALE = 0.5;

export function TransformOrderChangesResultGood() {
  const [run, setRun] = useState(0);

  return (
    <div className="space-y-4">
      <button
        onClick={() => setRun((v) => v + 1)}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90"
      >
        Replay
      </button>

      <div className="relative h-32 overflow-hidden rounded-lg bg-muted px-4">
        <div className="absolute inset-x-4 top-4 border-t border-border" />
        <span className="absolute right-4 top-[1.15rem] text-[10px] text-muted-foreground">
          rest
        </span>

        <div className="absolute inset-x-4 top-[76px] border-t border-dashed border-success" />
        <span className="absolute right-4 top-[80px] text-[10px] text-success">
          start ({TRAVEL_PX}px)
        </span>

        <span key={run} className="order-good-word absolute left-4 top-4 text-2xl font-semibold">
          Rise
        </span>
      </div>

      <style>{`
        .order-good-word {
          transform-origin: top left;
          animation: orderGood 1100ms linear 800ms both;
        }
        @keyframes orderGood {
          /* translate first, scale last: the two stay independent */
          from { transform: translate3d(0, ${TRAVEL_PX}px, 0) scale(${SCALE}); }
          to   { transform: translate3d(0, 0, 0) scale(1); }
        }
        @media (prefers-reduced-motion: reduce) {
          .order-good-word { animation: none; }
        }
      `}</style>

      <p className="text-xs text-success">
        <code>
          translate3d(0, {TRAVEL_PX}px, 0) scale({SCALE})
        </code>{' '}
        lands on the marker: {TRAVEL_PX}px is {TRAVEL_PX}px at every scale, so the path is the
        one the easing describes
      </p>
    </div>
  );
}
```

## References

- [animate-text — soft-blur-in effect (rendering_contract.transform_order)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/effects/soft-blur-in.json)
- [animate-text — schema reference](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/references/schema.md)
- [MDN — transform function order](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transform)
