implementing a custom theme toggle in framer

the design was done. it had been reviewed, refined, and approved. now it was time to build. one of the features — small but important — was a theme toggle. light and dark mode, just like in the app. but framer, our chosen platform for the new product marketing site at SwanIT Oy, didn’t give me an easy way to connect a custom button to this feature, even though it had a built-in option using color variables.
as the sole designer at SwanIT, a company focused on sustainability and the circular economy, i wear a lot of hats — branding, creative direction, design systems — you name it. when i decided to take on the development of this website too, it was because i knew i could. our developer was focused on the core product, and the marketing team needed a way to update the site quickly without waiting on anyone else.
what i didn’t expect was how tricky a ‘simple’ theme toggle would turn out to be.
here’s a deep dive into how i built a fully functional, persistent, and synced custom react ThemeToggle
component in framer—and what i learned along the way.
starting with ai—and where it broke
to get started, i turned to chatgpt. it gave me a solid foundation: a code component that works, switching icons on click and between light and dark modes. it looked like everything was set.
but problems started popping up:
- theme didn’t stay after refreshing the page: i’d toggle to light mode, refresh the page, and it immediately reverted to dark mode (because that’s what my system was set to).
- when there were multiple toggles on the site, they weren’t in sync: clicking one toggle wouldn’t update the others.
- initial load was wrong: the correct theme only applied after some user interaction.
- visual quirks: i wanted a clean, square button with subtle hover feedback just like in the image above. nothing bulky or over-designed.
why i built it myself
i found a similar toggle component made by framer university, but i couldn’t access the code behind it. i couldn’t tweak things like hover color or default icon, and it still had most of the problems i was already facing.
other components built by the community were available too, but none matched the simplicity i was after. many were too complicated or didn’t let me customize enough.
so i decided to build my own from scratch.
my non-negotiables
before jumping in, i made a checklist of what this button had to do:
- match the user’s system preference (light or dark) when they visit the site for the first time.
- remember the user’s choice, even after they leave and come back.
- keep all toggles on the page in sync.
- look clean and customizable — let me set icons, hover colors, size, and so on.
- support framer’s built-in color variables, so the site still adapts to light/dark mode themes.
building the engine: detecting the system theme
this function helped me figure out whether someone’s device prefers dark or light mode:
function getOSTheme() {
if (typeof window !== "undefined" && window.matchMedia) {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
}
return "light"
}
managing theme with a custom hook
this special function called useTheme
controls how the theme is stored, read, and updated:
function useTheme() {
const [isDark, setIsDark] = React.useState<boolean | null>(null)
const [isLoaded, setIsLoaded] = React.useState(false)
React.useEffect(() => {
const toggleState = localStorage.getItem("currentToggleState")
if (toggleState) {
setIsDark(toggleState === "dark")
} else {
setIsDark(getOSTheme() === "dark")
}
}, [])
React.useEffect(() => {
if (isDark !== null) {
const styleTags = document.querySelectorAll("style")
styleTags.forEach((styleTag) => {
if (styleTag.textContent?.includes("prefers-color-scheme")) {
styleTag.innerHTML = styleTag.textContent.replace(
/prefers-color-scheme: \w+/,
`prefers-color-scheme: ${isDark ? "dark" : "light"}`
)
}
})
localStorage.setItem("currentToggleState", isDark ? "dark" : "light")
document.documentElement.classList.toggle("dark", isDark)
setIsLoaded(true)
}
}, [isDark])
const toggleTheme = () => {
setIsDark(prev => !prev)
}
return [isDark, toggleTheme, isLoaded] as const
}
this function became the brain behind the button.
the finished toggle component
once the logic was done, i connected it to the visual part — the actual button users would click:
export function ThemeToggle({
lightIcon,
darkIcon,
iconWeight,
defaultColor,
hoverColor,
size,
}) {
const [isDark, toggleTheme, isLoaded] = useTheme()
const [isHovered, setIsHovered] = React.useState(false)
if (!isLoaded) return null
const IconComponent = isDark ? phosphorIcons[lightIcon] : phosphorIcons[darkIcon]
const color = isHovered ? hoverColor : defaultColor
return (
<span
onClick={toggleTheme}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
transition: "color 0.3s ease, transform 0.3s ease",
}}
>
{IconComponent && (
<IconComponent
color={color}
weight={iconWeight}
size={size}
/>
)}
</span>
)
}
it’s just a clean, reusable button that swaps between two icons based on the theme.
syncing multiple toggles
one of the harder parts was making sure all buttons on the page updated at the same time. in react (the framework framer uses), each component manages its own state — so changing one didn’t affect the others.
the fix? i made sure the theme state checked the saved setting in localStorage
and reloaded it when needed. you could go deeper and add a custom event or a watcher, but for this use case, that was enough.
a note on the process
i didn’t figure this out alone, not even with my buddy, chatgpt.
while working on a side project with my friend, Aremu Smog, i casually mentioned the issue. that turned into a 90-minute debugging session where we finally spotted the problem.
turns out, the issue wasn’t in the hook — it was that the component wasn’t fully replacing the old styles on the page. the theme looked right in one part but not everywhere. the fix? using querySelectorAll
to find and update all <style>
tags that mentioned prefers-color-scheme
.
after that, i returned to chatgpt to clean up the code and sync multiple toggle components.
final thoughts
what seemed like a minor ui detail turned into a surprisingly deep what looked like a small design detail turned into a much bigger challenge. but now, i have a theme toggle that:
- works with framer’s color system
- syncs between all buttons on the page
- remembers what the user picked
- is lightweight, customizable, and fully mine
and that’s a win worth writing about.