james-chimdindu
I'mJamesChimdinduCreativeDesigner

Find all my strengths and core
competencies in the puzzle
game

Hint: the unique characteristics
and attributes that define a
brand

James is a generational talent
in product design. From
projects large and small, he
never disappoints.

Charles Morgan, Cofounder

Now playing
Wildflower, Billie Eilish

New article
implementing a custom theme
toggle in framer Read here

Discover a spooky Easter egg
for Halloween. Coming soon!

Find all my strengths and core
competencies in the puzzle game

Hint: transitions between two
states or elements in a design

James is a generational talent in
product design. From projects
large and small, he never
disappoints.

Charles Morgan, Cofounder

Now playing
Wildflower, Billie Eilish

New article
implementing a custom theme
toggle in framer Read here

Discover a spooky Easter egg for
Halloween. Coming soon!

5 mins read. last updated apr 29, 2025, 11:53 am

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:

code snippet
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:

code snippet
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:

code snippet
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.

x
b
u
z
g
t
p
l
e
p
v
u
j
l
x
o
d
a
y
o
q
k
v
m
g
h
a
w
k
j
j
n
g
b
z
w
y
g
c
a
s
i
o
w
c
c
h
i
m
d
i
n
d
u
z
d
i
e
e
f
n
s
m
b
j
o
s
t
q
a
u
a
r
m
d
o
r
l
e
i
a