それはどのようなものになるでしょうか#
一:外観とインタラクションの観点から:
- 1 つのボタンだけで、クリックすることで切り替え、三択のドロップダウンメニューではない
- サーバーサイドレンダリングに優しく、ボタンは現在のテーマがダークモードかどうかを直接反映する
- ページをリフレッシュしてもちらつかない
- 切り替え時にページの色が全体的に遷移し、不一致が生じない
二:処理ロジックの観点から:
- ユーザーの好みはブラウザストレージに永続化できる
- ユーザーの好みはシステムの好みに無感覚で復元できる
私は Jotai を使って実現します。私は Jotai が好きです。
システムの好みの状態を取得する#
Media queries の prefers-color-scheme と matchMedia を使用して、システムの好みがダークモードかどうかを取得します。
onMount 関数は atom がサブスクライブされたときに実行され、サブスクリプションが解除されたときにその戻り値の関数を実行します。サーバーサイドレンダリングに対応するためにブラウザ環境の判断ロジックを追加します。
function atomSystemDark() {
const isSystemDarkAtom = atom<boolean | null>(null);
isSystemDarkAtom.onMount = (set) => {
if (typeof window === "undefined") return;
const matcher = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => {
set(matcher.matches);
};
update();
matcher.addEventListener("change", update);
return () => {
matcher.removeEventListener("change", update);
};
};
return isSystemDarkAtom;
}
いつダークモードにすべきか#
- ユーザーの好みがダークモードである
- システムの好みがダークモードであり、ユーザーの好みがライトモードでない
type Theme = "system" | "light" | "dark";
function isDarkMode(setting?: Theme | null, isSystemDark?: boolean | null) {
return setting === "dark" || (!!isSystemDark && setting !== "light");
}
ダークモードの状態の読み取りと切り替え#
- ユーザーのテーマの好みとシステムの好みを読み取って、現在がダークモードかどうかを判断します。ユーザーのテーマの好みは atomWithStorage を使用してブラウザに保存します(Fixes 2-1)。
- jotai-effect を利用して副作用を処理し、状態を HTML ページに同期させ、ユーザーの好みと現在のシステムの好みが一致する場合、ユーザーの好みをシステムの好みに復元します(Fixes 2-2)。
- 切り替えボタンのクリックコールバックはパラメータを受け取らず、現在のユーザーの好みとシステムの好みに基づいてユーザーの好みを更新します。
function atomDark() {
const isSystemDarkAtom = atomSystemDark();
const themeAtom = atomWithStorage<Theme>(storageKey, "system");
const toggleDarkEffect = atomEffect((get, set) => {
const theme = get(themeAtom);
const isSystemDark = get(isSystemDarkAtom);
const isDark = isDarkMode(theme, isSystemDark);
document.documentElement.classList.toggle("dark", isDark);
if (
(theme === "dark" && isSystemDark) ||
(theme === "light" && !isSystemDark)
) {
set(themeAtom, "system");
}
});
return atom(
(get) => {
get(toggleDarkEffect);
const theme = get(themeAtom);
const isSystemDark = get(isSystemDarkAtom);
return isDarkMode(theme, isSystemDark);
},
(get, set) => {
const theme = get(themeAtom);
const isSystemDark = get(isSystemDarkAtom);
set(
themeAtom,
theme === "system" ? (isSystemDark ? "light" : "dark") : "system"
);
}
);
}
使用可能なフックができました#
直接 atom を使用する代わりに、atomDark
のためにカスタムフックを作成する方が良い選択です。なぜなら、Jotai の write 関数はリアクティブではなく、直接 atom を使用すると toggleDark
関数だけが使用される可能性があり、その場合に読み取られる状態は正しくありません。
const isDarkAtom = atomDark();
function useDark() {
const isDark = useAtomValue(isDarkAtom);
const toggleDark = useSetAtom(isDarkAtom) as () => void;
return { isDark, toggleDark };
}
ボタンを作成する#
- tailwindcss-icons と Lucide を使用して、現在のテーマの状態を示すアイコンを導入します(Fixes 1-1)。
- tailwind の Dark Mode サポートを使用して、状態アイコンを正しく表示し、
isDark
状態を読み取らずにサーバーサイドレンダリングに優しくします(Fixes 1-2)。 - 少しのトランジションアニメーション効果を加えます。
function AppearanceSwitch() {
const { toggleDark } = useDark();
return (
<button onClick={toggleDark} className="flex">
<div className="i-lucide-sun scale-100 dark:scale-0 transition-transform duration-500 rotate-0 dark:-rotate-90" />
<div className="i-lucide-moon absolute scale-0 dark:scale-100 transition-transform duration-500 rotate-90 dark:rotate-0" />
</button>
);
}
ページのちらつきをどう解決するか?#
ブラウザが読み込んだページのスタイルとユーザーの好みが一致しない場合、ページがちらつき、スタイルが更新されることがあります。テーマを正しくするために、ページが読み込まれる前にスクリプトを注入する必要があります。(Fixes 1-3)
Vite を使用している場合は、index.html
にスクリプトを注入できます:
<script>
!(function () {
var e =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches,
t = localStorage.getItem("use-dark") || '"system"';
('"dark"' === t || (e && '"light"' !== t)) &&
document.documentElement.classList.toggle("dark", !0);
})();
</script>
Next.js を使用している場合は、dangerouslySetInnerHTML
を使用してスクリプトを注入できます。特に、クライアントでの水和時に React が警告を無視するために suppressHydrationWarning
を使用する必要があります。なぜなら、クライアントで html
ノードの className
を切り替えたため、サーバーサイドレンダリングの結果と一致しない可能性があるからです。
function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<>
<script
dangerouslySetInnerHTML={{
__html: "here",
}}
></script>
{children}
</>
);
}
function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
切り替え時にトランジションを無効にする#
Disable transitions on theme toggle という記事では、これを行う理由が非常に詳細に説明されています。なぜなら、切り替え時に一部のコンポーネントの色の遷移とページテーマの遷移のリズムが一致しないことを望まないからです。(Fixes 1-4)
これは素晴らしいですが、私たちのテーマ切り替えボタンは transition
を使用しているため、一部のコンポーネントにホワイトリストを設定できる必要があります。CSS の :not 擬似クラスを使用して実現できます。
/**
* credit: https://github.com/pacocoursey/next-themes/blob/cd67bfa20ef6ea78a814d65625c530baae4075ef/packages/next-themes/src/index.tsx#L285
*/
export function disableAnimation(disableTransitionExclude: string[] = []) {
const css = document.createElement("style");
css.append(
document.createTextNode(
`
*${disableTransitionExclude.map((s) => `:not(${s})`).join("")} {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
-ms-transition: none !important;
transition: none !important;
}
`
)
);
document.head.append(css);
return () => {
// Force restyle
(() => window.getComputedStyle(document.body))();
// Wait for next tick before removing
setTimeout(() => {
css.remove();
}, 1);
};
}
最後に#
上記の問題を処理した後、私は満足のいくダークモード切り替えボタンを持つことができました。私はそれを npm に公開しましたので、直接使用できます。GitHub で完全なコードと例を確認できます。