Dark Mode Toggle

Oh boy, I had to do this one. No grand explanation for this one, just a simple button that morphs between a sun and a moon, and helps you toggle between light and dark mode. And yes, this site does have a dark mode :)

The interaction

Three things, all on the same easing curve so the eye reads them as one motion:

  1. Button background crossfade — cream (#fef3c7) ↔ navy (#0f172a), animated by Framer Motion's animate prop on the button itself.
  2. Icon color crossfade — amber (#f59e0b) ↔ near-white (#fafafa), animated on the same parent so the lucide icon picks it up via currentColor.
  3. Icon swap — the outgoing icon rotates 180° and scales to 0; the incoming icon rotates 180° from the other direction and scales up from 0. Wrapped in <AnimatePresence mode="wait"> so the exit completes before the enter starts. Without mode="wait" both icons exist on screen at once for a frame and the whole thing looks broken.

Why it works well

  • The rotation does the work. Without it, the swap is just a crossfade and forgettable. Spinning the icons makes the transition feel like a deliberate flip — like turning a coin.
  • Same easing on background and icon. Both use [0.4, 0, 0.2, 1]. When two animations share a curve, the brain reads them as one intention rather than two independent things happening to coincide.
  • Soft warm/cool palette. Cream → navy is the temperature shift the icons describe. Pure white → black would be harsh and strip out the metaphor — the sun isn't white and the moon isn't black, they're warm and cool.

The shape of the code

<motion.button
  animate={{
    backgroundColor: dark ? '#0f172a' : '#fef3c7',
    color: dark ? '#fafafa' : '#f59e0b',
  }}
  transition={{ duration: 0.5, ease: [0.4, 0, 0.2, 1] }}
>
  <AnimatePresence mode="wait" initial={false}>
    <motion.span
      key={dark ? 'moon' : 'sun'}
      initial={{ rotate: -180, scale: 0, opacity: 0 }}
      animate={{ rotate: 0, scale: 1, opacity: 1 }}
      exit={{ rotate: 180, scale: 0, opacity: 0 }}
      transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
    >
      {dark ? <Moon size={56} /> : <Sun size={56} />}
    </motion.span>
  </AnimatePresence>
</motion.button>

The two non-obvious bits: mode="wait" is what sequences exit-then-enter so you never see both icons at once, and initial={false} on the AnimatePresence skips the entrance animation on first mount — without it, the sun spins in every time the page loads, which feels like a glitch.