Skip to content

Admin Theming and Branding

View markdown

How the KDIGI CMS admin UI is styled, and how to change its colors, logo, name and labels.

The admin is Payload's admin panel with custom components and one stylesheet. Payload still owns authentication, permissions, site (tenant) selection, queries and the document lifecycle; the theme only changes presentation. Branding here applies to the admin only. Each site's own brand (logo, favicon, primary color, theme) is site data on the Sites collection (Thương hiệu tab, branding group) and is not affected.

ConcernFile
Theme tokens and all admin CSS overridessrc/app/(payload)/custom.css
Tailwind v4 entry, scoped to .twp, and the shadcn token bridgesrc/app/(payload)/tailwind-admin.css
Loads the CSS and the Inter font (registered as an admin provider)src/components/admin/css-injector.tsx
Logo and icon components (admin.components.graphics)src/components/admin/logo.tsx, src/components/admin/icon.tsx
Brand imagespublic/brand/ (kdigi-logo.svg, kdigi-logo-white.svg, kdigi-mark.svg)
Browser tab title, favicon, descriptionadmin.meta in src/payload.config.ts
Login screen brandingsrc/components/admin/login-workspace-panel.tsx
Sidebar, dashboard, list and edit viewsadmin-nav*.tsx, editorial-dashboard-view.tsx, premium-*.tsx in src/components/admin/
shadcn/ui primitivessrc/components/ui/ (configured by components.json)
Markdown editor themesrc/components/admin/milkdown-theme-tokens.css and sibling milkdown-*.css

The stylesheet is imported once by CSSInjector, registered in admin.components.providers. There is no second theme provider; light/dark follows Payload's own theme switch (admin.theme: 'all', stored per user) through the data-theme attribute on <html>.

Everything is driven by CSS custom properties defined in custom.css.

Color tokens. Payload's native --theme-* variables, defined once for :root, [data-theme="light"] and once for [data-theme="dark"]. The default palette is neutral (grayscale OKLCH); the brand accent is a token you can change.

TokenRole
--theme-bg, --theme-input-bg, --theme-text, --theme-muted-textPage, input, text, secondary text
--theme-border-color, --harbor-control-borderDecorative borders, control borders
--theme-ringFocus ring
--theme-elevation-0 ... --theme-elevation-1000Payload's surface scale
--theme-brand, --theme-brand-hover, --theme-brand-activeAccent: primary buttons, links, active nav, checked controls, selected rows
--theme-brand-subtle, --theme-brand-mutedSoft accent backgrounds and borders
--harbor-on-primaryText color on top of --theme-brand
--theme-nav-bg, --theme-nav-borderSidebar

Success, warning and error colors keep their own semantic values; do not replace them with the accent.

Layout and type tokens (:root, prefixed --kdigi-):

Token groupExamples
Spacing--kdigi-space-1 ... --kdigi-space-8 (4/8/12/16/24/32 px)
Control height--kdigi-control-height (36 px; 44 px on phones), --kdigi-control-compact-height (32 px)
Type scale--kdigi-font-size-meta/body/section/page (12/14/16/24 px), matching --kdigi-line-* and --kdigi-weight-body/label/heading
Navigation--kdigi-nav-width (256 px), --kdigi-nav-compact-width (48 px)
Motion--kdigi-duration (160 ms); prefers-reduced-motion shortens animations
Other--font-body (Inter Variable, then system fonts), --style-radius-s/m/l (6/8/10 px), --app-header-height

html is 16 px and body text 14 px. --base-body-size: 16 must match html; lowering it produces fractional spacing in Payload's base() calculations.

tailwind-admin.css loads only Tailwind's theme and utilities layers (@layer theme, base, components, utilities). No global preflight is installed, so Payload's own styles are never reset. A minimal reset lives inside @layer base, scoped to .twp descendants.

Rules for custom React components:

  1. Put Tailwind utility classes and shadcn components inside a wrapper element with class twp: <div className="twp"><Button variant="outline">Action</Button></div>.
  2. .twp maps the shadcn variables (--background, --primary, --border, --ring, --sidebar-*, ...) onto the --theme-* tokens, so shadcn components follow the admin palette in light and dark mode. Do not declare an independent palette.
  3. Portaled content (popovers, tooltips, dialogs rendered outside the wrapper) must carry its own twp class on the content root, not only on the trigger.
  4. dark: utilities follow [data-theme="dark"] (custom variant in tailwind-admin.css).
  5. Do not put utilities on Payload's native elements; utilities are generated globally, and .twp only scopes the CSS variables and the reset.
export const MyPanel = () => (
<div className="twp rounded-lg border border-border bg-card p-4">
<Button variant="outline">Action</Button>
</div>
)

components.json points the shadcn CLI at src/app/(payload)/tailwind-admin.css, so components added with the CLI land in src/components/ui/ with the @/ aliases.

Edit src/app/(payload)/custom.css:

  1. Change the --theme-brand, --theme-brand-hover, --theme-brand-active, --theme-brand-subtle and --theme-brand-muted values in both the light block (:root, [data-theme="light"]) and the dark block ([data-theme="dark"]). Adjust --harbor-on-primary so button text stays readable (and --theme-ring if you want a tinted focus ring).
  2. Optionally tint the surfaces (--theme-elevation-*, --theme-nav-bg).
  3. Nothing else is required: the .twp bridge, the sidebar, buttons, links and the Markdown editor (milkdown-theme-tokens.css) all derive from these tokens.

Example (illustrative values):

:root, [data-theme="light"] {
--theme-brand: oklch(0.55 0.2 260);
--theme-brand-hover: oklch(0.5 0.2 260);
--theme-brand-active: oklch(0.45 0.2 260);
--theme-brand-subtle: oklch(0.96 0.03 260);
--theme-brand-muted: oklch(0.9 0.06 260);
--harbor-on-primary: oklch(0.99 0 0);
}

Check light and dark mode in /admin after the change, including a table row selection, a primary button and the editor.

  1. Replace the files in public/brand/ with your own assets, keeping the file names: kdigi-logo.svg (wordmark for light mode), kdigi-logo-white.svg (wordmark for dark mode) and kdigi-mark.svg (square mark: compact sidebar, header, login, favicon).
  2. If you use different names or formats, update the src (and width/height) in logo.tsx and icon.tsx, and the two icons entries in admin.meta in payload.config.ts (these must be absolute URLs built from serverURL).
  3. Display sizes come from custom.css: .kdigi-logo is 128 px wide, .kdigi-icon is 28 px (24 px and 20 px in the header). Logo renders both wordmarks and CSS shows the one matching data-theme.

Strings to change when renaming the product:

StringLocation
Tab title, title suffix, description, Open Graph textadmin.meta in src/payload.config.ts
Header home label (CSS content).app-header__step-nav .step-nav__home::after in custom.css
Login brand name and taglinelogin-workspace-panel.tsx
Sidebar logo link aria-labeladmin-nav-client.tsx
Dashboard welcome lineeditorial-dashboard-view.tsx
alt textlogo.tsx, icon.tsx

The admin has no Open Graph image (defaultOGImageType: 'off'); keep it that way, because Payload's dynamic OG route cannot render the relative image URL the sidebar icon uses.

The admin UI is bilingual, Vietnamese (default, fallbackLanguage: 'vi') and English (i18n.supportedLanguages in payload.config.ts). The two choices are independent: the header Content language switch changes ?locale=vi|en for content, the account menu Interface language changes the UI.

WhatWhere
Collection names, field labels, descriptionslabels, label and admin.description in src/collections/*.ts, usually { vi, en } objects
Sidebar groupsadmin.group in each collection
Automatic Vietnamese translation of English field labels/descriptionssrc/lib/admin-display-labels.ts (lookup tables; display-only, never touches field names, enum values or content). Applied to all collections by src/lib/apply-premium-admin-presentation.ts
Custom views and widgetsInline isEnglish ? '...' : '...' pairs in the component
Editor UI stringssrc/components/admin/milkdown-ui-copy.ts
Payload core labels that need overridingi18n.translations in payload.config.ts (for example the content/interface language labels)

To add a language, add its Payload translation bundle to i18n.supportedLanguages and extend the { vi, en } label objects and the isEnglish branches you care about.

apply-premium-admin-presentation.ts only changes admin metadata, labels and component registrations; names, types, access and hooks are untouched. It swaps native text, textarea, select and checkbox inputs for the premium-* field components unless a field already has a custom Field or is hidden. Other custom surfaces:

SurfaceComponent
Sidebaradmin-nav.tsx (server, permission-filtered) and admin-nav-client.tsx; collapse state in localStorage key kdigi_cms_sidebar_collapsed
Dashboardeditorial-dashboard-view.tsx with dashboard-widgets.tsx and seo-audit-widget.tsx; shows live counts only
Content calendarcontent-calendar/content-calendar-view.tsx, mounted at /admin/content-calendar
Collection listspremium-collection-list-view.tsx, premium-collection-table.tsx
Edit viewpremium-edit-view.tsx, a wrapper around Payload's default edit view (one form, one save lifecycle)
Media Librarymedia-grid-list-view.tsx (see media-storage.md)
Markdown editormarkdown-editor-field.tsx, milkdown-editor-core.tsx (see content-markdown.md)

The editor core imports @milkdown/crepe/theme/common/style.css, @milkdown/crepe/theme/frame.css, then milkdown-lark-theme.css, milkdown-layer-tokens.css and milkdown-editor-core.css. Its colors come from --mlk-* variables defined in milkdown-theme-tokens.css in terms of the admin --theme-* tokens, so rebranding the accent also rebrands the editor. Layer (z-index) values live in milkdown-layer-tokens.css; do not raise the whole editor wrapper above modals.

  • Spacing, control heights and type sizes have a single owner each (--kdigi-* tokens). Prefer changing the token over adding !important overrides, and check computed styles when editing the cascade.
  • Small screens: the sidebar becomes an overlay below 768 px, settings tabs go horizontal below 1024 px, controls grow to 44 px on phones.
  • Payload's @payloadcms/ui is patched (patches/@payloadcms__ui@3.90.2.patch, declared under pnpm.patchedDependencies in package.json) so drawer toggler aria-labels resolve bilingual labels. Re-check the patch whenever you upgrade Payload; see upgrading.md.
  • Previewing the admin over plain HTTP from a non-loopback host (LAN IP, hostname) cannot log in, because browsers omit Origin and Payload then ignores the auth cookie. Use localhost, or HTTPS, and set PAYLOAD_PUBLIC_SERVER_URL to the exact origin you open (see configuration.md).

See also: architecture.md, roles-and-permissions.md.