# Text Anti-Aliasing and Transforms

**SHOULD** · **ID:** `design-text-antialiasing-transforms` · **Category:** design
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/design-text-antialiasing-transforms

> SHOULD: Animate a wrapper rather than the text node when scaling — re-rasterizing glyphs at fractional sizes makes strokes crawl and can flip sub-pixel anti-aliasing to grayscale mid-flight, and one wrapper transform keeps the lockup scaling as a single object. If artifacts persist, promote the wrapper with `translateZ(0)` or `will-change: transform` and release it when the transition ends (blanket promotion is still wrong — see `performance-gpu-translatez`).

Animate a wrapper rather than the text node itself, and promote it to its own layer when scaling artifacts persist

Glyphs are hinted and anti-aliased for the size they are rendered at. Put a scale transform directly on a text node and the browser may re-rasterize it at every intermediate fractional size, so the strokes visibly crawl and thicken through the animation, and sub-pixel anti-aliasing can switch to grayscale mid-flight. Moving the transform up to a wrapper fixes the composition too: one transform and one origin means the lockup scales as a single object instead of each line scaling about its own centre and drifting apart. If artifacts survive that, promote the wrapper — translateZ(0) or will-change: transform gives it its own compositor layer, so the text is rasterized once and merely re-composited per frame. Distinct from performance-gpu-translatez, which is about when promotion is worth its memory cost at all (and warns against blanket promotion) — here promotion is a targeted, transient fix for a rendering artifact, applied to the animating wrapper and released when the transition ends. Also distinct from content-font-smoothing, which concerns static -webkit-font-smoothing choices, not the smoothing change a transform induces.

## Rule snippet

```tsx
.title-wrap { will-change: transform; transform: scale(0.96); }
.title-wrap.is-in { transform: scale(1); } /* drop will-change on transitionend */
```

## Bad — do not do this

`design-text-antialiasing-transforms-bad`

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

/**
 * Bad: the transform is applied to the text nodes themselves. Press "Zoom" and each
 * glyph run is re-rasterized at a fractional size mid-transform, so the strokes visibly
 * shimmer and thicken. And because every text node scales about its *own* centre, the
 * lockup geometry comes apart — the heading, the figure and the caption drift out of
 * their relationship instead of growing as one object.
 */
export function TextAntialiasingTransformsBad() {
  const [zoomed, setZoomed] = useState(false);
  const scale = zoomed ? 'scale-150' : 'scale-100';

  return (
    <div className="w-full max-w-sm space-y-3">
      <button
        type="button"
        onClick={() => setZoomed((z) => !z)}
        className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
      >
        {zoomed ? 'Reset' : 'Zoom the text'}
      </button>

      <div className="flex h-40 items-center justify-center overflow-hidden rounded-xl border border-border bg-card p-4">
        <div className="text-center">
          {/* Every text node carries its own transform */}
          <p
            className={`text-xs uppercase tracking-wide text-muted-foreground transition-transform duration-500 motion-reduce:transition-none ${scale}`}
          >
            Revenue
          </p>
          <p
            className={`text-2xl font-semibold text-foreground transition-transform duration-500 motion-reduce:transition-none ${scale}`}
          >
            $48,200
          </p>
          <p
            className={`text-xs text-muted-foreground transition-transform duration-500 motion-reduce:transition-none ${scale}`}
          >
            vs. $43,000 last month
          </p>
        </div>
      </div>

      <p className="text-xs text-error">
        Scaling a text node changes its smoothing: the browser re-rasterizes the glyphs at every
        intermediate size, and the strokes crawl. The composition breaks too &mdash; three separate
        transforms, three separate origins, so the lines overlap and the lockup stops being a lockup.
      </p>
    </div>
  );
}
```

## Good — do this

`design-text-antialiasing-transforms-good`

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

/**
 * Good: the transform lives on a wrapper, and the text rides along inside it. One
 * transform, one origin — the lockup scales as a single object and its internal
 * geometry is preserved.
 *
 * The wrapper is promoted to its own compositor layer for the duration of the
 * animation (`transform-gpu` = translateZ(0), plus `will-change: transform`), so the
 * glyphs are rasterized once and then merely re-composited — no per-frame smoothing
 * change. Promotion is scoped and transient: `will-change` is set only while the
 * element is actually animating, and dropped on transition end, because a permanently
 * promoted layer costs memory for nothing.
 */
export function TextAntialiasingTransformsGood() {
  const [zoomed, setZoomed] = useState(false);
  const [animating, setAnimating] = useState(false);

  const toggle = () => {
    setAnimating(true);
    setZoomed((z) => !z);
  };

  return (
    <div className="w-full max-w-sm space-y-3">
      <button
        type="button"
        onClick={toggle}
        className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
      >
        {zoomed ? 'Reset' : 'Zoom the text'}
      </button>

      <div className="flex h-40 items-center justify-center overflow-hidden rounded-xl border border-border bg-card p-4">
        {/* One transform on the wrapper; the text nodes are untouched */}
        <div
          onTransitionEnd={() => setAnimating(false)}
          className={`text-center transition-transform duration-500 motion-reduce:transition-none ${
            zoomed ? 'scale-150' : 'scale-100'
          } ${animating ? 'transform-gpu will-change-transform' : ''}`}
        >
          <p className="text-xs uppercase tracking-wide text-muted-foreground">Revenue</p>
          <p className="text-2xl font-semibold text-foreground">$48,200</p>
          <p className="text-xs text-muted-foreground">vs. $43,000 last month</p>
        </div>
      </div>

      <p className="text-xs text-success">
        The whole lockup grows as one unit &mdash; spacing, alignment and proportion all hold. On a
        promoted layer the text is rasterized once and re-composited, so the strokes stay steady
        instead of crawling. The layer is created for the transition and released when it ends.
      </p>
    </div>
  );
}
```

## References

- [Vercel Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines)
- [MDN: will-change](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/will-change)
- [MDN: transform](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transform)
- [MDN: font-smooth](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-smooth)
