# Scale Pressable Elements on :active

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

> SHOULD: Answer every press instantly with `transform: scale(0.97)` on `:active` and `transition: transform 160ms ease-out`. Stay inside 0.95–0.98 (0.85 reads as broken), and apply it to any pressable element — cards, icon buttons, list rows — not only `<button>`.

Confirm a press with transform: scale(0.97) and a 160ms ease-out transition

A press is the one moment the interface can answer instantly, before any network call resolves. Without it the user has no evidence the pointer-down landed, so on a slow action they press again — the classic double-submit. `transform: scale(0.97)` with `transition: transform 160ms ease-out` is the whole fix: compositor-only, cheap, and the range that reads as pressed is narrow (0.95–0.98) — 0.85 collapses the control and reads as broken. It is also why `scale()` is the right property rather than a size change: `scale()` scales children too, so the icon and the label go down with the surface, which is exactly the physical model of a button being pushed. This applies to any pressable element — cards, icon buttons, list rows — not just things tagged `<button>`.

## Rule snippet

```tsx
.pressable { transition: transform 160ms ease-out; }
.pressable:active { transform: scale(0.97); } /* Tailwind: active:scale-[0.97] */
```

## Bad — do not do this

`interactions-press-feedback-scale-bad`

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

export function PressFeedbackScaleBad() {
  const [log, setLog] = useState<string[]>([]);
  const record = (what: string) =>
    setLog((prev) => [`${what} — pointerdown fired, nothing moved`, ...prev].slice(0, 3));

  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">
        {/* No transform at all: the only state change is hover, which touch users never see. */}
        <button
          type="button"
          onPointerDown={() => record('Primary button')}
          className="w-full rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground hover:opacity-90"
        >
          Save changes
        </button>

        <button
          type="button"
          onPointerDown={() => record('Card')}
          className="flex w-full items-center gap-2 rounded-md border border-border bg-muted p-3 text-left text-sm text-foreground"
        >
          <span className="grid size-8 place-items-center rounded-md bg-primary text-primary-foreground">
            ★
          </span>
          <span>
            Pressable card
            <span className="block text-xs text-muted-foreground">no press feedback either</span>
          </span>
        </button>

        {/* Overdone: 0.85 reads as broken, not responsive. */}
        <button
          type="button"
          onPointerDown={() => record('Overdone button')}
          className="w-full rounded-md border border-border bg-muted px-3 py-2 text-sm text-foreground transition-transform duration-500 ease-in-out active:scale-[0.85]"
        >
          Overdone: scale(0.85), 500ms
        </button>

        <div className="space-y-1 text-xs text-error">
          {log.length === 0 ? (
            <span className="text-muted-foreground">Press and hold each control above.</span>
          ) : (
            log.map((l, i) => <div key={i}>{l}</div>)
          )}
        </div>
      </div>
      <p className="text-xs text-error">
        Hold the mouse down on the first two: the event fires, but the pixels never acknowledge it,
        so a slow network reads as a dead button and you click again. The third overcorrects —
        <code>scale(0.85)</code> over 500ms collapses the control and feels broken rather than
        responsive.
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-press-feedback-scale-good`

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

// scale(0.97) + 160ms ease-out. Subtle enough to read as "pressed", not as "collapsed".
const press =
  'transition-transform duration-[160ms] ease-out active:scale-[0.97] motion-reduce:transition-none';

export function PressFeedbackScaleGood() {
  const [log, setLog] = useState<string[]>([]);
  const record = (what: string) =>
    setLog((prev) => [`${what} — pressed, scaled to 0.97`, ...prev].slice(0, 3));

  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">
        <button
          type="button"
          onPointerDown={() => record('Primary button')}
          className={`w-full rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground hover:opacity-90 ${press}`}
        >
          Save changes
        </button>

        <button
          type="button"
          onPointerDown={() => record('Card')}
          className={`flex w-full items-center gap-2 rounded-md border border-border bg-muted p-3 text-left text-sm text-foreground ${press}`}
        >
          <span className="grid size-8 place-items-center rounded-md bg-primary text-primary-foreground">
            ★
          </span>
          <span>
            Pressable card
            <span className="block text-xs text-muted-foreground">
              the icon and text shrink with it
            </span>
          </span>
        </button>

        <button
          type="button"
          aria-label="Star"
          onPointerDown={() => record('Icon button')}
          className={`grid size-11 place-items-center rounded-md border border-border bg-muted text-foreground ${press}`}
        >
          ★
        </button>

        <div className="space-y-1 text-xs text-success">
          {log.length === 0 ? (
            <span className="text-muted-foreground">Press and hold each control above.</span>
          ) : (
            log.map((l, i) => <div key={i}>{l}</div>)
          )}
        </div>
      </div>
      <p className="text-xs text-success">
        <code>transform: scale(0.97)</code> on <code>:active</code> with{' '}
        <code>transition: transform 160ms ease-out</code>. Hold the pointer down and the control
        settles under your finger, then springs back on release —{' '}
        <code>scale()</code> takes the children with it, so the icon and label shrink too. Any
        pressable element earns this, not just buttons.
      </p>
    </div>
  );
}
```

## References

- [Emil Kowalski: review-animations STANDARDS.md](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/STANDARDS.md)
- [MDN: scale()](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/scale)
