コンテンツへスキップ

外観カスタマイズ

KUKAN をフォークして自組織向けにカスタマイズする際のガイドです。 変更は段階的に行えます。軽い変更から順に試してください。

フォーク側で変更するファイルは apps/web/brands/default/ 配下に限定されます。中身は次の通りです。

パス(brands/default/ 配下) 内容 対応セクション
brand-config.ts サイト名・説明・ロゴ・フッターリンク・OG 画像・ファビコン・GA 測定 ID Tier 2
theme.css CSS 変数(色・サイズ)の上書き Tier 1
messages/ja.json / en.json UI テキスト(i18n メッセージ)の上書き i18n メッセージの上書き
overrides/ Header / Footer / TopPage などのコンポーネント差し替え(スロット) Tier 3
pages/ 独自の静的ページ(利用規約・プライバシーポリシー等) 静的ページの追加
public/ ロゴ・ファビコン・OG 画像などの静的ファイル 静的ファイルの配置
index.ts / messages/index.ts エクスポートのまとめ(バレル)。通常は編集不要 —

これ以外のファイルは変更しないでください。本体の更新を取り込む際にコンフリクトが発生します。

brands/default/theme.css に CSS 変数を記述するだけで、サイト全体の配色やサイズを変更できます。

apps/web/brands/default/theme.css
:root {
--primary: 142 64% 32%; /* メインカラー(HSL) */
--primary-foreground: 0 0% 100%; /* メインカラー上のテキスト色 */
--kukan-header-height: 72px; /* ヘッダー高さ */
--kukan-container-max-width: 1400px;
}

shadcn/ui 標準の変数と KUKAN 固有の変数(--kukan-* プレフィックス)が利用できます。全一覧は apps/web/src/app/globals.css を参照してください。

上書きしてよいかの判断がしやすいよう、各変数に上書き区分を付けています。

  • 推奨 — ブランドに合わせて変更する想定の変数
  • 注意 — 上書き可能ですが複数のコンポーネント・状態で共有されています。「用途と主な使用箇所」を確認し、デフォルトの明暗関係(淡色・濃色)を維持してください
  • 非推奨 — UI の意味(エラー・成功・警告)を伝える変数。ブランド都合での変更は推奨しません
変数 区分 用途と主な使用箇所
--primary / --primary-foreground 推奨 メインカラー。ボタン(default)、リンク、進捗バー、テキスト選択、チェックボックス等の選択状態
--highlight 推奨 検索キーワードのハイライト(影響は検索結果表示のみ)
--radius 推奨 角丸の基準値(全コンポーネント)
--kukan-* 推奨 ヘッダー高さ・ロゴ高さ・コンテナ幅等のレイアウト値
--background / --foreground 注意 ページ背景・基本テキスト。サイト全体に波及
--card / --popover(各 -foreground 付き) 注意 カード、およびドロップダウン・セレクト等のポップアップの背景とテキスト
--secondary / --secondary-foreground 注意 secondary ボタン・バッジの背景。淡色前提
--muted / --muted-foreground 注意 補足テキスト(サイト内で最も使用箇所が多い変数)、プレースホルダー、スケルトン、淡色背景。淡色前提
--accent / --accent-foreground 注意 ホバー・選択状態の色。outline / ghost ボタンのホバー、セレクト・ドロップダウンのフォーカス項目、ダイアログの閉じるボタン等。淡色前提
--border / --input 注意 全コンポーネントの境界線・入力枠
--ring 注意 フォーカスリング。キーボード操作の視認性に直結するため、背景とのコントラストを確保してください
--destructive / --destructive-foreground 非推奨 エラー・削除の意味色(destructive ボタン、フォームエラー、削除メニュー項目)。ベタ塗り背景と、その上のテキスト
--success / --warning 非推奨 成功・警告の意味色(アラート、パイプラインステータス、フォーム警告)。ボーダー・淡色背景・アイコンに使用
--success-tint-foreground / --warning-tint-foreground 非推奨 成功・警告の淡色(/10〜/15)背景上のテキスト色。ベタ塗り用ではありません
--ai / --ai-tint-foreground 非推奨 AI が生成したもの(抄録バッジ・検索カード等)を示す意味色。-tint-foreground は淡色背景上のテキスト

配色を安全に差し替えるため、以下を守ってください。

  1. 生トークンのみを上書きする — --primary 等をそのまま再宣言します。globals.css の --color-*(Tailwind 連携用マッピング)は本体管理のため変更しないでください。
  2. HSL の裸トリプレット形式を維持する — --primary: 142 64% 32%; のようにカンマなし・hsl() なしで記述します。hex 値や hsl(...) で包んだ値は動作しません。
  3. 不透明度を焼き込まない — 透過は使用側のユーティリティ(bg-success/10 等)で掛かります。トークンには不透明度 100% のベース色だけを入れてください。
  4. ペアはセットで更新し、コントラストを確認する — --primary を変えたら --primary-foreground も更新し、コントラスト比 4.5:1 以上(WCAG AA)を確保します。--success / --warning を変えた場合、対応する -tint-foreground は同系統の濃色にし、/15 の淡色背景上で 4.5:1 以上を確認します(-foreground と異なりベタ塗り背景用ではない点に注意)。
  5. dark: クラスを持ち込まない — KUKAN のテーマトークンはライト専用です。override コンポーネントに dark: を書くと、OS がダーク設定のユーザーだけ表示が崩れます。
  6. override コンポーネントでも生パレット色を書かない — text-green-600 ではなく text-success のようにセマンティッククラスを使うと、後からの配色変更が theme.css 1 ファイルで済みます。

独自トークンの追加(--brand-*)

Section titled “独自トークンの追加(--brand-*)”

デザイナー指定の配色に、公式トークンの役割(主要色・強調色など)に当てはまらない独自色が含まれる場合は、--brand- プレフィックスの独自トークンを brands/default/theme.css に追加できます。

apps/web/brands/default/theme.css
:root {
--brand-accent-green: 183 29% 87%;
--brand-link: 183 79% 28%;
}
  • --brand-* はフォーク専用の名前空間です — 本体は --brand-* を定義も参照もしないため、本体更新と衝突しません。逆に --kukan-* は本体専用のため、独自トークンには使わないでください
  • 形式は公式トークンと同じ HSL 裸トリプレットを推奨します(使用側で /10 等の透過を掛けられます)
  • 使用できるのはフォーク所有のコンポーネント内のみです(brands/default/overrides/ / brands/default/pages/)。本体のコンポーネントが独自トークンを参照することはありません
  • Tailwind のユーティリティクラスは生成されません — theme.css は globals.css と別の CSS ユニットのため @theme によるクラス生成が効きません。arbitrary value 記法で参照してください:
<a className="text-[hsl(var(--brand-link))] bg-[hsl(var(--brand-accent-green))]">...</a>

本体画面の色を部分的に変えたい場合

Section titled “本体画面の色を部分的に変えたい場合”

オーバーライドしていない本体画面で「このコンポーネントだけ色を変えたい」場合、上の表のとおり公式トークンの上書きでは影響範囲が広すぎることがあります。このケースでは、本体側がコンポーネントスコープのエイリアストークン(例: --kukan-header-bg。デフォルトは var(--primary) を参照し、上書きしない限り挙動は変わりません)を追加する運用としています。必要になったら本体リポジトリへ issue で追加を提案してください。

Tier 2: テキスト・メタデータの変更

Section titled “Tier 2: テキスト・メタデータの変更”

brands/default/brand-config.ts を編集して、サイト名・著作権表示・メタデータを変更します。

apps/web/brands/default/brand-config.ts
import type { BrandConfig } from '@/types/brand'
export const brandConfig: BrandConfig = {
siteName: '○○市オープンデータカタログ',
siteDescription: '○○市が公開するオープンデータを検索・ダウンロードできます',
copyright: '○○市.',
copyrightUrl: 'https://www.city.example.lg.jp',
logo: { type: 'image', src: '/brand/logo.svg', width: 160, height: 40, alt: '○○市' },
headerNavExtra: [
{ label: '市公式サイト', href: 'https://www.city.example.lg.jp', external: true },
],
footerLinks: [
{ label: { ja: '利用規約', en: 'Terms of Use' }, href: '/terms' },
{ label: 'お問い合わせ', href: '/contact' },
],
ogImage: '/brand/og-image.png',
faviconPath: '/brand/favicon.ico',
}
フィールド 説明
siteName サイトタイトル(ブラウザタブ、ヘッダー等)
siteDescription meta description(検索エンジン向け)
copyright フッターの著作権表示テキスト(© 2026 は自動付与)
copyrightUrl フッターのサイト名リンク先(省略時はリンクなし)
logo ヘッダーロゴの設定(後述)
headerNavExtra ヘッダーナビゲーションの追加項目
footerLinks フッターのリンク一覧
ogImage OGP 画像のパス(public/ 配下のパス)
faviconPath ファビコンのパス
noindex true で全ページに noindex, nofollow の robots meta タグを付与(省略時はなし)
gaMeasurementId GA4 測定 ID(G-XXXXXXXXXX)。null / 省略で GA4 無効(アクセス統計)
csp ページの Content-Security-Policy に足すソース(ディレクティブ → ソースの配列。例: { 'img-src': ['https://cdn.example.jp'] })。自分のホストからスクリプトや CSS を読む、別のドメインの API を呼ぶブランドが使う。足すだけで、外せない(ADR-071)。GA4 のホストは自動で入る

テキスト系フィールド(siteName・siteDescription・copyright・logo.alt・headerNavExtra[].label・footerLinks[].label)は、文字列のほかにロケールマップ(例: { ja: '利用規約', en: 'Terms of Use' })を指定できます。文字列は全ロケール共通、マップは言語切替に追従します。指定のないロケールは英語(デフォルトロケール)→定義済みの値の順で補われます。

noindex は、検索エンジンにインデックスさせたくない検証環境や限定公開サイト向けのスイッチです。robots.txt でサイト全体のクロールを断ることはしません(断るとクローラーが meta タグ自体を読めなくなるためです)。アクセス自体を制限したい場合は、Basic 認証や IP 制限などのアクセス制御を併用してください。

検索エンジン向けの出力は、noindex に応じて次のように切り替わります。Search Console への登録は必要ありません(主要な検索エンジンは robots.txt の Sitemap: 行から sitemap を見つけます)。

出力 noindex: false noindex: true
/sitemap.xml(データセット・リソース・組織・カテゴリー・タグ) 公開のものだけを、種類ごとに 1 万件ずつ /sitemap/<種類>/<番号>.xml に分けて出す 404
robots.txt の Sitemap: 行 出す 出さない
データセット詳細の構造化データ(schema.org Dataset、JSON-LD) 公開のデータセットに出す(Google Dataset Search が読む) 出さない
絞り込み・検索語の付いた一覧ページ noindex, follow noindex, nofollow(全ページ)

robots.txt は noindex に関係なく、一覧のクエリ(/dataset?・/organization?・/group?)、/api/、/odata/、/dashboard、/auth/ へのクロールを断ります。絞り込みの組み合わせは際限なく URL を生むためで、データセットへは sitemap と組織・カテゴリー・タグのページからたどれます。sitemap と robots.txt の URL は BETTER_AUTH_URL のオリジンで書かれます。

検索例チップ(検索ボックス下のクエリ例)はブランド設定ではなく、管理画面のサイト管理(/dashboard/admin/site)から設定します。人口や基準日のように更新のたびにビルドしたくない値も、同じ画面の「サイトのメタデータ」に置けます(サイトのメタデータの参照)。

logo フィールドは 2 つのモードから選択できます。

// デフォルト(KUKAN ロゴ)
logo: { type: 'default' }
// 画像ファイル(推奨: 高さ 32〜40px、SVG 形式)
logo: { type: 'image', src: '/brand/logo.svg', width: 160, height: 40, alt: '○○市' }

UI に表示される翻訳テキストを組織ごとに変更できます。brands/default/messages/{locale}.json に上書きしたいキーだけを記述してください。

apps/web/brands/default/messages/ja.json
{
"home": {
"title": "○○市オープンデータカタログ",
"description": "○○市のオープンデータを検索・活用できるポータル"
}
}
apps/web/brands/default/messages/en.json
{
"home": {
"title": "City of Example Open Data Catalog",
"description": "A portal to search and utilize open data from the City of Example"
}
}
  • 記述したキーのみ上書きされ、それ以外はデフォルト(apps/web/messages/{locale}.json)が使われます
  • ネストされたオブジェクトは再帰的にマージされるため、兄弟キーを再記述する必要はありません
  • 利用可能なキーの一覧は apps/web/messages/ja.json を参照してください
用途 設定場所
ブランド固有の値(サイト名・ナビ項目・著作権表示等) brand-config.ts(ロケールマップで多言語化可能)
本体 UI の翻訳テキストの上書き brands/default/messages/{locale}.json

Header や Footer の構造自体を変更したい場合は、カスタムコンポーネントを作成して差し替えます。

1. カスタムコンポーネントを作成

apps/web/brands/default/overrides/header.tsx
import { getCurrentUser } from '@/lib/server-api'
import { LanguageSwitcher } from '@/components/layout/language-switcher'
import { MobileNav } from '@/components/layout/mobile-nav'
import { UserMenu } from '@/components/auth/user-menu'
export async function Header() {
const user = await getCurrentUser()
return (
<header className="sticky top-0 z-40 bg-[hsl(var(--primary))]">
<div className="mx-auto flex h-[var(--kukan-header-height)] max-w-[var(--kukan-container-max-width)] items-center justify-between px-4">
<img src="/brand/logo.svg" alt="○○市" className="h-8" />
<div className="flex items-center gap-2">
<LanguageSwitcher />
{user && <UserMenu user={user} />}
<MobileNav user={user} />
</div>
</div>
</header>
)
}

2. overrides/index.ts に登録

apps/web/brands/default/overrides/index.ts
import type { BrandOverrides } from '@/types/brand'
import { Header } from './header'
export const overrides: BrandOverrides = {
Header,
}

これだけで Header が差し替わります。

スロット名 差し替え対象
Header サイトヘッダー全体
Footer サイトフッター全体
TopPage トップページ全体

カスタムコンポーネント内では、本体のデフォルト実装をインポートして部分的に再利用できます。 循環参照を避けるため、Header ではなく DefaultHeader / DefaultFooter を使用してください。

import { DefaultHeader } from '@/components/layout/header'
import { DefaultFooter } from '@/components/layout/footer'

カスタムコンポーネントで brandConfig のテキスト系フィールド(siteName 等)を表示する場合は、resolveBrandConfig() で解決してから使います。テキスト系フィールドは LocalizedText 型(文字列またはロケールマップ)のため、{brandConfig.siteName} のような直接描画は型エラーになります。

async サーバーコンポーネントでは getLocale() を使います。

import { getLocale } from 'next-intl/server'
import { resolveBrandConfig } from '@/lib/resolved-brand'
export async function CustomHeader() {
const brand = resolveBrandConfig(await getLocale())
// brand.siteName は現在のロケールで解決済みの string
return <span>{brand.siteName}</span>
}

クライアントコンポーネントでは useLocale() を使います(next-intl/server は import しないでください — server-only のためビルドに失敗します)。useLocale() は非 async のサーバーコンポーネントでも使えます。

'use client'
import { useLocale } from 'next-intl'
import { resolveBrandConfig } from '@/lib/resolved-brand'
export function SiteName() {
const brand = resolveBrandConfig(useLocale())
return <span>{brand.siteName}</span>
}

人口・世帯数・基準日のように月に一度ほど変わる値は、ブランド設定ではなく管理画面のサイト管理(/dashboard/admin/site)の「サイトのメタデータ」に入れます。値を直すたびにビルドし直す必要はありません。各値は key・種類(数値・文字列・言語別の文字列・日付)・値の組で、どの key を何に使うかはそれを読むコンポーネントが決めます。kukan- で始まる key は本体の予約なので、ブランドではそれ以外を使ってください。

サーバーコンポーネントでは、整形の部品に key を name で渡します。key が無いときや種類が違うときは何も描きません。

import { SiteDate, SiteNumber, SiteText } from '@/components/site-metadata'
export async function PopulationBand() {
return (
<dl>
<dt>人口</dt>
<dd>
<SiteNumber name="population" />
</dd>
<dt>人口動態(前月比)</dt>
<dd>
<SiteNumber name="population-change" signed />
</dd>
<dd>
<SiteDate name="population-as-of" />
</dd>
<dd>
<SiteText name="tagline" />
</dd>
</dl>
)
}
部品 描くもの
SiteNumber 数値を表示言語に合わせた桁区切りで描く。signed で正の値に + を付け、fractionDigits で小数桁を固定
SiteDate 日付を描く。既定は日本語表示なら和暦(令和8年6月1日)、英語表示なら西暦。calendar="gregory" / "japanese" で固定
SiteText 文字列を描く。言語別の文字列は表示言語で選び、無い言語はブランドのテキストと同じ順で補う

値を組み立てて使うときは、getSiteMetadata() で読み出します。1 リクエストの中で設定を読むのは 1 回だけです。

import { getLocale } from 'next-intl/server'
import { getSiteMetadata } from '@/lib/server-api'
const [metadata, locale] = await Promise.all([getSiteMetadata(), getLocale()])
metadata.number('population') // number | undefined
metadata.text('tagline', locale) // string | undefined
metadata.date('population-as-of') // 'YYYY-MM-DD' | undefined

クライアントコンポーネントで使う

Section titled “クライアントコンポーネントで使う”

ここまでの部品と getSiteMetadata() はサーバー専用で、'use client' のファイルからは使えません。タブやカルーセルのように動きのある部品で値を使うときも、サーバー側で読んでから渡します。値が最初の HTML に入るので、読み込み中に表示がちらつかず、ブラウザから設定を読み直すこともありません。

  • 値を渡す: サーバーコンポーネントで取り出した値(数値や ISO の日付の文字列)を props で渡す
  • 描いたものを渡す: SiteNumber などでサーバーが描いた要素を、children などで渡す
// overrides/top-page.tsx — server component
import { SiteDate, SiteNumber } from '@/components/site-metadata'
import { StatsCarousel, StatSlide } from './stats-carousel' // 'use client'
export async function TopPage() {
return (
<StatsCarousel>
<StatSlide label="人口" asOf={<SiteDate name="population-as-of" />}>
<SiteNumber name="population" />
</StatSlide>
<StatSlide label="世帯" asOf={<SiteDate name="population-as-of" />}>
<SiteNumber name="households" />
</StatSlide>
</StatsCarousel>
)
}

値は公開の GET /api/v1/site/settings の metadata に key と値の組で載ります(日付は ISO の文字列)。保存してから、ログインしていない閲覧者に届くまで、AWS では最大で約 90 秒、Docker Compose では最大 30 秒かかります。

override が増えた場合、ディレクトリで整理できます。

brands/default/overrides/
├── index.ts
├── layout/
│ ├── header.tsx
│ └── footer.tsx
└── pages/
└── hero-section.tsx

index.ts で全て集約すれば、内部構造は自由です。

利用規約やプライバシーポリシーなど、組織固有の静的ページを追加できます。 初期状態ではサンプルの利用規約ページ(/terms)が含まれています。不要であれば削除してください。

1. brands/default/pages/ にページコンポーネントを作成

apps/web/brands/default/pages/privacy.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'プライバシーポリシー',
}
export default function PrivacyPage() {
return (
<article className="mx-auto max-w-3xl px-4 py-12">
<h1 className="mb-8 text-2xl font-bold">プライバシーポリシー</h1>
<p>...</p>
</article>
)
}

2. brands/default/pages/index.ts に登録

export const pages: Record<string, () => Promise<BrandPage>> = {
terms: () => import('./terms'),
privacy: () => import('./privacy'), // 追加
}

これで /privacy にアクセスするとページが表示されます。

3. 必要に応じて brand-config.ts の headerNavExtra や footerLinks にリンクを追加

headerNavExtra: [
{ label: 'プライバシーポリシー', href: '/privacy' },
],
footerLinks: [
{ label: '利用規約', href: '/terms' },
{ label: 'プライバシーポリシー', href: '/privacy' },
],

不要なページは brands/default/pages/index.ts のマップから削除し、対応する .tsx ファイルを削除してください。

ロゴ、ファビコン、OG 画像などは apps/web/brands/default/public/ に配置します。

apps/web/brands/default/public/
├── logo.svg
├── favicon.ico
└── og-image.png

配置したファイルは /brand/logo.svg のような URL で参照できます。

複数ブランド(1 フォークで複数サイト)

Section titled “複数ブランド(1 フォークで複数サイト)”

1 つのフォークで複数サイト(マルチサイト運用)を 運用し、サイトごとに見た目を変えたい場合は、ブランドを複数持てます(ADR-042)。

  • デフォルトブランドは apps/web/brands/default/ です。新しいブランドは brands/default/ をコピーして apps/web/brands/<name>/ を作り、中身を編集します
  • ビルド時に KUKAN_BRAND=<name> を渡すと @/brand の解決先がそのブランドに 切り替わります(未指定なら brands/default/)。ビルドに入るのは選択した 1 ブランドのみです
  • AWS では environments.ts の各サイトに brand: '<name>' を指定すると、 パイプラインがサイトごとに KUKAN_BRAND を変えて web イメージをビルドします (環境設定リファレンス)。存在しない ブランドを指定するとイメージビルドが失敗します
  • Docker Compose では web イメージのビルド時に --build-arg KUKAN_BRAND=<name> を 渡します(マルチサイトでは docker/multi-site/compose.site.yml の KUKAN_BRAND のコメントを外します)
  • BrandConfig / BrandOverrides の契約と CSS 変数のルールは全ブランド共通です。 各ブランドは値と差し替え部品だけを持ちます

フォーク側は定期的に本体の main ブランチを取り込んでください。

ターミナルウィンドウ
git remote add upstream https://github.com/kukan-project/kukan.git
git fetch upstream
git merge upstream/main

カスタマイズが brands/default/(public/ 含む)に限定されていれば、コンフリクトは原則発生しません。

本体側が src/types/brand.ts に型を追加した場合、brand-config.ts で新しいフィールドの追加が必要になることがあります。TypeScript の型エラーとして検出されるので、エラーメッセージに従って対応してください。

本体のユニットテストはブランド非依存に設計されており、テスト内では brands/default/ の内容が KUKAN デフォルト値に固定されます。ブランドをどうカスタマイズしても本体のテスト(CI)はそのまま通ります。

  • 本体のテストファイルは変更しないでください。カスタマイズによって本体のテストが失敗する場合は、フォーク側で修正せず、本体リポジトリへ issue で報告してください
  • カスタムコンポーネント自体のテストは apps/web/brands/default/__tests__/ に配置します(複数ブランドでは各ブランドの brands/<name>/__tests__/)。*.test.ts / *.test.tsx は自動的に検出され、pnpm test で実行されます。テスト内の @/brand は常に brands/default/ を指すので、ブランドのファイルは相対パス(../overrides/header など)で読み込んでください。テストを書くかどうかはフォーク側の裁量です
apps/web/brands/default/__tests__/header.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Header } from '../overrides/header'
describe('Custom Header', () => {
it('should render the organization logo', async () => {
render(await Header())
expect(screen.getByAltText('○○市')).toBeInTheDocument()
})
})
やりたいこと 方法 所要時間の目安
配色を変えたい brands/default/theme.css に CSS 変数を記述 5 分
公式トークンにない独自色を使いたい brands/default/theme.css に --brand-* トークンを定義 5 分
サイト名・テキストを変えたい brands/default/brand-config.ts を編集 30 分
UI の翻訳テキストを変えたい brands/default/messages/{locale}.json に上書きキーを記述 10 分
ロゴを差し替えたい brands/default/public/ に配置 + config 編集 10 分
ヘッダー/フッターの構造を変えたい brands/default/overrides/ にコンポーネント作成 数時間
トップページを差し替えたい brands/default/overrides/ にコンポーネント作成 数時間
静的ページを追加したい brands/default/pages/ にコンポーネント作成 + 登録 30 分