Skip to content

Appearance Customization

This guide explains how to customize KUKAN when you fork the repository for your organization. Changes can be applied incrementally — start with the simplest approach.

Files you modify in a fork are limited to apps/web/brands/default/. Its contents:

Path (under brands/default/)ContentsSection
brand-config.tsSite name, description, logo, footer links, OG image, favicon, GA IDTier 2
theme.cssCSS variable overrides (colors, sizing)Tier 1
messages/ja.json / en.jsonUI text (i18n message) overridesi18n Message Overrides
overrides/Component replacements for Header / Footer / TopPage (slots)Tier 3
pages/Custom static pages (terms, privacy policy, etc.)Static Pages
public/Static files: logo, favicon, OG image, etc.Static Files
index.ts / messages/index.tsExport barrels; normally no need to edit

Do not modify files outside this directory. Doing so will cause merge conflicts when pulling upstream updates.

Write CSS variables in brand/theme.css to change colors and sizes across the entire site.

apps/web/brands/default/theme.css
:root {
--primary: 142 64% 32%; /* Main color (HSL) */
--primary-foreground: 0 0% 100%; /* Text color on main color */
--kukan-header-height: 72px; /* Header height */
--kukan-container-max-width: 1400px;
}

You can override shadcn/ui standard variables and KUKAN-specific variables (prefixed with --kukan-*). See apps/web/src/app/globals.css for the full list.

Each variable carries an override rating to make the go/no-go decision easier:

  • Recommended — variables intended to be changed to match your brand
  • Caution — overridable, but shared across multiple components and states. Check the “Purpose and main usage” column and preserve the default light/dark relationship
  • Not recommended — variables that convey UI meaning (error, success, warning). Changing them for branding reasons is discouraged
VariableRatingPurpose and main usage
--primary / --primary-foregroundRecommendedMain brand color. Buttons (default), links, progress bars, text selection, checked states of checkboxes etc.
--highlightRecommendedSearch keyword highlighting (only affects search result display)
--radiusRecommendedBase border radius (all components)
--kukan-*RecommendedLayout values such as header height, logo height, container width
--background / --foregroundCautionPage background and base text. Affects the whole site
--card / --popover (each with -foreground)CautionBackgrounds and text of cards and popups such as dropdowns and selects
--secondary / --secondary-foregroundCautionBackgrounds of secondary buttons and badges. Expected to stay light
--muted / --muted-foregroundCautionSupplementary text (the most widely used variable on the site), placeholders, skeletons, tinted backgrounds. Expected to stay light
--accent / --accent-foregroundCautionHover and selection color. Hover of outline/ghost buttons, focused items in selects/dropdowns, dialog close buttons, etc. Expected to stay light
--border / --inputCautionBorders and input outlines across all components
--ringCautionFocus ring. Directly affects keyboard-navigation visibility — keep sufficient contrast against backgrounds
--destructive / --destructive-foregroundNot recommendedSemantic color for errors and deletion (destructive buttons, form errors, delete menu items). Solid fill and text on it
--success / --warningNot recommendedSemantic colors for success / warning states (alerts, pipeline status, form warnings). Used for borders, tinted backgrounds, icons
--success-tint-foreground / --warning-tint-foregroundNot recommendedText color on the tinted (/10–/15) success / warning surfaces; not for solid fills

Follow these rules to swap colors safely:

  1. Override raw tokens only — redeclare variables like --primary as-is. Never touch the --color-* mappings in globals.css (they wire tokens into Tailwind and are managed upstream).
  2. Keep the bare HSL triplet format — write --primary: 142 64% 32%; with no commas and no hsl() wrapper. Hex values or hsl(...)-wrapped values will not work.
  3. Do not bake in opacity — transparency is applied at usage sites (e.g. bg-success/10). Tokens must hold fully opaque base colors.
  4. Update paired tokens together and check contrast — when changing --primary, also update --primary-foreground and keep a contrast ratio of at least 4.5:1 (WCAG AA). When changing --success / --warning, set the matching -tint-foreground to a dark shade of the same hue and verify at least 4.5:1 on the /15 tinted surface (unlike -foreground tokens, these are not meant for solid fills).
  5. Do not introduce dark: classes — KUKAN’s theme tokens are light-only. Adding dark: in override components breaks the display only for users whose OS prefers dark mode.
  6. Avoid raw palette colors in override components too — use semantic classes like text-success instead of text-green-600, so a future recoloring only takes edits to theme.css.

When a designer-specified palette includes colors that do not fit any official token role (primary, accent, etc.), you can add custom tokens with the --brand- prefix in brand/theme.css.

apps/web/brands/default/theme.css
:root {
--brand-accent-green: 183 29% 87%;
--brand-link: 183 79% 28%;
}
  • --brand-* is a fork-only namespace — upstream never defines or references --brand-* tokens, so they cannot conflict with upstream updates. Conversely, --kukan-* is reserved for upstream; do not use it for custom tokens
  • Use the same bare HSL triplet format as official tokens (so opacity modifiers like /10 work at usage sites)
  • Custom tokens can only be consumed in fork-owned components (brand/overrides/ / brand/pages/). Upstream components never reference them
  • No Tailwind utility classes are generatedtheme.css is a separate CSS unit from globals.css, so @theme cannot generate classes for it. Reference custom tokens with the arbitrary value syntax:
<a className="text-[hsl(var(--brand-link))] bg-[hsl(var(--brand-accent-green))]">...</a>

If you want to recolor a single component on an upstream screen you have not overridden, overriding official tokens often has too wide a blast radius (see the table above). For this case, the upstream repository adds component-scoped alias tokens on demand (e.g. --kukan-header-bg, defaulting to var(--primary) so nothing changes unless a fork overrides it). Propose the addition via an issue on the upstream repository when you need one.

Edit brand/brand-config.ts to change the site name, copyright, and metadata.

apps/web/brands/default/brand-config.ts
import type { BrandConfig } from '@/types/brand'
export const brandConfig: BrandConfig = {
siteName: 'City of Example Open Data Catalog',
siteDescription: 'Search and download open data published by the City of Example',
copyright: 'City of Example.',
copyrightUrl: 'https://www.city.example.lg.jp',
logo: { type: 'image', src: '/brand/logo.svg', width: 160, height: 40, alt: 'City of Example' },
headerNavExtra: [
{ label: 'Official Site', href: 'https://www.city.example.lg.jp', external: true },
],
footerLinks: [
{ label: 'Terms of Use', href: '/terms' },
{ label: 'Contact', href: '/contact' },
],
ogImage: '/brand/og-image.png',
faviconPath: '/brand/favicon.ico',
}
FieldDescription
siteNameSite title (browser tab, header, etc.)
siteDescriptionmeta description (for search engines)
copyrightFooter copyright text (© 2026 is prepended automatically)
copyrightUrlLink target for the site name in the footer (omit for no link)
logoHeader logo configuration (see below)
headerNavExtraAdditional navigation items for the header
footerLinksFooter link list
ogImageOGP image path (relative to public/)
faviconPathFavicon path

The example-query chips under the search box are configured from the admin Site Management page (/dashboard/admin/site), not the brand config.

The logo field supports two modes:

// Default (KUKAN logo)
logo: { type: 'default' }
// Image file (recommended: 32-40px height, SVG format)
logo: { type: 'image', src: '/brand/logo.svg', width: 160, height: 40, alt: 'City of Example' }

You can override UI translation text for your organization. Specify only the keys you want to change in brand/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"
}
}
  • Only specified keys are overridden; all others use the defaults from apps/web/messages/{locale}.json
  • Nested objects are merged recursively — you don’t need to repeat sibling keys
  • See apps/web/messages/en.json for the full list of available keys
Use caseWhere to configure
Metadata, OGP, etc. (language-independent)brand-config.ts
UI display text (per-language)brand/messages/{locale}.json

To change the structure of the Header or Footer, create custom components and register them as overrides.

1. Create a custom component

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="City of Example" className="h-8" />
<div className="flex items-center gap-2">
<LanguageSwitcher />
{user && <UserMenu user={user} />}
<MobileNav user={user} />
</div>
</div>
</header>
)
}

2. Register in 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,
}

That’s all it takes to replace the Header.

SlotReplaces
HeaderEntire site header
FooterEntire site footer
TopPageEntire top page (home page)

Inside custom components, you can import and reuse parts of the default implementation. Use DefaultHeader / DefaultFooter instead of Header / Footer to avoid circular references.

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

If you have many overrides, organize them into subdirectories:

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

The internal structure is up to you as long as index.ts exports everything.

You can add organization-specific static pages such as terms of use or privacy policies. A sample terms page (/terms) is included by default. Remove it if not needed.

1. Create a page component in brand/pages/

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

2. Register in brand/pages/index.ts

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

The page is now accessible at /privacy.

3. Optionally add links to headerNavExtra or footerLinks in brand-config.ts

headerNavExtra: [
{ label: 'Privacy Policy', href: '/privacy' },
],
footerLinks: [
{ label: 'Terms of Use', href: '/terms' },
{ label: 'Privacy Policy', href: '/privacy' },
],

To remove a page, delete its entry from the pages map in brand/pages/index.ts and delete the corresponding .tsx file.

Place logos, favicons, and OG images in apps/web/brands/default/public/.

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

Files placed here are served at URLs like /brand/logo.svg.

Multiple Brands (Multiple Sites in One Fork)

Section titled “Multiple Brands (Multiple Sites in One Fork)”

If one fork operates multiple sites (Multi-Site Operation) and you want a different look per site, you can hold multiple brands (ADR-042).

  • The default brand is apps/web/brands/default/. Add a new brand by copying brands/default/ to apps/web/brands/<name>/ and editing it
  • Passing KUKAN_BRAND=<name> at build time switches what @/brand resolves to (unset → brands/default/). Only the selected brand enters the build
  • On AWS, set brand: '<name>' on each site in environments.ts and the pipeline builds a web image per site with the matching KUKAN_BRAND (Environment Configuration Reference). An unknown brand fails the image build
  • The BrandConfig / BrandOverrides contract and the CSS variable rules are common to all brands; each brand only supplies values and override components

Periodically pull updates from the upstream main branch:

Terminal window
git remote add upstream https://github.com/kukan-project/kukan.git
git fetch upstream
git merge upstream/main

As long as your changes are limited to brands/default/ (including its public/), merge conflicts should not occur.

When the upstream adds new fields to src/types/brand.ts, you may need to add them to your brand-config.ts. TypeScript will report type errors — follow the error messages to add the missing fields.

The upstream unit tests are designed to be brand-independent: inside tests, the contents of brands/default/ are pinned to the KUKAN defaults. No matter how you customize the brand, the upstream tests (CI) will keep passing.

  • Do not modify upstream test files. If a customization breaks an upstream test, report it to the upstream repository via an issue instead of patching it in your fork
  • Place tests for your custom components in apps/web/brands/default/__tests__/. Files named *.test.ts / *.test.tsx are discovered automatically and run with pnpm test. Whether to write tests is up to your fork
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('City logo')).toBeInTheDocument()
})
})
GoalMethodEstimated time
Change colorsWrite CSS variables in brand/theme.css5 min
Use custom colors outside official tokensDefine --brand-* tokens in brand/theme.css5 min
Change site name and textEdit brand/brand-config.ts30 min
Override UI translation textAdd override keys to brand/messages/{locale}.json10 min
Replace logoPlace file in brands/default/public/ + edit config10 min
Change header/footer structureCreate components in brand/overrides/A few hours
Replace the top pageCreate component in brand/overrides/A few hours
Add static pagesCreate components in brand/pages/ + register30 min