payload-admin-theme
v1.2.0
Published
Tema admin Payload bergaya shadcn yang seluruh konfigurasinya diatur dari panel admin
Maintainers
Readme
payload-admin-theme
A shadcn-style admin theme for Payload CMS whose entire configuration lives in the admin panel — not in your code.
Save the form, the panel changes. No restart, no rebuild, no deploy.
Installation · How it works · Options · Limitations · Support
There are two places to configure the theme, and both edit the same data: the palette panel in the header (quick, for experimenting) and the System → Konfigurasi Admin page (complete, including logo and title).
| Area | What you can change |
|---|---|
| Colour | A single accent hex drives the whole panel. The 50–950 scale is computed in OKLCH, and the text colour on top of the accent is picked automatically so its contrast meets WCAG. |
| Shape (shadcn) | Corner rounding from none to full for buttons, inputs, cards, tables and popovers. |
| Typography | Inter, Geist, Helvetica, System, or Payload's own font. |
| Presets | Zinc, Ocean, Forest, Sunset, Berry, Swiss — accent, radius and typeface in one click. |
| Admin title | Shown in the sidebar and appended to the browser tab title. |
| Logo & icon | Uploaded from the media library, with an optional dark-mode variant. The icon doubles as the favicon. |
| Content width | Centred (~1280px) or full-bleed. |
| Navigation | Lucide icons (with an icon picker), groups and visibility per collection/global. Shared by the sidebar, the dashboard and the command palette. |
| Login | Background image (shown as uploaded), optional scrim, heading and supporting copy. |
| Advanced | Raw --pt-* token overrides and custom CSS. |
Relationship to payload-theme
This plugin builds on top of payload-theme
(MIT, © Lider Bektaş). The entire visual layer — the shadcn stylesheet, the icon
sidebar, the dashboard, the ⌘K command palette, the login layout — comes from
there and is not copied. See NOTICE.md.
What this plugin adds is the configuration layer, and there is exactly one reason for it:
payload-themecomputes its theme once at boot from static options and stores it in the client config. Values coming from the database can never reach it without a restart.
So everything that must change on save was moved onto the path that is re-sent on every request: CSS rendered on the server.
Installation
pnpm add payload-admin-themeOne package. payload-theme is an internal dependency of this plugin: its
components are re-exported through payload-admin-theme/client and
payload-admin-theme/rsc, and its stylesheet ships inside
payload-admin-theme/styles.css. Your application never needs to know it exists.
1. Register the plugin in payload.config.ts:
import { payAdminTheme } from 'payload-admin-theme'
export default buildConfig({
plugins: [
payAdminTheme({
// Initial values, used until someone opens the theme page. Optional.
defaults: { accent: '#0d9488', siteTitle: 'Your Site' },
}),
],
})2. Import the stylesheet in src/app/(payload)/custom.scss:
@import 'payload-admin-theme/styles.css';This step stays manual because the stylesheet has to be bundled together with
your application's CSS. Injecting it through a runtime <link> would cause a
flash of unstyled panel on first load.
3. Regenerate the import map, then start the app:
npx payload generate:importmapOpen System → Konfigurasi Admin. Change anything, save, and see the result.
How it works
"Konfigurasi Admin" global (database)
│ re-read on every request (5s cache, cleared on save)
▼
resolveTheme() ── a half-filled document → final values, never throws
▼
buildTheme() ── one hex → OKLCH 50–950 scale + light/dark tokens
▼
buildCSS() ── tokens, radius, font, branding, custom CSS
▼
<style> rendered on the server on every admin pageThe cascade order this relies on:
@layer payload— colour and radius tokens. Payload declares the layer orderpayload-default, payload, so this block beats Payload's defaults without!important, and still loses to your project's own unlayered CSS.- Unlayered —
--font-bodyand the branding rules. Payload declares--font-bodyoutside any layer, so a layered declaration would always lose. - Your custom CSS, last.
The static ThemeProvider from payload-theme is removed, so there is no
second token block competing with this one.
A single source of truth
The header palette panel and the Warna & Bentuk tab edit the same fields.
The customiser that ships with payload-theme is not used: it stores its
choices in localStorage per browser and applies them as inline styles on
<html>. Inline styles always beat a stylesheet, so the moment anyone touched
it, the database theme stopped applying in that browser — and the Warna & Bentuk
tab had no way of knowing what that person was actually looking at.
The replacement writes every change to the global through the REST API and then
triggers router.refresh(). The consequence is deliberate: anything changed
from the header applies to every user, not just the browser that changed it.
Leftover localStorage from the old customiser is cleared automatically on load.
The other three header buttons (collapse sidebar, light/dark, user menu) behave exactly as before. Light/dark mode is deliberately not stored in Konfigurasi Admin: it is a per-user preference, and it lands in the same place as the "Admin Theme" radios on the Account page.
Value precedence
From strongest to weakest:
- A value typed into the panel
- A preset selected in the panel
defaultsfrom the plugin options- The preset named in
defaults - Library defaults (
#4f4ece, radiusmd, Payload's font)
Rule 2 sits above rule 3 on purpose: selecting a preset in the panel is an explicit action someone just took, so it has to beat a default written in code.
A cleared text field is treated as not set, not as an empty value — clearing
the accent returns you to defaults, not to the library default. Boolean false
and the number 0 are still honoured as deliberate choices.
Options
| Option | Default | Description |
|---|---|---|
| defaults | {} | Initial values used until the global has been saved. |
| globalSlug | admin-theme | Slug of the theme global. |
| adminGroup | System | Sidebar group for the theme global. |
| themeOptions | {} | Passed straight through to payloadTheme() (see below). |
| installBaseTheme | true | Disable if you call payloadTheme() yourself. |
| access | — | read / update access for the theme global. |
| disabled | false | Stop enforcing the theme; the global remains so the database schema stays consistent. |
What still has to live in code
Two things cannot move into the panel, and that is not a design choice: both
are resolved when generate:importmap runs, not per request.
payAdminTheme({
themeOptions: {
// Lucide icons per collection/global
nav: { icons: { media: 'image', posts: 'newspaper', users: 'users' } },
// Your own dashboard widgets
dashboard: { widgets: ['/components/widgets/Stats#Stats'] },
},
})Relationship to payload-theme
This package builds on payload-theme
and re-exports everything it exports, through payload-admin-theme/client
and payload-admin-theme/rsc. Your application installs one package; the base
theme is an implementation detail.
Component paths registered by the base theme are rewritten from
payload-theme/* to payload-admin-theme/* so they resolve from your app —
under pnpm's strict layout they would not otherwise, because the base theme is
not your dependency.
Two rules keep that rewriting honest, and both are covered by tests:
- Every base-theme export is forwarded, using
export *rather than a hand-written list. A new component in the base theme is therefore available under the rewritten path automatically. - Only forwarded subpaths are rewritten —
clientandrsc. A path from any other subpath is left pointing at the original package, because failing to resolvepayload-theme/otheris far easier to trace than resolving to a module of ours that exists but has no such export.
Version 1.1.1 and earlier used a hand-written re-export list, and
[email protected] broke it: the new AvatarProvider was rewritten to an
export that did not exist, and the admin panel answered 500. If you pinned the
base theme to work around that — pnpm.overrides or similar — you can remove
the pin.
The login page
A single full-height screen split 2 : 1 — two parts image, one part form.
The background image is uploaded from the Login tab and shown exactly as
uploaded: the accent glow and grid overlays that payload-theme paints on the
brand panel are switched off while an image is present, because both were
designed for a plain dark panel and read as a colour cast over a photograph.
An optional dark scrim is available on the same tab and defaults to 0. Raise it only if the heading and tagline — both white — get lost against a bright image.
Below 900px the image is hidden and the form returns to a single column — the
payload-theme behaviour, deliberately left untouched.
Navigation & dashboard
The Navigasi tab controls the icon, group and visibility of each
collection/global. Icons are chosen through a shadcn-style picker: a popover with
search over a grid of Lucide icons. Only the icon name is stored and it is
loaded through DynamicIcon, so none of the ~1500 icons end up in your bundle.
The same settings drive the sidebar, the dashboard cards and the command palette, so all three stay in step.
This works differently from the rest of the theme. Icons and groups are not CSS —
they are part of the Payload config (admin.custom.payloadTheme.nav.icons and
collection.admin.group). The plugin writes into the live config and then clears
Payload's client-config cache via _payload_doNotCacheClientConfig, the switch
Payload itself provides for runtime config changes. This is safe because the
value is not per-user: one navigation setting applies to everyone.
Known limitations
- Group headings cannot be inserted between dashboard cards. The
payload-themeDashboard component has no concept of groups, and a pseudo-element cannot be a grid item of its own. Cards are still grouped and ordered to match the sidebar via CSSorder— they simply have no heading above each group. - The sidebar logo is painted as a
background-image. Thepayload-themesidebar is a client component that reads its logo from a config frozen at boot, so the<img>inside it cannot change on save. Painting it through CSS moves the logo onto the path that is re-sent on every request. As a consequence the sidebar logo has noalttext — the logo link already carries anaria-labelfrom Payload. - The sidebar title is rendered through
contenton a pseudo-element, for the same reason. The text is escaped so a title containing quotes cannot break the stylesheet. - The favicon and tab title are adjusted in the browser. Payload builds
<head>through Next'sgenerateMetadatafromadmin.meta, which is read from the config and never sees the database. [email protected]publishes extensionless ESM imports. Valid under a bundler (Next) and under Payload's CLI loader, invalid under plain Node. If you run the config through plain Node — Vitest, for example — inline the package:test: { server: { deps: { inline: ['payload-theme'] } } }.- When developed through
link:or a workspace, the plugin and the application must share a single copy ofreact,react-dom,@payloadcms/uiand@payloadcms/next. Otherwise React context becomes two separate instances,useConfig()returnsundefined, and the admin panel fails to render. For npm installs pnpm resolves this throughpeerDependencies; for local development, keep the application and the plugin in one pnpm workspace.
Development
pnpm install
pnpm test # 49 unit + 24 integration tests (in-memory SQLite)
pnpm build
pnpm watch # rebuild dist while developing against an applicationSupport
If this plugin saves you time, you can support its development:
License
MIT © Suryo Galih Kencana Harianja
See NOTICE.md for the payload-theme attribution.
