Skip to content

[Enhancement] Smooth theme toggle — prevent flash and double-toggle #20

@kronpatel

Description

@kronpatel

Enhancement Request Template

Is your enhancement request related to a problem? Please describe.

Yes. When toggling the app theme (light ↔ dark) users can observe an abrupt flash/jump in background and text colors (FOUC) and the toggle icon can visibly glitch if clicked rapidly. This results in a jarring visual experience and can sometimes surface hydration mismatch warnings in SSR contexts.

Describe the enhancement you'd like

Make theme switching smooth and robust by:

  • Adding a short CSS transition (≈300ms) for background and text color on the root document element so theme changes animate instead of jumping.
  • Temporarily disabling the toggle and applying a small "isChanging" state during the transition to prevent rapid double toggles and race conditions.
  • Using framer-motion to animate the toggle icon (smooth rotate/fade) with a controlled transition.
  • Keeping the SSR-mounted check to avoid hydration mismatch (render the client-only toggle only after mount).

Describe alternatives you've considered

  • Applying transitions via global CSS classes. This works but can be overridden by other global rules; applying the transition programmatically to document.documentElement keeps the change explicit and scoped.
  • Letting next-themes handle transitions implicitly. next-themes doesn't add transitions by default, so user-observed flashes will persist without a supplemental approach.
  • Rendering a static placeholder icon during hydration to avoid flash. This reduces flash but doesn't provide the smooth animation experience.

Possible Implementation Details

Suggested changes in components/shared/theme-toggle.tsx:

  • Add local state:
const [isChanging, setIsChanging] = useState(false);
  • On toggle:
setIsChanging(true);
setTheme(theme === "light" ? "dark" : "light");
setTimeout(() => setIsChanging(false), 300);
  • On mount, add a temporary transition:
useEffect(() => {
  document.documentElement.style.transition = "background-color 0.3s ease-in-out, color 0.3s ease-in-out";
  return () => { document.documentElement.style.transition = ""; };
}, []);
  • Use framer-motion for the icon and disable the button while isChanging is true:
<motion.button disabled={isChanging} className={isChanging ? 'opacity-50' : ''}>
  <motion.div animate={{ rotate: theme === 'light' ? 0 : 180 }} transition={{ duration: 0.3 }}>
    {theme === 'light' ? <Moon/> : <Sun/>}
  </motion.div>
</motion.button>
  • Ensure aria-label stays present and consider adding aria-busy={isChanging} to help assistive technologies.

Additional context

  • Files to check:
    • components/shared/theme-toggle.tsx
    • Theme provider usage (likely ThemeProvider from next-themes)
  • If the app later supports multiple themes (beyond light/dark), update toggle logic accordingly.
  • Possible global CSS rules that set transition: none could block the effect — verify global styles.

Optional Sections (if relevant):

  • Priority: Medium — improves perceived polish and UX.
  • Are you willing to submit a PR for this enhancement? Yes (I already applied a fix in the repo; can open a PR).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions