rhyoharianja

payload admin theme

by rhyoharianja
Admin customization
v3
^3.88.0
MIT

Overview

Summary generated from the README

Adds a shadcn-style theme to the Payload admin panel, built on the payload-theme package. All settings are stored in a global and rendered as server-side CSS, so changes apply on save without a restart or rebuild. The stylesheet must be imported in custom.scss and the import map regenerated. Requires Payload 3.88 or newer.

Features

  • Single accent color generates a full OKLCH 50-950 scale with automatic accessible text contrast
  • Presets, corner radius, typography and content width set from a System settings page
  • Logo, dark-mode logo, favicon and admin title uploaded from the media library
  • Per-collection Lucide icons, groups and visibility shared by sidebar, dashboard and command palette
  • Custom login page with background image, scrim, heading and supporting copy
  • Raw token overrides and custom CSS, plus a header palette panel for quick changes

Use cases

  • Rebrand the Payload admin for a client without redeploying code
  • Let non-developers adjust colors, logo and navigation from the admin panel
  • Build a white-label CMS with a custom login screen and sidebar icons

From the author

Tema admin Payload bergaya shadcn yang seluruh konfigurasinya diatur dari panel admin

Installation

npm i payload-admin-theme

1

Stars

0

Forks

0

Open Issues

1 months ago

Last Updated

0

Weekly Downloads

v1.2.0

npm Version

508.1 KB

Unpacked Size

2

Dependencies

GitHub stars

Trends appear after 7 days of data. Daily snapshots have not started yet.

npm weekly downloads

Trends appear after 7 days of data. Daily snapshots have not started yet.

Each point is npm's rolling 7-day download count.

Health Score

53/100Good

Composite score based on GitHub activity, stars, npm downloads, publish recency, dependency count, and package size.

Badge for your README

Payload Directory health badge
Markdown
[![Payload Directory](https://payloaddirectory.dev/badge/1342450007-root.svg)](https://payloaddirectory.dev/plugins/1342450007-root)
HTML
<a href="https://payloaddirectory.dev/plugins/1342450007-root"><img src="https://payloaddirectory.dev/badge/1342450007-root.svg" alt="Payload Directory"></a>
Updated Sep 1, 2026Created Aug 22, 2026Published Sep 1, 2026

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.

Payload CMS Next.js React TypeScript pnpm Vitest License: MIT

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).

AreaWhat you can change
ColourA 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.
TypographyInter, Geist, Helvetica, System, or Payload's own font.
PresetsZinc, Ocean, Forest, Sunset, Berry, Swiss — accent, radius and typeface in one click.
Admin titleShown in the sidebar and appended to the browser tab title.
Logo & iconUploaded from the media library, with an optional dark-mode variant. The icon doubles as the favicon.
Content widthCentred (~1280px) or full-bleed.
NavigationLucide icons (with an icon picker), groups and visibility per collection/global. Shared by the sidebar, the dashboard and the command palette.
LoginBackground image (shown as uploaded), optional scrim, heading and supporting copy.
AdvancedRaw --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-theme computes 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-theme

One 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:importmap

Open 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 page

The cascade order this relies on:

  1. @layer payload — colour and radius tokens. Payload declares the layer order payload-default, payload, so this block beats Payload's defaults without !important, and still loses to your project's own unlayered CSS.
  2. Unlayered — --font-body and the branding rules. Payload declares --font-body outside any layer, so a layered declaration would always lose.
  3. 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:

  1. A value typed into the panel
  2. A preset selected in the panel
  3. defaults from the plugin options
  4. The preset named in defaults
  5. Library defaults (#4f4ece, radius md, 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

OptionDefaultDescription
defaults{}Initial values used until the global has been saved.
globalSlugadmin-themeSlug of the theme global.
adminGroupSystemSidebar group for the theme global.
themeOptions{}Passed straight through to payloadTheme() (see below).
installBaseThemetrueDisable if you call payloadTheme() yourself.
access—read / update access for the theme global.
disabledfalseStop 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:

  1. 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.
  2. Only forwarded subpaths are rewritten — client and rsc. A path from any other subpath is left pointing at the original package, because failing to resolve payload-theme/other is 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-theme Dashboard 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 CSS order — they simply have no heading above each group.
  • The sidebar logo is painted as a background-image. The payload-theme sidebar 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 no alt text — the logo link already carries an aria-label from Payload.
  • The sidebar title is rendered through content on 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's generateMetadata from admin.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 of react, react-dom, @payloadcms/ui and @payloadcms/next. Otherwise React context becomes two separate instances, useConfig() returns undefined, and the admin panel fails to render. For npm installs pnpm resolves this through peerDependencies; 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 application

Support

If this plugin saves you time, you can support its development:

PayPal Saweria

License

MIT © Suryo Galih Kencana Harianja

See NOTICE.md for the payload-theme attribution.

Similar plugins