# How to implement spring physics buttons with Framer Motion

# Introduction

Buttons that scale on hover with CSS feel dead. No overshoot. No bounce. No life.

Spring physics fix this. The button pops past its target scale and settles back. Feels like it has weight.

# The problem with CSS scale

```css
.button:hover {
  transform: scale(1.02);
  transition: transform 0.2s ease;
}
```

This moves from A to B in a straight line. Predictable. Boring. Every hover feels identical.

# The fix. motion.button with spring physics.

```tsx
import { motion } from 'motion/react'

<motion.button
  whileHover={{ scale: 1.04 }}
  whileTap={{ scale: 0.97 }}
  transition={{ type: 'spring', stiffness: 400, damping: 15 }}
>
  Subscribe
</motion.button>
```

Three things happening here.

1.  `whileHover` scales up to 1.04 using a spring. It overshoots slightly past 1.04 then bounces back. That overshoot is the magic.
    
2.  `whileTap` squishes down to 0.97 on press. Gives the button physical weight. You push it and it compresses.
    
3.  The spring config controls the feel. `stiffness` is how fast it moves. `damping` is how quickly the bounce settles.
    

# Understanding the spring config

```tsx
{ type: 'spring', stiffness: 400, damping: 15 }
```

**stiffness** controls speed. Higher means snappier response.

*   100 = lazy. slow to reach target.
    
*   400 = punchy. gets there fast.
    
*   800 = aggressive. almost instant.
    

**damping** controls bounce. Lower means more overshoot.

*   10 = very bouncy. visible wobble.
    
*   15 = snappy with subtle overshoot. the sweet spot.
    
*   25 = almost no bounce. starts to feel like CSS again.
    
*   40 = no visible bounce at all. defeats the purpose.
    

Start with `stiffness: 400, damping: 15`. Adjust from there.

# The CSS transition trap

If your button also has CSS `transition-all`. motion and CSS will fight over `transform`.

Bad.

```tsx
<motion.button
  whileHover={{ scale: 1.04 }}
  className="transition-all ..."
>
```

CSS intercepts the scale change and applies its own easing on top of the spring. The bounce disappears.

Good.

```tsx
<motion.button
  whileHover={{ scale: 1.04 }}
  className="transition-[color,background-color,border-color,box-shadow] ..."
>
```

Let CSS handle colors and shadows. Let motion handle transform. They stay in their lanes.

# Choosing which variants get springs

Not every button needs bounce. Ghost buttons that just change text color. Outline buttons that tint their background. These are subtle. A spring scale on them would feel wrong.

Reserve spring physics for buttons that demand attention. Primary CTAs. Upgrade buttons. The action you want users to take.

```tsx
const hasSpring: Record<ButtonVariant, boolean> = {
  primary: true,
  outline: false,
  ghost: false,
  'danger-outline': false,
  'cancel-muted': false,
}
```

Then conditionally apply.

```tsx
<motion.button
  whileHover={hasSpring[variant] ? { scale: 1.04 } : undefined}
  whileTap={hasSpring[variant] ? { scale: 0.97 } : undefined}
  transition={hasSpring[variant] ? springConfig : undefined}
>
```

When `whileHover` is `undefined`. motion does nothing. The button behaves like a normal element. Zero overhead.

# Type conflict with motion.button

`motion.button` has its own `onAnimationStart` that conflicts with React's `AnimationEventHandler`. If you spread `ButtonHTMLAttributes` you get a type error.

Fix by omitting the conflicting props.

```tsx
type ButtonProps = {
  variant: ButtonVariant
  children: ReactNode
} & Omit<
  ButtonHTMLAttributes<HTMLButtonElement>,
  'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'
>
```

Same applies to drag events. motion owns those too.

# The full picture

```tsx
import { cn } from '@/utils/cn'
import { motion } from 'motion/react'

const springTransition = {
  type: 'spring',
  stiffness: 400,
  damping: 15,
} as const

function Button({ variant, children, className, ...props }) {
  const isSpring = variant === 'primary'

  return (
    <motion.button
      whileHover={isSpring ? { scale: 1.04 } : undefined}
      whileTap={isSpring ? { scale: 0.97 } : undefined}
      transition={isSpring ? springTransition : undefined}
      className={cn(
        'transition-[color,background-color,border-color,box-shadow]',
        variantStyles[variant],
        className
      )}
      {...props}
    >
      {children}
    </motion.button>
  )
}
```

That is it. One spring config. Conditional application. CSS handles colors. Motion handles physics. The button feels alive.
