npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

payload-admin-theme

v1.2.0

Published

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

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

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

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

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