# Translate by Percentage, Not Pixels

**SHOULD** · **ID:** `animations-percentage-translate` · **Category:** animations
**Source:** [Emil Kowalski](https://emilkowalski.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-percentage-translate

> SHOULD: Park off-screen elements with translate percentages, not hardcoded px: `translateY(100%)` resolves against the element's own height, so it stays correct when a toast wraps or a drawer gains a row. Add edge gaps with `calc(100% + 12px)` rather than a magic number.

Offset off-screen elements with translate percentages so the value stays correct at any size

A translate percentage resolves against the element itself, not its parent: translateY(100%) always moves it by exactly its own height, and translateX(-100%) by its own width. That is why a hardcoded translateY(300px) is a latent bug — it was measured against the content that existed the day it was written, and the moment a toast wraps to a second line or a drawer gains a row, the offset is wrong. Too small and the "hidden" element peeks into view; too large and it flies in from further away than it should, stretching the perceived duration. Percentages are self-correcting, which is exactly how Sonner parks toasts and Vaul parks drawers. Add the gap to the edge with calc(100% + 12px) rather than baking it into a magic number.

## Rule snippet

```tsx
.toast { transform: translateY(calc(100% + 12px)); } /* not translateY(300px) */
.toast[data-open] { transform: translateY(0); }
```

## Bad — do not do this

`animations-percentage-translate-bad`

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

/**
 * Bad: the toast is parked off-screen with a hardcoded `translateY(56px)` —
 * a number someone measured against the one-line toast that existed at the time.
 * Grow the content to two lines and the offset is 20px short: the toast peeks
 * above the bottom edge while it is supposed to be hidden.
 */
export function PercentageTranslateBad() {
  const [shown, setShown] = useState(false);
  const [twoLines, setTwoLines] = useState(false);

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-3">
        <button
          onClick={() => setShown((v) => !v)}
          className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm transition-transform duration-150 ease-out active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          {shown ? 'Hide toast' : 'Show toast'}
        </button>
        <label className="inline-flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
          <input
            type="checkbox"
            checked={twoLines}
            onChange={(e) => setTwoLines(e.target.checked)}
            className="size-3.5 accent-primary"
          />
          Grow to two lines
        </label>
      </div>

      <div className="relative h-40 overflow-hidden rounded-lg bg-muted">
        <div
          className="absolute inset-x-3 bottom-3 rounded-md border border-border bg-card p-3 text-sm text-card-foreground"
          style={{
            transition: 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)',
            transform: shown ? 'translateY(0)' : 'translateY(56px)',
          }}
        >
          <p className="font-medium">Deployment queued</p>
          {twoLines && (
            <p className="mt-1 text-muted-foreground">
              Waiting on the build cache before it can start.
            </p>
          )}
        </div>
      </div>

      <p className="text-xs text-destructive">
        56px was correct for exactly one toast height. At two lines the "hidden" toast still peeks 20px into view.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-percentage-translate-good`

```tsx
import { useState } from 'react';
import { ReducedMotionSwitch } from '@/components/demo-kit/ReducedMotionSwitch';
import { useSimulatedReducedMotion } from '@/hooks/useSimulatedReducedMotion';

/**
 * Good: same 300ms, same curve, same layout. The only change is the hidden
 * offset — `translateY(calc(100% + 12px))`. A translate percentage resolves
 * against the element's *own* height, so 100% always clears the toast whatever
 * it currently contains; the 12px covers the gap to the bottom edge.
 */
export function PercentageTranslateGood() {
  const [shown, setShown] = useState(false);
  const [twoLines, setTwoLines] = useState(false);
  const reduced = useSimulatedReducedMotion();

  const motion = reduced
    ? {
        transition: 'opacity 150ms linear',
        transform: 'translateY(0)',
        opacity: shown ? 1 : 0,
      }
    : {
        transition: 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)',
        transform: shown ? 'translateY(0)' : 'translateY(calc(100% + 12px))',
        opacity: 1,
      };

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-3">
        <button
          onClick={() => setShown((v) => !v)}
          className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm transition-transform duration-150 ease-out active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          {shown ? 'Hide toast' : 'Show toast'}
        </button>
        <label className="inline-flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
          <input
            type="checkbox"
            checked={twoLines}
            onChange={(e) => setTwoLines(e.target.checked)}
            className="size-3.5 accent-primary"
          />
          Grow to two lines
        </label>
        <ReducedMotionSwitch />
      </div>

      <div className="relative h-40 overflow-hidden rounded-lg bg-muted">
        <div
          className="absolute inset-x-3 bottom-3 rounded-md border border-border bg-card p-3 text-sm text-card-foreground"
          style={motion}
        >
          <p className="font-medium">Deployment queued</p>
          {twoLines && (
            <p className="mt-1 text-muted-foreground">
              Waiting on the build cache before it can start.
            </p>
          )}
        </div>
      </div>

      <p className="text-xs text-success">
        translateY(calc(100% + 12px)) is measured against the toast itself, so it hides cleanly at one line, two lines,
        or ten. Reduced motion swaps the slide for a fade.
      </p>
    </div>
  );
}
```

## References

- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [MDN — translateY()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/transform-function/translateY)
- [Sonner](https://github.com/emilkowalski/sonner)
- [Vaul](https://github.com/emilkowalski/vaul)
