sukawasatoru.com

Blog を Dark mode に対応した

Tailwind CSS を使用すると簡単に Dark mode に対応できるようなので Blog に実装した。

主に必要だったことは基本的な Tailwind CSS の設定、明示的な Light / Dark の指定と System 設定に従う 3つのパターンを指定することができる Component の実装、個々のテキスト色や背景色の設定、コードの Highlight に使用している Prism の Dark mode 対応の 4点だった。

主に Tailwind CSS のドキュメント Dark Mode - Tailwind CSS を参照し順番に適用するだけで対応できた。

基本的な仕組みとしてはダークモード時は <html> タグに CSS の .dark class が付与されているので CSS Selector を .dark .foo とすることで Dark mode を適用しているようだ。

この仕組みを使うことで HTML を書く時に例えば h1 の文字色を Light / Dark mode それぞれ指定する場合 <h1 className='text-neutral-600 dark:text-white'> とすると Light の時は text-neutral-600 が使用され Dark の時は text-white が適用される。

基本的な Tailwind CSS の設定

今回 System の設定に追従し Light / Dark を切り替えるだけでなくユーザーが明示的に Light / Dark を切り替えることができる仕組みを実装したく、その場合は Toggling dark mode manually に従い tailwind.config.js に次の設定を行う必要があった:

use 'strict';

/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: 'class',
  // snip.
};

明示的に Light / Dark を指定した場合 LocalStorage にその指定を記録し次回移行その値を使用するようにした。これに関する実装は Supporting system preference and manual selection にあるので参考にし _document.tsx に次のように実装した:

<Script
  id="add-prefs-color-scheme-class"
  strategy="beforeInteractive"
>{`
if (
  localStorage.theme === 'dark' ||
  ((!('appearance' in localStorage) || localStorage.appearance === 'system') &&
    window.matchMedia('(prefers-color-scheme: dark)').matches
  )
) {
  document.documentElement.classList.add('dark');
}
`}</Script>

この JavaScript は FOUC を避けるために <head> タグの中に記述する必要がある。 Next.js の場合は <Script strategy="beforeInteractive"> とする。

Dark mode 切り替えのための Component の実装

Tailwind UI の Syntax - Tailwind CSS Documentation Template に実装されているためこれを参考にした。

Light / Dark を適用する仕組みが特に参考になった。内容としては

といったものだった。

const useColorMediaQuery = (): MediaQueryList => useMemo(() =>
    typeof window !== 'undefined' &&
    window.matchMedia('(prefers-color-scheme: dark)') ||
    {matches: false} as any,
  [],
);

const updateClassList = (isDark: boolean) => {
  if (isDark) {
    document.documentElement.classList.add('dark');
  } else {
    document.documentElement.classList.remove('dark');
  }

  // need to use transitions to clear div element if use transition in AppearanceSelector.
  // document.documentElement.classList.add('[&_*]:!transition-none');
  // sleep().then(() => document.documentElement.classList.remove('[&_*]:!transition-none'));
};

/**
 * undefined may be pre-rendered result.
 *
 * the undefined is required for `system` because `system` uses client value to render
 * so need to cause an effect after pre-rendered result.
 */
const useAppearance = (): [Appearance | undefined, (value: Appearance) => void, boolean] => {
  const [appearance, setAppearance] = useState<Appearance>();
  const initial = useRef(true);
  const mediaQuery = useColorMediaQuery();
  const [isSystemDark, setIsSystemDark] = useState(mediaQuery.matches);

  useIsomorphicLayoutEffect(() => {
    if (initial.current) {
      // initialize state after pre-rendered.
      setAppearance(prefsRepo.getAppearance());

      initial.current = false;

      // use appearance that set via _document.tsx.
    } else {
      appearance && prefsRepo.saveAppearance(appearance);
      switch (appearance) {
        case 'dark':
          updateClassList(true);
          break;
        case 'light':
          updateClassList(false);
          break;
        case 'system':
          updateClassList(mediaQuery.matches);
      }
    }
  }, [appearance]);

  // observe browser's appearance and update classList.
  useEffect(() => {
    const cb = (ev: MediaQueryListEvent) => {
      setIsSystemDark(ev.matches);
      if (prefsRepo.getAppearance() === 'system') {
        updateClassList(ev.matches);
      }
    };

    mediaQuery.addEventListener('change', cb);
    return () => mediaQuery.removeEventListener('change', cb);
  }, [mediaQuery]);

  return [appearance, setAppearance, isSystemDark];
};

テキスト色や背景色の設定

この Blog はそれほど規模の大きいものではないのであまり考えずに色指定をおこなった。大まかな方針としては基本的には global.css でテキスト色や背景色を指定し、もし Component 内で Light の色指定を行なっている場合は Component で Dark の色指定を行うようにした。

global.css:

@tailwind base;
@tailwind components;

@layer base {
  .dark {
    @apply text-slate-300;
    @apply bg-neutral-900;
  }
}

@tailwind utilities;

Component:

const MyHeader: FC = () =>
  <header>
    <h1 className='text-neutral-600 dark:text-white'>
      <Link href="/">
        <a>
          sukawasatoru.com
        </a>
      </Link>
    </h1>
  </header>;

Prism の Dark mode 対応

次の実装で Markdown をレンダリングしすると各タグに <foo style="color:black"> といった style attribute を使用する:

<Prism
    language={match?.[1]}
    // style を使用すると Highlight に style attribute を使用する
    style={ghcolors}
    PreTag={(rest) => <pre {...rest} className='sm:rounded-md bg-slate-200'/>}
  >
    {c.props.children}
  </Prism>;

この Blog は getStaticProps で Markdown をレンダリングし、レンダリング済みのものをブラウザで表示している。動的に Light / Dark の切り替えに対応するためにはブラウザで Markdown をレンダリングするか CSS の class による Highlight をするようにしないといけない。今回は CSS の class を使用するようにした。

まず Prism の style を無効化するために <Prism> の style に空の object を渡す。ただ空の object を渡しても <pre>#fff の背景色を設定するようなので style で上書きしないようにした:

<Prism
    language={match?.[1]}
    style={{}}
    PreTag={(rest) => <pre {...rest} className={clsx(c.props.className, 'sm:rounded-md bg-slate-200')} style={{backgroundColor: ''}}/>}
  >
    {c.props.children}
  </Prism>;

次に CSS を用意する。style は PrismJS/prism-themes: A wider selection of Prism themes から適当に Light 用と Dark 用の CSS を用意し prism.css に両方の CSS を記述する。 Dark 用の CSS の各 Selector には .dark を設定する:

/* Light 用の Style. */

/**
 * GHColors theme by Avi Aryan (http://aviaryan.in)
 * Inspired by Github syntax coloring
 */

code[class*="language-"],
pre[class*="language-"] {
  /* snip. */
}

pre > code[class*="language-"] {
  font-size: 1em;
}
/* snip. */

/* ここからは Dark 用の Style. */

/**
 * Solarized dark atom theme for `prism.js`
 * Based on Atom's `atom-dark` theme: https://github.com/atom/atom-dark-syntax
 * @author Pranay Chauhan (@PranayChauhan2516)
 */

.dark code[class*="language-"],
.dark pre[class*="language-"] {
  /* snip. */
pp}

/* Code blocks */
.dark pre[class*="language-"] {
  /* snip. */
}

作成した CSS は _app.tsx で import するようにした。

これで Blog の大部分を Dark mode に対応できた。 Stork Search を使用してる箇所は別途対応が必要だがしばらくはこのままで様子を見てみたい。

Dark mode 適用の全体的な変更は 618ac2b...30ae423 になる。


timestamp
2022-08-15 (First edition)