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
.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.
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.
whileHoverscales up to 1.04 using a spring. It overshoots slightly past 1.04 then bounces back. That overshoot is the magic.whileTapsquishes down to 0.97 on press. Gives the button physical weight. You push it and it compresses.The spring config controls the feel.
stiffnessis how fast it moves.dampingis how quickly the bounce settles.
Understanding the spring config
{ 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.
<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.
<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.
const hasSpring: Record<ButtonVariant, boolean> = {
primary: true,
outline: false,
ghost: false,
'danger-outline': false,
'cancel-muted': false,
}
Then conditionally apply.
<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.
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
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.





