Skip to main content

Command Palette

Search for a command to run...

How to implement spring physics buttons with Framer Motion

Published
4 min read
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.

  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

{ 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.