カスタムWordPressテーマを0から作った話

「Cocoonテーマだと世界観が出しきれない」と感じたことはありませんか。私がまさにそれで、ある日思い切ってゼロからWordPressテーマを書くことにしました。このエントリーでは、Nakano Roomというカスタムテーマの設計から実装まで、実際のコードを交えながら振り返ります。

1. 既存テーマの限界と、独自世界観への挑戦

Cocoonは機能豊富な素晴らしいテーマです。しかし「手描き×ステッカー風の、ちょっとアート寄りなデザイン」を実現しようとすると、どうしても既存スキンの枠に収まってしまいます。カスタムCSSを積み重ねるほど保守が難しくなり、テーマアップデートで壊れるリスクも増える。

参考にしたのは fumino.b-rave.tokyo のような、手書き感とポップさを両立したデザインです。「このテイストを自分のサイトで表現したい」という一点が、カスタムテーマ開発への動機でした。

2. デザインコンセプト:手描き×ステッカー、紫系パレット、Design Tokens

まず色と「空気感」を決めました。キーワードは3つです。

  • 手描き感 ── 破線ボーダー、微妙な回転、ラフな影
  • ステッカー感 ── 要素を貼り付けたコラージュ的レイアウト
  • 紫系パレット ── ラベンダー・パステルパープルを軸に、シアン・ピンクを差し色に

このコンセプトを CSS の Design Tokens(カスタムプロパティ) として定義することで、変更が全体に即座に反映される仕組みにしました。

/* assets/css/app.css */
:root {
  /* Colors - Purple Palette */
  --brand: #74549c;
  --brand-light: #b388ff;
  --brand-soft: #c4a4e4;
  --brand-pale: #e8daf5;
  --lavender: #c4a4e4;
  --cyan: #74c4e4;
  --pink: #e8a0c8;
  --bg: #faf8ff;

  /* Spacing (8px scale) */
  --s1: 8px;  --s2: 16px;  --s3: 24px;
  --s4: 32px; --s5: 48px;  --s6: 64px; --s7: 96px;

  /* Transitions */
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --duration: 0.4s;
}

スペーシングを8pxスケールに統一することで、コンポーネント間の余白がすべて整合します。--ease はサイト全体のアニメーションに使い回しているカスタムイージング関数で、ゆっくり入って素早く終わる「バネっぽい」動きが特徴です。

3. テーマ構成の詳細

WordPressのカスタムテーマは最低でも style.cssindex.php があれば動きます。今回の nakano-theme は以下の構成にしました。

ファイル役割
style.cssテーマメタ情報(Theme Name, Version など)
functions.phpテーマサポート設定、CSS/JS読み込み、カスタム関数
header.phpHTML head、ローディング演出、ヘッダーナビ
footer.phpフッター、ウィジェットエリア
front-page.phpトップページ(ヒーロー + 記事グリッド)
single.php個別記事ページ
page.php固定ページ
archive.phpカテゴリ・タグ一覧
404.php404エラーページ
template-parts/hero.phpヒーローセクション(パーツ分離)
template-parts/content-card.phpカード型記事アイテム
assets/css/app.css全CSSスタイル(Design Tokens含む)
assets/js/app.jsインタラクション全般(ES6+)

functions.php では、Google Fontsの読み込みを wp_enqueue_style() でキューに登録しています。直接 <link> タグを書くより、WordPress のキャッシュ・最適化の恩恵を受けられます。

// functions.php
add_action('wp_enqueue_scripts', function () {
    // M PLUS Rounded 1c + Zen Maru Gothic
    wp_enqueue_style(
        'nakano-google-fonts',
        'https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@300;400;500;700;800;900&family=Zen+Maru+Gothic:wght@400;500;700&display=swap',
        [],
        null
    );

    // メインCSS
    wp_enqueue_style(
        'nakano-main',
        get_theme_file_uri('/assets/css/app.css'),
        ['nakano-google-fonts'],
        NAKANO_VERSION
    );
});

4. ヒーローセクションの実装

トップページの「顔」になるヒーローセクションは、template-parts/hero.php として独立したパーツに切り出しました。メインテンプレートから get_template_part('template-parts/hero') で呼び出すだけです。

HTML構造

<!-- template-parts/hero.php -->
<section class="hero" aria-labelledby="hero-title">
  <div class="hero-inner">
    <div class="hero-copy">
      <h1 id="hero-title" class="display">中野の部屋</h1>
      <p class="lead">サイトのキャッチフレーズ</p>
      <div class="hero-cta">
        <a class="btn btn-primary" href="/blog/">最新記事</a>
        <a class="btn btn-ghost"  href="/about/">プロフィール</a>
      </div>
    </div>

    <!-- 装飾レイヤー(aria-hidden=true でスクリーンリーダーから隠す) -->
    <div class="hero-collage" aria-hidden="true">
      <div class="hero-shape hero-shape-1"></div>
      <div class="hero-shape hero-shape-2"></div>
      <div class="hero-shape hero-shape-3"></div>
      <div class="label label-new">new!</div>
      <div class="scribble scribble-arrow"></div>
    </div>
  </div>

  <div class="scroll-hint" aria-hidden="true">
    <span class="scroll-text">Scroll</span>
    <span class="scroll-arrow">↓</span>
  </div>
</section>

アニメーション

背景の円形装飾(.hero-shape)は、CSS の @keyframes float でゆっくり上下に揺れるようにしました。各シェイプで animation-duration を変えることでランダム感を出しています。

.hero-shape-1 {
  width: 200px; height: 200px;
  background: linear-gradient(135deg, var(--lavender), var(--brand-pale));
  animation: float 6s ease-in-out infinite;
}
.hero-shape-2 {
  width: 140px; height: 140px;
  animation: float 8s ease-in-out infinite reverse; /* 逆回転 */
}
.hero-shape-3 {
  animation: float 7s ease-in-out infinite 1s; /* 1秒ディレイ */
}

@keyframes float {
  0%, 100% { transform: translateY(0) rotate(0deg); }
  50%       { transform: translateY(-16px) rotate(6deg); }
}

スクロールヒントの矢印も同様に @keyframes scroll-bounce で上下に動かしています。「このテキストはスクロールして読み進めてね」という視覚的な誘導です。

5. カード型レイアウトの実装

記事一覧はグリッドレイアウトのカード形式にしました。template-parts/content-card.php が1枚のカードに対応します。

<!-- content-card.php -->
<article <?php post_class('card reveal'); ?>>
  <a class="card-link" href="<?php the_permalink(); ?>">
    <?php if (has_post_thumbnail()): ?>
      <figure class="card-media">
        <?php the_post_thumbnail('card-thumbnail', ['loading' => 'lazy']); ?>
      </figure>
    <?php else: ?>
      <figure class="card-media card-media-placeholder">
        <div class="placeholder-icon" aria-hidden="true">✎</div>
      </figure>
    <?php endif; ?>

    <div class="card-body">
      <p class="meta">
        <time datetime="<?php echo esc_attr(get_the_date('c')); ?>">
          <?php echo esc_html(get_the_date('Y.m.d')); ?>
        </time>
        <?php /* カテゴリタグを表示 */ ?>
      </p>
      <h3 class="card-title"><?php the_title(); ?></h3>
      <p class="card-excerpt"><?php echo esc_html(wp_strip_all_tags(get_the_excerpt())); ?></p>
    </div>
  </a>
</article>

reveal クラスがついている要素は、JavaScript の IntersectionObserver でビューポートに入ったタイミングで is-inview クラスが付与され、フェードイン+スライドアップするようになっています。カードに reveal を付けておくだけで「画面に入ったら動く」演出が自動で乗ります。

カードのホバー演出はCSSだけで完結しています。

.card {
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-card);
  transition: all var(--duration) var(--ease);
}
.card:hover {
  transform: translateY(-6px) rotate(-0.5deg); /* 少し傾けてポップに */
  box-shadow: var(--shadow-hover);
}
.card:hover .card-media img {
  transform: scale(1.05); /* 画像ズームイン */
}

6. レスポンシブ対応(モバイルファースト)

スタイルの基本はモバイル(1カラム)で書き、min-width のメディアクエリで段階的に広げるアプローチです。

/* デフォルト: 1カラム(スマートフォン) */
.post-grid {
  display: grid;
  gap: var(--s3);
  grid-template-columns: 1fr;
}

/* タブレット以上 (720px+): 2カラム */
@media (min-width: 720px) {
  .post-grid { grid-template-columns: repeat(2, 1fr); }

  .hero-inner {
    grid-template-columns: 1.1fr 0.9fr; /* コピー:デコ = 1.1:0.9 */
    align-items: center;
  }
}

/* デスクトップ (960px+): 3カラム */
@media (min-width: 960px) {
  .post-grid { grid-template-columns: repeat(3, 1fr); }

  /* ナビゲーション: モバイルでは折りたたむ、960px以上で展開 */
  .nav-list { display: flex !important; position: static; }
  .nav-toggle { display: none; }
}

ヒーローセクションはスマートフォンでは縦1カラム、タブレット以上で左にテキスト・右にデコレーション要素という2カラム配置になります。

7. パフォーマンス最適化

ローディング演出とフォントの読み込みブロック対策

ページ表示直後に白いローディングスクリーンを表示し、window load イベント後に非表示にします。コンテンツが準備できてからすべてが見えるため、レイアウトシフト(CLS)を抑制できます。

// app.js
window.addEventListener('load', () => {
  document.body.classList.remove('is-loading');
});

// フォールバック: 3秒でローディング強制終了
setTimeout(() => {
  document.body.classList.remove('is-loading');
}, 3000);

ローディングスクリーン自体のCSSは、body.is-loading がある間だけ表示し、クラスが取れると opacity: 0 + visibility: hidden でフェードアウトします。

.loading-screen {
  position: fixed;
  inset: 0;
  z-index: 9999;
  background: var(--bg);
  display: flex;
  align-items: center;
  justify-content: center;
  transition: opacity 0.6s var(--ease), visibility 0.6s;
}

/* is-loading が外れたら消える */
body:not(.is-loading) .loading-screen {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

フォント最適化

Google Fontsのクエリに &display=swap を付けることで、フォントが読み込まれる前はシステムフォントで表示し(FOUT)、後からカスタムフォントに切り替わります。フォント読み込み待ちでテキストが非表示になる(FOIT)よりUXが良いです。

スクロールリビール(IntersectionObserver)

スクロールリビールには scroll イベントではなく IntersectionObserver を使っています。scroll イベントはスクロールのたびに発火してパフォーマンスに影響しますが、IntersectionObserver はブラウザが効率よく処理します。

const io = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        entry.target.classList.add('is-inview');
        io.unobserve(entry.target); // 一度表示されたら監視終了
      }
    }
  },
  { root: null, threshold: 0.1 }
);

8. つまずいたポイントと解決策

モバイルナビのアクセシビリティ

最初はCSSだけでハンバーガーメニューを実装しようとしましたが、スクリーンリーダーで「ボタンの状態(開いている/閉じている)」が伝わらない問題がありました。

解決策: aria-expanded 属性をJavaScriptでトグル。また Escape キーで閉じる処理と、ナビ外クリックで閉じる処理も追加しました。

toggle.addEventListener('click', () => {
  const isOpen = nav.classList.toggle('is-open');
  toggle.setAttribute('aria-expanded', String(isOpen));
});

// Escキーで閉じる
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && nav.classList.contains('is-open')) {
    nav.classList.remove('is-open');
    toggle.setAttribute('aria-expanded', 'false');
    toggle.focus(); // フォーカスをトグルボタンに戻す
  }
});

カスタムカーソルの残影問題

カスタムカーソル(ラベンダー色の円)をRAF(requestAnimationFrame)で追従させているのですが、最初は線形補間の係数を大きくしすぎてカーソルが瞬時に移動してしまい、演出の意味がなくなっていました。

解決策: currentX += (cursorX - currentX) * 0.15 という線形補間に落ち着きました。係数0.15が「ちょうどいい遅延感」でした。また hover: hover メディアクエリでマウス非搭載デバイス(タッチパネルのみ)では無効化しています。

モーションセーフへの対応

アニメーションを多用しているので、prefers-reduced-motion: reduce の対応が必要でした。

@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  .reveal { opacity: 1; transform: none; }
  .loading-screen { display: none; }
  .cursor { display: none; }
}

JavaScriptでも window.matchMedia('(prefers-reduced-motion: reduce)').matches を確認し、IntersectionObserverのリビール演出とカスタムカーソルを無効化しています。

9. まとめ:カスタムテーマ開発で学んだこと

今回のテーマ開発を通じて、いくつかの重要な気づきがありました。

  • Design Tokens が設計を楽にする ── 色・スペーシング・アニメーションをCSSカスタムプロパティとして定義することで、「このサイトの世界観」を1ファイルで管理できます。変更1行が全体に伝播するのは気持ちいい。
  • モバイルファーストで書くと整理される ── 最小構成(1カラム)をベースにして広げていくと、不要なスタイルを書かずに済む。逆方向(デスクトップ→モバイル)で書くと上書きだらけになりがちです。
  • アクセシビリティは後付けが難しい ── aria-* 属性やキーボード操作は最初から設計に組み込む必要があります。後から追加しようとすると構造ごと変える必要が出てきます。
  • template-parts による分割が保守を楽に ── ヒーローやカードを別ファイルに切り出すと、該当箇所だけ編集できてコンフリクトも起きにくい。PHPのincludeをWordPressの流儀に沿ってやるだけですが、効果は大きいです。
  • Progressive Enhancement が安心感をくれる ── ローディング演出もスクロールリビールもJavaScript無効時には「ただ普通に表示される」ように設計しました。演出はあくまで上積みで、コンテンツそのものを壊さないことが大事です。

「Cocoonで限界を感じたらゼロから作ってみる」という選択は、WordPressの内部構造やフロントエンドの基礎を深く理解するきっかけになりました。コード量はそれほど多くありませんが、自分の世界観を持ったサイトが動いているのは、既製テーマを使うのとはまた違う達成感があります。

テーマのソースコードは GitHub で公開予定です。興味のある方はぜひ参考にしてみてください。