How to implement spring physics buttons with Framer Motion

Just a guy who loves to write code and watch anime.
Search for a command to run...

Just a guy who loves to write code and watch anime.
No comments yet. Be the first to comment.
Qualities that make outstanding builders.

Bipedal vs quadrupedal: how many legs This is about the number of legs the creature walks on. Bipedal. Two legs. Humans, ostriches, T-rex, kangaroos, most fantasy humanoids. Quadrupedal. Four legs. Do

Intro You hear "lerp" and "smoothstep" everywhere in game dev. They sound like math jargon. They're not. Both are small tools that do the same job: smoothly move from one value to another. The problem

What is Kinematics Kinematics is the math field. It's the study of how things move without worrying about forces (which would be dynamics). FK and IK are the two branches: forward kinematics and inver

Intro Textures are usually the biggest cost in a 3D scene. Memory, bandwidth, and load time all get eaten by them. Resizing them is the obvious lever. There's more. This post is about the less obvious

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