🌟

DocusaurusのUIをカスタマイズする

に公開

はじめに

Docusaurus は、React ベースの静的サイトジェネレーターです。
仕事で触る機会があったものの情報があまり多くなかったので、発信してみようと思います。
インストールは完了した想定で進めるので、インストール方法は公式ドキュメント等を参照してください。

今回やること

Docusaurus が自動生成する記事のインデックスページにあるカードをカスタマイズの対象とします。

カスタマイズ前

カスタマイズ後

UI のカスタマイズ方法

カスタマイズ方法には大きく分けて以下の 2 種類があります。

  • CSS
  • Swizzle

CSS は Docusaurus のテーマに含まれる CSS を上書きする方法です。
Swizzle は Docusaurus のテーマに含まれる React コンポーネントをカスタマイズする方法で、 Wrapping と Ejecting の 2 つの手法があり、ユースケースに応じて適切な手法を選びます。

ユースケースごとに分けると以下のような認識で概ね問題ないと思います。

カスタマイズ方法 ユースケース メリット デメリット
CSS 色や余白、フォントなど簡単な装飾や見た目を調整 手軽・安全/Docusaurus のアップデートに強い 構造や JS ロジックの変更は不可
Swizzle(Wrapping) 要素を追加、外側からの装飾 アップデートの影響を受けにくい 内部構造や props の直接変更は不可
Swizzle(Ejecting) 内部構造の変更、要素の削除、ロジックの変更、props の追加・削除 完全なカスタマイズが可能 アップデートの影響を受けやすい

まずは CSS でカスタマイズしてみる

対象の要素を調べる

DevTools で確認すると card padding--lg cardContainer_****というクラスが適用されています。

スタイルを定義する

以下のようにスタイルを定義してみます。
cardContainer_********の部分はビルドで変動するので部分一致セレクターを使用します。

src/css/custom.css
[class*='cardContainer_'] {
  background-color: #f0f0f0;
  padding: 6px !important;
}

背景色は変わりましたが、padding は適用されませんでした。

Swizzle を使用してみる

CSS では padding を適用できなかったので、Swizzle を使用してみます。

対象のコンポーネントを特定する

コンポーネントの特定はGitHub を参照したり、以下のコマンドでも確認できます

$ npm run swizzle @docusaurus/theme-classic -- --list

が、名前だけみてもどれか分かりづらいので個人的には以下のようにクラス名で grep する方法が良いと思います。
cardContainernode_modulesを grep してみます。

$ grep -r "cardContainer" ./node_modules
./node_modules/@docusaurus/theme-classic/src/theme/DocCard/index.tsx:      className={clsx('card padding--lg', styles.cardContainer, className)}>
./node_modules/@docusaurus/theme-classic/src/theme/DocCard/styles.module.css:.cardContainer {
./node_modules/@docusaurus/theme-classic/src/theme/DocCard/styles.module.css:.cardContainer:hover {
./node_modules/@docusaurus/theme-classic/src/theme/DocCard/styles.module.css:.cardContainer *:last-child {
./node_modules/@docusaurus/theme-classic/lib/theme/DocCard/index.js:      className={clsx('card padding--lg', styles.cardContainer, className)}>
./node_modules/@docusaurus/theme-classic/lib/theme/DocCard/styles.module.css:.cardContainer {
./node_modules/@docusaurus/theme-classic/lib/theme/DocCard/styles.module.css:.cardContainer:hover {
./node_modules/@docusaurus/theme-classic/lib/theme/DocCard/styles.module.css:.cardContainer *:last-child {
grep: ./node_modules/.cache/webpack/client-production-en/0.pack: binary file matches
grep: ./node_modules/.cache/webpack/server-production-en/0.pack: binary file matches
grep: ./node_modules/.cache/webpack/client-development-en/4.pack: binary file matches
grep: ./node_modules/.cache/webpack/client-development-en/7.pack: binary file matches
grep: ./node_modules/.cache/webpack/client-development-en/6.pack: binary file matches

Swizzle を実行する

DocCardというコンポーネントがヒットしたので、Swizzle を実行します。
今回は内部構造も変更したいので、ejectオプションをつけます。

$ npm run swizzle @docusaurus/theme-classic DocCard -- --eject

src/theme/DocCardindex.jsstyles.module.cssが生成されます。
index.jsにはDocCardコンポーネントと、その子コンポーネントが定義されています。
子コンポーネントのCardContainerpadding--lgクラスを削除してみます。

src/theme/DocCard/index.js
function CardContainer({ className, href, children }) {
  return (
    <Link
      href={href}
-     className={clsx('card padding--lg', styles.cardContainer, className)}>
+     className={clsx('card', styles.cardContainer, className)}>
      {children}
    </Link>
  );
}

custom.cssで定義した padding が適用されました。

custom.cssからsrc/theme/DocCard/styles.module.cssにスタイルの定義を移しておきます。

src/theme/DocCard/styles.module.css
.cardContainer {
  --ifm-link-color: var(--ifm-color-emphasis-800);
  --ifm-link-hover-color: var(--ifm-color-emphasis-700);
  --ifm-link-hover-decoration: none;

  box-shadow: 0 1.5px 3px 0 rgb(0 0 0 / 15%);
  border: 1px solid var(--ifm-color-emphasis-200);
  transition: all var(--ifm-transition-fast) ease;
  transition-property: border, box-shadow;
+ background-color: #f0f0f0;
+ padding: 6px;
}

自由にカスタマイズする

あとは React と同じように自由に編集が可能です。
※Docusaurus のアップデートでコンポーネントの内部構造が変更された場合は、修正が必要になる可能性があります

最終的に以下のように編集してみました。

src/theme/DocCard/index.js
import React from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import {
  useDocById,
  findFirstSidebarItemLink,
} from '@docusaurus/plugin-content-docs/client';
import { usePluralForm } from '@docusaurus/theme-common';
import isInternalUrl from '@docusaurus/isInternalUrl';
import { translate } from '@docusaurus/Translate';
import Heading from '@theme/Heading';
import styles from './styles.module.css';

function useCategoryItemsPlural() {
  const { selectMessage } = usePluralForm();
  return (count) =>
    selectMessage(
      count,
      translate(
        {
          message: '1 item|{count} items',
          id: 'theme.docs.DocCard.categoryDescription.plurals',
          description:
            'The default description for a category card in the generated index about how many items this category includes',
        },
        { count },
      ),
    );
}

function CardContainer({ className, href, children }) {
  return (
    <Link
      href={href}
      className={clsx('card', styles.cardContainer, className)}>
      <div className={styles.cardInner}>
        {children}
      </div>
    </Link>
  );
}

function CardLayout({ className, href, icon, title, description, cardType }) {
  return (
    <CardContainer href={href} className={clsx(className, styles[`card${cardType}`])}>
      <div className={styles.cardHeader}>
        <div className={clsx(styles.cardIcon, styles[`icon${cardType}`])}>
          {icon}
        </div>
        <Heading
          as="h3"
          className={clsx('text--truncate', styles.cardTitle)}
          title={title}>
          {title}
        </Heading>
      </div>
      {description && (
        <p
          className={clsx('text--truncate', styles.cardDescription)}
          title={description}>
          {description}
        </p>
      )}
      <div className={styles.cardFooter}>
        <span className={styles.readMore}>
          {cardType === 'Category' ? '📁 カテゴリを見る' : '📖 記事を読む'}
        </span>
      </div>
    </CardContainer>
  );
}

function CardCategory({ item }) {
  const href = findFirstSidebarItemLink(item);
  const categoryItemsPlural = useCategoryItemsPlural();
  // Unexpected: categories that don't have a link have been filtered upfront
  if (!href) {
    return null;
  }
  return (
    <CardLayout
      className={item.className}
      href={href}
      icon="🗂️"
      title={item.label}
      description={item.description ?? categoryItemsPlural(item.items.length)}
      cardType="Category"
    />
  );
}

function CardLink({ item }) {
  const icon = isInternalUrl(item.href) ? '📄️' : '🔗';
  const doc = useDocById(item.docId ?? undefined);
  return (
    <CardLayout
      className={item.className}
      href={item.href}
      icon={icon}
      title={item.label}
      description={item.description ?? doc?.description}
      cardType="Link"
    />
  );
}

export default function DocCard({ item }) {
  switch (item.type) {
    case 'link':
      return <CardLink item={item} />;
    case 'category':
      return <CardCategory item={item} />;
    default:
      throw new Error(`unknown item type ${JSON.stringify(item)}`);
  }
}
src/theme/DocCard/styles.module.css
/* カードコンテナ - メインのカードスタイル */
.cardContainer {
  --ifm-link-color: var(--ifm-color-emphasis-800);
  --ifm-link-hover-color: var(--ifm-color-emphasis-700);
  --ifm-link-hover-decoration: none;

  position: relative;
  background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
  border: 1px solid rgba(148, 163, 184, 0.2);
  border-radius: 16px;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  overflow: hidden;
  transform: translateY(0);
}

.cardContainer:hover {
  transform: translateY(-4px);
  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
  border-color: var(--ifm-color-primary-light);
}

.cardContainer:hover .cardInner {
  background: linear-gradient(135deg, #ffffff 0%, #f1f5f9 100%);
}

/* カード内部構造 */
.cardInner {
  padding: 24px;
  height: 100%;
  display: flex;
  flex-direction: column;
  transition: all 0.3s ease;
}

/* カードヘッダー */
.cardHeader {
  display: flex;
  align-items: flex-start;
  gap: 16px;
  margin-bottom: 16px;
}

/* アイコンスタイル */
.cardIcon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  border-radius: 12px;
  font-size: 20px;
  transition: all 0.3s ease;
  flex-shrink: 0;
}

.iconCategory {
  background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
  color: white;
  box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
}

.iconLink {
  background: linear-gradient(135deg, #10b981 0%, #059669 100%);
  color: white;
  box-shadow: 0 4px 8px rgba(16, 185, 129, 0.3);
}

.cardContainer:hover .cardIcon {
  transform: scale(1.1);
}

/* タイトルスタイル */
.cardTitle {
  font-size: 1.25rem;
  font-weight: 600;
  line-height: 1.4;
  margin: 0;
  color: var(--ifm-color-emphasis-900);
  flex: 1;
}

/* 説明文スタイル */
.cardDescription {
  font-size: 0.875rem;
  line-height: 1.6;
  color: var(--ifm-color-emphasis-700);
  margin: 0 0 auto 0;
  opacity: 0.8;
}

/* カードフッター */
.cardFooter {
  margin-top: 20px;
  padding-top: 16px;
  border-top: 1px solid rgba(148, 163, 184, 0.1);
}

.readMore {
  display: inline-flex;
  align-items: center;
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--ifm-color-primary);
  opacity: 0.7;
  transition: all 0.3s ease;
}

.cardContainer:hover .readMore {
  opacity: 1;
  transform: translateX(4px);
}

/* カテゴリ専用スタイル */
.cardCategory {
  background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
  border-color: rgba(59, 130, 246, 0.2);
}

.cardCategory:hover {
  background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
  border-color: var(--ifm-color-primary);
}

/* リンク専用スタイル */
.cardLink {
  background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
  border-color: rgba(16, 185, 129, 0.2);
}

.cardLink:hover {
  background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
  border-color: #10b981;
}

/* ダークモード対応 */
[data-theme='dark'] .cardContainer {
  background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
  border-color: rgba(148, 163, 184, 0.2);
}

[data-theme='dark'] .cardContainer:hover .cardInner {
  background: linear-gradient(135deg, #334155 0%, #1e293b 100%);
}

[data-theme='dark'] .cardTitle {
  color: var(--ifm-color-emphasis-100);
}

[data-theme='dark'] .cardDescription {
  color: var(--ifm-color-emphasis-300);
}

[data-theme='dark'] .cardCategory {
  background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 100%);
}

[data-theme='dark'] .cardLink {
  background: linear-gradient(135deg, #064e3b 0%, #065f46 100%);
}

/* レスポンシブデザイン */
@media (max-width: 768px) {
  .cardInner {
    padding: 20px;
  }

  .cardHeader {
    gap: 12px;
    margin-bottom: 12px;
  }

  .cardIcon {
    width: 40px;
    height: 40px;
    font-size: 18px;
  }

  .cardTitle {
    font-size: 1.125rem;
  }
}

/* アクセシビリティ */
@media (prefers-reduced-motion: reduce) {
  .cardContainer,
  .cardIcon,
  .readMore {
    transition: none;
  }

  .cardContainer:hover {
    transform: none;
  }

  .cardContainer:hover .cardIcon {
    transform: none;
  }

  .cardContainer:hover .readMore {
    transform: none;
  }
}

まとめ

Docusaurus の UI をカスタマイズするにあたり、まず CSS のみで padding の変更を試みましたが、padding--lg クラスが強い優先度で適用されていたため !important 付きでも上書きできませんでした。
そのため Swizzle でコンポーネントを編集する手段を取りましたが、この CSS では上書きしづらい課題は、 こちら でも議論されていることから、将来的には今回のように Swizzle に頼らず解決できる可能性があります。

現時点 (v3.x) では、次のような優先度で進めるとメンテナンスコストを抑えられると思いました。

  1. まず CSS 上書きだけで対応できるか確認する
  2. 無理なら Swizzle (Wrapping) で外側から拡張する
  3. それでも足りなければ Swizzle (Eject) で内部構造を編集する

4.x のリリース後、また機会があれば記事を書きたいと思います。

Discussion