# Animate Text With Transform, Not Metrics

**SHOULD** · **ID:** `animations-text-motion-uses-transform` · **Category:** animations
**Source:** [Web Platform](https://web.dev/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-text-motion-uses-transform

> SHOULD: Emphasize or pulse text with transform/opacity, never by animating font-weight, font-size, letter-spacing, or font-variation-settings — those are layout properties that relayout the line every frame and shove neighbours around. A real variable-font weight animation must be a deliberate, isolated, reduced-motion-gated effect.

Pulse or emphasize text with transform/opacity — animating font weight, size, or letter-spacing relayouts the line

This is the general "only animate transform and opacity" rule at its least obvious edge. A "breathing" heading built by animating font-weight, or a word that emphasizes itself by growing its letter-spacing, changes the glyphs' advance widths on every frame — which reflows the whole line and shoves neighbouring content around, the exact jank the compositor was meant to avoid. Variable fonts make this especially tempting because `font-variation-settings: "wght"` animates smoothly, but it is still a layout property, not a composited one. Get the same effect with transform: an inline-block word scaling from 1 to 1.1, or a crossfade in opacity, runs entirely on the GPU and never moves its neighbours. If a genuine weight animation is the point, treat it as a deliberate, isolated, reduced-motion-gated effect — not a default.

## Rule snippet

```tsx
/* reflows */ @keyframes b{50%{letter-spacing:.2em}}
/* composited */ @keyframes g{50%{transform:scale(1.1)}}
```

## Bad — do not do this

`animations-text-motion-uses-transform-bad`

```tsx
export function TextMotionUsesTransformBad() {
  return (
    <div className="w-full max-w-sm py-6">
      <style>{`@keyframes uig-metric-bad{0%,100%{letter-spacing:normal}50%{letter-spacing:0.2em}}
        @media (prefers-reduced-motion: reduce){.uig-metric-bad{animation:none}}`}</style>
      <p className="text-base leading-relaxed text-foreground">
        Our{' '}
        {/* Animating letter-spacing (a text metric) resizes the word every frame. */}
        <span
          className="uig-metric-bad font-semibold"
          style={{ animation: 'uig-metric-bad 1.6s ease-in-out infinite', display: 'inline-block' }}
        >
          biggest
        </span>{' '}
        sale of the year is finally here.
      </p>
      <p className="mt-4 text-xs text-destructive">
        Animating letter-spacing changes the word’s width every frame, so the whole line relayouts and the neighbouring words twitch.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-text-motion-uses-transform-good`

```tsx
export function TextMotionUsesTransformGood() {
  return (
    <div className="w-full max-w-sm py-6">
      <style>{`@keyframes uig-metric-good{0%,100%{transform:scale(1)}50%{transform:scale(1.14)}}
        @media (prefers-reduced-motion: reduce){.uig-metric-good{animation:none}}`}</style>
      <p className="text-base leading-relaxed text-foreground">
        Our{' '}
        {/* Scaling with transform is compositor-only — no relayout, neighbours stay put. */}
        <span
          className="uig-metric-good font-semibold text-primary"
          style={{ animation: 'uig-metric-good 1.6s ease-in-out infinite', display: 'inline-block', transformOrigin: 'center' }}
        >
          biggest
        </span>{' '}
        sale of the year is finally here.
      </p>
      <p className="mt-4 text-xs text-success">
        Scaling with transform runs on the compositor: the word pulses without relayout, so the rest of the line never moves.
      </p>
    </div>
  );
}
```

## References

- [web.dev — Animations and performance](https://web.dev/articles/animations-guide)
- [MDN — CSS performance: reflow](https://developer.mozilla.org/en-US/docs/Web/Performance/CSS_JavaScript_animation_performance)
