Skip to content

feat(ui): add explicit system option to the theme toggle - #5639

Open
fabianfrankwerner wants to merge 1 commit into
Dokploy:canaryfrom
fabianfrankwerner:feat/system-theme-mode-toggle
Open

fabianfrankwerner wants to merge 1 commit into
Dokploy:canaryfrom
fabianfrankwerner:feat/system-theme-mode-toggle

Conversation

@fabianfrankwerner

@fabianfrankwerner fabianfrankwerner commented Oct 9, 2026 •

Copy link
Copy Markdown

What is this PR about?

Adds an explicit System option to the dark/light mode setting, closing #5638.

apps/dokploy/pages/_app.tsx already configures next-themes with defaultTheme="system" and enableSystem, but apps/dokploy/components/ui/modeToggle.tsx only ever called setTheme("dark") / setTheme("light"), and next-themes persists that choice to localStorage. So "system" was only a first-visit default: after a single click the choice became sticky and there was no UI anywhere that could restore "system" — the only way back was manually clearing localStorage. As a result the panel stopped following OS-level appearance changes (macOS/iOS Dark Mode, forced-colors on Windows/Linux).

This PR turns the existing button into a dropdown with three radio items — Light, Dark, System — so the active mode is both visible and selectable.

Design notes, aimed at staying minimal:

  • The trigger keeps the existing Button and the existing Sun/Moon rotate + scale classes verbatim. It still renders off the dark class on <html>, which next-themes sets in its pre-hydration script, so in system mode the icon shows the resolved appearance and the animation still plays when the OS theme changes. No third icon on the trigger, and no lost animation.
  • The mounted guard is scoped to the radio group value only, so the checkmark cannot cause a hydration mismatch and the trigger renders exactly as before.
  • DropdownMenuRadioGroup / DropdownMenuRadioItem already exist in components/ui/dropdown-menu.tsx; sonner.tsx already forwards "system" to the toaster. No new dependency, no schema change, and _app.tsx is untouched.
  • The sr-only label is a plain string (the previous attempt in feat: add support for a system theme #3163 rendered a literal Switch to ${nextTheme} theme), and the trigger carries a matching aria-label.

Scope is one file, apps/dokploy/components/ui/modeToggle.tsx. Its only consumer is apps/dokploy/components/layouts/user-nav.tsx, which renders it inside the account dropdown.

Checklist

Before submitting this PR, please make sure that:

  • You created a dedicated branch based on the canary branch.
  • You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
  • You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.

What I actually verified

Tested against a real local instance following the CONTRIBUTING setup — pnpm run dokploy:setup, pnpm run server:script, pnpm run dokploy:dev, then driven through a real Chromium at 1440x900:

  • The account menu now opens a theme submenu offering exactly Light, Dark and System as menuitemradio items.
  • On a fresh visit with no stored preference, System is pre-selected, and the panel already resolves from the OS before hydration — html gets class="… dark" under an OS dark scheme and class="… light" under light, with zero React hydration warnings in both cases.
  • With System selected, localStorage.theme === "system" and the dark class on <html> tracked an OS scheme change live in both directions (light → dark → light) without a reload.
  • Dark stayed dark under both a light and a dark OS; Light stayed light under a dark OS. Both persisted across a full page reload, and the checkmark tracked the active option after the reload.
  • The existing Sun/Moon rotate/scale animation is intact — the trigger icon flips from sun to moon when the OS scheme changes while System is selected, which is the point of keeping the trigger driven by the dark class rather than by a per-mode icon.

Also checked statically: biome check with the repo-pinned @biomejs/biome@2.5.7 passes on the changed file, and tsc --noEmit in apps/dokploy passes.

One note on scope: I did not add an automated component test. The repo's vitest setup is server-side only (include: ["__test__/**/*.test.ts"], node environment, no jsdom/@testing-library installed), and standing up a component-test stack is a separate concern from a one-file UI change. Happy to open a follow-up PR for that if you want component tests — say the word.

Issues related (if applicable)

closes #5638

Screenshots (if applicable)

System selected, OS in light mode — sun icon, light UI, System checked:

System selected with the OS in light mode

Same selection, OS switched to dark — moon icon, whole UI follows, System still checked. The point of these two together is that the mode stayed System while the resolved appearance changed, and the trigger animation still ran:

System selected with the OS in dark mode

Light pinned while the OS is dark — the override still wins:

Light pinned while the OS is dark

Dark pinned while the OS is light — the other override direction:

Dark pinned while the OS is light

Note on the previous attempt

#3163 tried to reach the same goal by cycling light → dark → system on the existing button. That approach was closed with feedback about over-engineering, the lost CSS animations, and the sr-only bug. This PR deliberately takes the dropdown route instead, which keeps the animations and the existing trigger markup intact, and I kept the diff deliberately small.

The toggle only ever called setTheme("dark")/setTheme("light"), which
next-themes persists to localStorage. Although _app.tsx already sets
defaultTheme="system" with enableSystem, that is only a first-visit
default: after a single click the choice is sticky and there is no UI
anywhere that can restore "system", so the panel stops following OS
appearance changes.

Replace the two-state button with a dropdown exposing Light, Dark and
System as radio items, so the current mode is visible and selectable.

The trigger keeps the existing Button and the existing Sun/Moon
rotate+scale classes verbatim. It still renders from the `dark` class on
<html>, which next-themes applies in its pre-hydration script, so in
system mode the icon reflects the resolved appearance and the animation
still plays when the OS theme changes.

The mounted guard is scoped to the radio group `value` only, so the
checkmark cannot cause a hydration mismatch and the trigger renders
identically to before. No change to _app.tsx and no new dependency.

Closes Dokploy#5638

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a "System" option to the dark/light mode setting (follow OS appearance)

1 participant