Theme Toggle

iOS inspired theme toggle.

LightMode
Structure

This component is pretty light-weight. It only contains a button with an SVG and conditionally rendered text. While simple, it still requires a bit of tweaking to make it feel good. I started off by setting up the state and the click handler.

const ThemeButton = () => {
  const [isDark, setIsDark] = useState(false);
 
  const handleClick = () => {
    setIsDark(!isDark);
  };
};
Theme Icon

I re-created the iOS theme toggle icon in Figma and then exported it as an SVG. I made sure to structure the SVG into individual paths, so I could animate each path separately.

The original iOS animation is animated in a more complex way. I've tried that as well, but for this interaction I preferred a simpler approach.

<motion.svg
  xmlns="http://www.w3.org/2000/svg"
  width="100"
  height="100"
  viewBox="0 0 100 100"
  fill="none"
  initial={false}
  animate={{ rotate: isDark ? 180 : 0 }}
  className="size-9 will-change-transform"
>
  <motion.path
    d="M50 18C58.4869 18 66.6262 21.3714 72.6274 27.3726C78.6286 33.3737 82 41.513 82 50C82 58.4869 78.6286 66.6262 72.6275 72.6274C66.6263 78.6286 58.487 82 50.0001 82L50 50L50 18Z"
    initial={false}
    animate={{
      fill: isDark ? "var(--color-gray-100)" : "var(--color-gray-1200)",
    }}
  />
  <motion.circle
    cx="50"
    cy="50"
    r="30"
    initial={false}
    animate={{
      stroke: isDark ? "var(--color-gray-100)" : "var(--color-gray-1200)",
    }}
    strokeWidth="4"
  />
  <motion.circle
    cx="50"
    cy="50"
    r="12"
    initial={false}
    animate={{
      fill: isDark ? "var(--color-gray-100)" : "var(--color-gray-1200)",
    }}
  />
  <motion.path
    d="M50 62C53.1826 62 56.2348 60.7357 58.4853 58.4853C60.7357 56.2348 62 53.1826 62 50C62 46.8174 60.7357 43.7652 58.4853 41.5147C56.2348 39.2643 53.1826 38 50 38L50 50L50 62Z"
    initial={false}
    animate={{
      fill: isDark ? "var(--color-gray-1200)" : "var(--color-gray-300)",
    }}
  />
</motion.svg>
Text Animation

The text animation is done using AnimatePresence and animating the y position of each span. I used the popLayout mode to make the exiting element pop out of the page layout, allowing the new element to move into the layout immediately.

<AnimatePresence initial={false} mode="popLayout">
  <motion.span
    key={isDark ? "dark" : "light"}
    initial={{ opacity: 0, y: -48 }}
    animate={{ opacity: 1, y: 0 }}
    exit={{ opacity: 0, y: 48 }}
  >
    {isDark ? "Dark" : "Light"}
  </motion.span>
</AnimatePresence>
Transition

For this animation, it made sense to wrap everything in a single MotionConfig because I wanted all transitions to be the same.

<MotionConfig transition={{ type: "spring", duration: 0.7, bounce: 0 }}>
Layout

The words "Dark" and "Light" aren't the same width. This caused instant changes in the layout. I wanted these changes to be animated. I added the layout prop to the SVG and the "Mode" span.

<motion.span layout>Mode</motion.span>

For the SVG, I created a new motion.div and added the layout prop there, instead of adding the prop directly to the SVG component, as that would interfere with the rotation animation.

<motion.div layout>
  <ThemeIcon isDark={isDark} />
</motion.div>
Timeout

Finally, after setting everything up, I added a timeout to the button. Without this, clicking the button quickly can trigger the animation again before it's completed, causing it to start from the wrong direction.

const [isAnimating, setIsAnimating] = useState(false);
 
const handleClick = () => {
  if (isAnimating) return;
 
  setIsAnimating(true);
  setIsDark(!isDark);
 
  setTimeout(() => {
    setIsAnimating(false);
  }, 700);
};
More

In case you have any questions reach me at jakub@kbo.sk or see more of my work on Twitter.