Theme Toggle
iOS inspired theme toggle.
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);
};
};
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 translate-x-0 translate-y-0"
>
<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>
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>
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 }}>
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>
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);
};
In case you have any questions reach me at jakub@kbo.sk or see more of my work on Twitter.