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:
parent
f5d616b77e
commit
4e9b405c4c
8 changed files with 190 additions and 15 deletions
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
56
examples/blog-starter/src/app/_components/switch.module.css
Normal file
56
examples/blog-starter/src/app/_components/switch.module.css
Normal 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);
|
||||
}
|
||||
}
|
113
examples/blog-starter/src/app/_components/theme-switcher.tsx
Normal file
113
examples/blog-starter/src/app/_components/theme-switcher.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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}",
|
||||
|
|
Loading…
Reference in a new issue