Example/update blog starter (#66926)

What?

Updated blog-starter example to support dark theme. Also added a button
to switch modes (User preference).

→ User can opt for dark / light / system mode
→ Mode is persisted using localStorage
→ Mode is also synced across browsing contexts
→ No FOUC (Flash of Unstyled Content)
→ Full SSG
→ No additional dependency

Why?

Now that dark mode is a first-class feature of many operating systems,
it’s becoming more and more common to design a dark version of your
website to go along with the default design.

How?

- Used tailwind `dark:` modifier
- Used localStorage for persisting user's preference
- Used storage event to sync the mode across tabs/iframes
- Injected script to avoid FOUC
- Added appropriate comments in the code for clarity and readability
This commit is contained in:
Mayank 2024-06-23 13:49:09 +05:30 committed by GitHub
parent f5d616b77e
commit 4e9b405c4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 190 additions and 15 deletions

View file

@ -7,21 +7,21 @@
},
"dependencies": {
"classnames": "^2.5.1",
"date-fns": "^3.3.1",
"date-fns": "^3.6.0",
"gray-matter": "^4.0.3",
"next": "14.1.0",
"react": "^18",
"react-dom": "^18",
"next": "latest",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"remark": "^15.0.1",
"remark-html": "^16.0.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
"@types/node": "^20.14.8",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.2"
}
}

View file

@ -9,7 +9,7 @@ type Props = {
const Alert = ({ preview }: Props) => {
return (
<div
className={cn("border-b", {
className={cn("border-b dark:bg-slate-800", {
"bg-neutral-800 border-neutral-800 text-white": preview,
"bg-neutral-50 border-neutral-200": !preview,
})}

View file

@ -3,7 +3,7 @@ import { EXAMPLE_PATH } from "@/lib/constants";
export function Footer() {
return (
<footer className="bg-neutral-50 border-t border-neutral-200">
<footer className="bg-neutral-50 border-t border-neutral-200 dark:bg-slate-800">
<Container>
<div className="py-28 flex flex-col lg:flex-row items-center">
<h3 className="text-4xl lg:text-[2.5rem] font-bold tracking-tighter leading-tight text-center lg:text-left mb-10 lg:mb-0 lg:pr-4 lg:w-1/2">

View file

@ -2,7 +2,7 @@ import Link from "next/link";
const Header = () => {
return (
<h2 className="text-2xl md:text-4xl font-bold tracking-tight md:tracking-tighter leading-tight mb-20 mt-8">
<h2 className="text-2xl md:text-4xl font-bold tracking-tight md:tracking-tighter leading-tight mb-20 mt-8 flex items-center">
<Link href="/" className="hover:underline">
Blog
</Link>

View file

@ -0,0 +1,56 @@
.switch {
all: unset;
position: absolute;
right: 20px;
top: 70px;
display: inline-block;
color: currentColor;
border-radius: 50%;
border: 1px dashed currentColor;
cursor: pointer;
--size: 24px;
height: var(--size);
width: var(--size);
transition: all 0.3s ease-in-out 0s !important;
}
[data-mode="system"] .switch::after {
position: absolute;
height: 100%;
width: 100%;
top: 0;
left: 0;
font-weight: 600;
font-size: calc(var(--size) / 2);
display: flex;
align-items: center;
justify-content: center;
content: "A";
}
[data-mode="light"] .switch {
box-shadow: 0 0 50px 10px yellow;
background-color: yellow;
border: 1px solid orangered;
}
[data-mode="dark"] .switch {
box-shadow: calc(var(--size) / 4) calc(var(--size) / -4) calc(var(--size) / 8)
inset #fff;
border: none;
background: transparent;
animation: n linear 0.5s;
}
@keyframes n {
40% {
transform: rotate(-15deg);
}
80% {
transform: rotate(10deg);
}
0%,
100% {
transform: rotate(0deg);
}
}

View file

@ -0,0 +1,113 @@
"use client";
import styles from "./switch.module.css";
import { memo, useEffect, useState } from "react";
declare global {
var updateDOM: () => void;
}
type ColorSchemePreference = "system" | "dark" | "light";
const STORAGE_KEY = "nextjs-blog-starter-theme";
const modes: ColorSchemePreference[] = ["system", "dark", "light"];
/** to reuse updateDOM function defined inside injected script */
/** function to be injected in script tag for avoiding FOUC (Flash of Unstyled Content) */
export const NoFOUCScript = (storageKey: string) => {
/* can not use outside constants or function as this script will be injected in a different context */
const [SYSTEM, DARK, LIGHT] = ["system", "dark", "light"];
/** Modify transition globally to avoid patched transitions */
const modifyTransition = () => {
const css = document.createElement("style");
css.textContent = "*,*:after,*:before{transition:none !important;}";
document.head.appendChild(css);
return () => {
/* Force restyle */
getComputedStyle(document.body);
/* Wait for next tick before removing */
setTimeout(() => document.head.removeChild(css), 1);
};
};
const media = matchMedia(`(prefers-color-scheme: ${DARK})`);
/** function to add remove dark class */
window.updateDOM = () => {
const restoreTransitions = modifyTransition();
const mode = localStorage.getItem(storageKey) ?? SYSTEM;
const systemMode = media.matches ? DARK : LIGHT;
const resolvedMode = mode === SYSTEM ? systemMode : mode;
const classList = document.documentElement.classList;
if (resolvedMode === DARK) classList.add(DARK);
else classList.remove(DARK);
document.documentElement.setAttribute("data-mode", mode);
restoreTransitions();
};
window.updateDOM();
media.addEventListener("change", window.updateDOM);
};
let updateDOM: () => void;
/**
* Switch button to quickly toggle user preference.
*/
const Switch = () => {
const [mode, setMode] = useState<ColorSchemePreference>(
() =>
((typeof localStorage !== "undefined" &&
localStorage.getItem(STORAGE_KEY)) ??
"system") as ColorSchemePreference,
);
useEffect(() => {
// store global functions to local variables to avoid any interference
updateDOM = window.updateDOM;
/** Sync the tabs */
addEventListener("storage", (e: StorageEvent): void => {
e.key === STORAGE_KEY && setMode(e.newValue as ColorSchemePreference);
});
}, []);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, mode);
updateDOM();
}, [mode]);
/** toggle mode */
const handleModeSwitch = () => {
const index = modes.indexOf(mode);
setMode(modes[(index + 1) % modes.length]);
};
return (
<button
suppressHydrationWarning
className={styles.switch}
onClick={handleModeSwitch}
/>
);
};
const Script = memo(() => (
<script
dangerouslySetInnerHTML={{
__html: `(${NoFOUCScript.toString()})('${STORAGE_KEY}')`,
}}
/>
));
/**
* This component wich applies classes and transitions.
*/
export const ThemeSwitcher = () => {
return (
<>
<Script />
<Switch />
</>
);
};

View file

@ -2,6 +2,8 @@ import Footer from "@/app/_components/footer";
import { CMS_NAME, HOME_OG_IMAGE_URL } from "@/lib/constants";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import cn from "classnames";
import { ThemeSwitcher } from "./_components/theme-switcher";
import "./globals.css";
@ -55,7 +57,10 @@ export default function RootLayout({
<meta name="theme-color" content="#000" />
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
</head>
<body className={inter.className}>
<body
className={cn(inter.className, "dark:bg-slate-900 dark:text-slate-400")}
>
<ThemeSwitcher />
<div className="min-h-screen">{children}</div>
<Footer />
</body>

View file

@ -1,6 +1,7 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",