Atomic Payload
Plugins

@pro-laico/styles

Manage your whole design system from the Payload admin: write Tailwind on your blocks and author colors, tokens, reusable shortcuts, and swappable design sets as content, with no config file to edit and no redeploy to restyle your site.

@pro-laico/styles brings your design system into the Payload admin. Write Tailwind classes directly on your blocks and author colors, spacing, animations, reusable shortcuts, and entire swappable design sets as content. There's no tailwind.config to edit and no redeploy to restyle your site. Saving in the admin regenerates the stylesheet your frontend serves.

Installation

pnpm add @pro-laico/styles
npm install @pro-laico/styles
yarn add @pro-laico/styles

payload, @payloadcms/ui, react, and server-only are peers you already have in a Payload + Next.js app. unocss is also a required peer. It's the engine that turns your authored classes and design set into CSS, so install it if you don't have it yet.

How the CSS reaches your app

The plugin registers two collections you edit in the admin, plus two hidden CSS storage globals (draftStorage and publishedStorage). Those collections are designSet (your theme: colors, spacing, animations, prose, and other tokens) and shortcutSet (reusable class groupings, like a Tailwind component class).

When you save a design set, shortcut set, or any document that carries authored classes, a beforeChange hook regenerates the stylesheet with UnoCSS and writes it into those storage globals. Your frontend reads the stored CSS and injects it into the page <head>, and reads the active design set for its <html> / <body> class names. Switching which design set is active swaps your whole look (no rebuild).

You wire the CSS hook one of two ways, and the Setup tabs below show both:

  • Standalone: pass a getCached getter (from createCssGetCached) and the plugin attaches its own cssHook, so this package processes CSS entirely on its own.
  • With the template: pass the project's atomicHook (from @pro-laico/atomic), which handles CSS along with the rest of the runtime.

The CSS hook collects every field whose name ends in ClassName (recursively, including fields nested in blocks) and feeds those values to UnoCSS. That's how ClassNameField works (it names its field …ClassName), but it also means a field of your own ending in ClassName (e.g. heroClassName) gets swept up and treated as atomic classes whether you meant it to or not. Use ClassNameField when you want a field processed, and avoid the ClassName suffix on any other field.

Setup

@pro-laico/core comes bundled with this plugin — it's a dependency, not a separate install. Its cached reads reach Payload through a config you register once in your Next.js instrumentation hook (registerPayloadConfig); see @pro-laico/core → Setup to wire it up.

Adding styles to your own Payload + Next.js project, with no @pro-laico/atomic.

Add the plugin to your Payload config

generateLivePreviewPath (shared by both collections for live preview) is optional. Leave it out and the plugin uses @pro-laico/core's generateLivePreviewPath, which builds the URL from PREVIEW_SECRET + NEXT_PUBLIC_SERVER_URL. Pass your own to override it. Pass a getCached getter (built with createCssGetCached) to attach the standalone CSS hook so this package generates the stylesheet itself:

import { buildConfig } from 'payload'
import { stylesPlugin } from '@pro-laico/styles'
import { createCssGetCached } from '@pro-laico/styles/cache'

// Resolves the active designSet / shortcutSet and the page atomic-classes from
// this package's own getters. A standalone styles project has no header / footer
// collections, so none are injected.
const getCached = createCssGetCached()

export default buildConfig({
  plugins: [
    stylesPlugin({
      getCached,
      // generateLivePreviewPath is optional; omit it to use the @pro-laico/core
      // default, or pass your own to override.
    }),
  ],
})

This registers the designSet and shortcutSet collections and the draftStorage / publishedStorage globals the generated CSS is written to.

Pairing this with @pro-laico/fonts? Pass designSet: { fontField: fontUploadField() } so the design set's Fonts tab gets the font picker. That's how the template wires the two together (see the other tab).

Register your Payload config

createCssGetCached uses this package's own design-set / shortcut-set / atomic-class getters, which reach Payload's Local API through a config you register once at startup. Add an instrumentation.ts (the same registration the frontend getters rely on):

// src/instrumentation.ts
export async function register(): Promise<void> {
  if (process.env.NEXT_RUNTIME !== 'nodejs') return

  const { registerPayloadConfig } = await import('@pro-laico/core/config')
  const { default: configPromise } = await import('@payload-config')

  registerPayloadConfig(configPromise)
}

The getters are wrapped with revalidation tags, so editing a design set or shortcut set in the admin flows through to your page (and to live preview).

Let your blocks carry classes

The class textarea field (where you type Tailwind/UnoCSS classes) ships in this package. Add ClassNameField to any collection whose documents should feed the stylesheet (e.g. your pages blocks). When such a document is saved, the standalone hook collects every *ClassName value (including those nested in blocks), stores them, and regenerates the CSS:

// blocks/hero.ts: every visual choice on the block is an authored class, not a hard-coded one
import type { Block } from 'payload'

import { ClassNameField } from '@pro-laico/styles/fields/className'

export const Hero: Block = {
  slug: 'hero',
  fields: [
    ClassNameField({ namePrefix: 'section', defaultValue: 'flex flex-col items-center gap-5 py-20' }),
    ClassNameField({ namePrefix: 'heading', defaultValue: 'text-4xl font-bold tracking-tight' }),
    { name: 'heading', type: 'text' },
    { name: 'subheading', type: 'text' },
  ],
}

Attach the same standalone hook to that collection's beforeChange so saving a page regenerates the stylesheet with its classes. createCssHook(getCached) builds it.

Render the generated stylesheet on your frontend

Your frontend reads the generated CSS from the storage globals via getCachedSiteCSS(draft) and injects it into <head>. Read the active design set too, via getCachedDesignSet(draft), for its <html> / <body> / wrapper class names:

// app/(frontend)/layout.tsx
import { draftMode } from 'next/headers'

import { getCachedDesignSet, getCachedSiteCSS } from '@pro-laico/styles/cache'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const { isEnabled: draft } = await draftMode()

  const css = await getCachedSiteCSS(draft)
  const ds = await getCachedDesignSet(draft)

  return (
    <html lang="en" className={ds?.htmlClassName ?? undefined}>
      <head>
        <style id="atomic-generated" type="text/css" dangerouslySetInnerHTML={{ __html: css || '' }} />
      </head>
      <body className={ds?.bodyClassName || undefined}>
        <div className={`${ds?.wrapperClassName ?? ''} isolate`}>{children}</div>
      </body>
    </html>
  )
}

Now picking a different design set in the admin restyles the whole site on the next request. The design set's tokens become the var(--…) values your classes resolve against.

Author your design in the admin

Open the designSet collection and edit its tabs (colors, sizes, animations, prose, and other tokens), then mark one set active. Author reusable class groupings in the shortcutSet collection. Only one design set is active at a time, so you can keep alternates as drafts and switch the active set to swap the whole look.

The styles-only example wires this exact standalone path end to end.

The atomic-payload template already wires everything: the designSet + shortcutSet collections, the CSS hook (through @pro-laico/atomic's atomicHook), the storage globals, and the layout that serves the generated stylesheet. The template uses the shared atomicHook instead of a getCached getter, and turns on the font picker:

// src/plugins/styles.ts
import { stylesPlugin } from '@pro-laico/styles'
import { fontUploadField } from '@pro-laico/fonts'
import { atomicHook } from '@pro-laico/atomic/hook'
import { generateLivePreviewPath } from '@pro-laico/core'

export const stylesPluginConfig = stylesPlugin({
  atomicHook,
  generateLivePreviewPath,
  designSet: { fontField: fontUploadField() },
  registerTypescriptSchema: false,
})

You just author your design and build.

Author your design set

Open the designSet collection and edit its tabs (colors, sizes, animations, prose, and other tokens). If you've also set up @pro-laico/fonts, the Fonts tab is where you pick the active faces.

Add reusable shortcuts

In the shortcutSet collection, author reusable UnoCSS shortcuts, a class grouping you name once and reuse anywhere. The built-in default shortcuts show as read-only rows for reference.

Mark a set active and save

Mark one design set active and save. The atomicHook regenerates the stylesheet into the storage globals, and the template's frontend layout already serves it. Reload to see the new design. Keep alternates as drafts and switch the active set to swap the whole look, no redeploy needed.

Caching & revalidation

The reads this package ships (@pro-laico/styles/cache) are server-side, wrapped in unstable_cache so a page doesn't re-query Payload on every render:

  • getCachedDesignSet(draft) / getCachedShortcutSet(draft): the active sets.
  • getCachedSiteCSS(draft): the generated stylesheet you inject into <head>.
  • getCachedAtomicClasses(draft): every stored *ClassName value across your pages.

When you save a design set, shortcut set, or page, the cssHook regenerates the stylesheet and revalidates the matching tags (designSet / shortcutSet, atomic-classes, site-css), and the plugin revalidates on delete, so the next read serves fresh CSS and live preview updates. See Caching & revalidation for how the tags and withCache work.

Options

stylesPlugin(options) accepts:

Prop

Type

The designSet, shortcutSet, and cssHookOptions options take their own nested keys:

All options at their defaults, as a working starting point:

stylesPlugin({
  // generateLivePreviewPath is optional; omitted here so it defaults to
  // @pro-laico/core's generateLivePreviewPath. Pass your own to override.
  enabled: true,
  // Wire CSS one way: pass getCached (standalone) OR atomicHook (template). Neither has a default.
  getCached: createCssGetCached(),
  designSet: {
    enabled: true,
    // access defaults to authenticated-only; collection and fontField are unset
  },
  shortcutSet: {
    enabled: true,
    defaultShortcuts: [],
    // access defaults to authenticated-only; collection is unset
  },
  cssHookOptions: {
    cssCacheTagBySlug: { header: 'header', footer: 'footer', designSet: 'designSet', shortcutSet: 'shortcutSet' },
    cssStorageGlobals: { draft: 'draftStorage', published: 'publishedStorage' },
    designSetSlug: 'designSet',
    cssProcessorSkipSlugs: ['iconSet'],
  },
  includeStorageGlobals: true,
  registerTypescriptSchema: true,
})

Exports

Everything @pro-laico/styles ships, grouped by what it's for.

The plugin

Export

Type

stylesPluginplugin
The plugin itself. Registers the designSet + shortcutSet collections and the CSS storage globals. Default and named export.

Parameters

options:StylesPluginOptionsSee the Options table above. generateLivePreviewPath is optional (defaults to @pro-laico/core's helper); pass getCached (standalone) or atomicHook (template) to wire CSS generation.

Returns

PluginA Payload config plugin you add to buildConfig({ plugins: [...] }).

Example

import { buildConfig } from 'payload'import { stylesPlugin } from '@pro-laico/styles'import { createCssGetCached } from '@pro-laico/styles/cache'export default buildConfig({plugins: [  // generateLivePreviewPath is optional; it defaults to @pro-laico/core's helper.  stylesPlugin({ getCached: createCssGetCached() }),],})

Location

@pro-laico/styles
StylesPluginOptionstype
The TypeScript type for the plugin's options.

Location

@pro-laico/styles
StylesDesignSetOptionstype
The options type for the designSet collection setting.

Location

@pro-laico/styles
StylesShortcutSetOptionstype
The options type for the shortcutSet collection setting.

Location

@pro-laico/styles

Fields

Export

Type

ClassNameFieldfield
The atomic-classes textarea field. Add it to a collection or block so authors can type Tailwind / UnoCSS classes that feed the generated stylesheet. Every field whose name ends in ClassName is collected by the CSS hook.

Parameters

args?:{ namePrefix?: string } & textarea field overridesnamePrefix prefixes the field name (e.g. section produces sectionClassName). Other keys (defaultValue, label, admin, …) are merged onto the field.

Returns

TextareaFieldA Payload textarea field named <namePrefix>ClassName.

Example

import type { Block } from 'payload'import { ClassNameField } from '@pro-laico/styles/fields/className'// every visual choice on the block is an authored class, not a hard-coded oneexport const Hero: Block = {slug: 'hero',fields: [  ClassNameField({ namePrefix: 'section', defaultValue: 'flex flex-col items-center gap-5 py-20' }),  ClassNameField({ namePrefix: 'heading', defaultValue: 'text-4xl font-bold tracking-tight' }),  { name: 'heading', type: 'text' },],}

Location

@pro-laico/styles/fields/className

CSS processing

Export

Type

createCssProcessorfunction
Builds the UnoCSS processor that reads the active sets and stored classes and writes the generated stylesheet into the storage globals. The standalone cssHook and the template atomicHook both run it; you rarely call it directly.

Parameters

getCached:CssProcessorGetCachedThe (tag, draft) getter it fetches the sets and classes through. Build one with createCssGetCached().
options:CssProcessorOptionscssCacheTagBySlug (the header / footer / designSet / shortcutSet tag keys) and cssStorageGlobals (the draft / published global slugs).

Returns

(args: { slug, context, draft, req }) => Promise<string>A processor you invoke from a beforeChange hook; it writes the CSS into the storage global and returns it.

Example

import { createCssProcessor } from '@pro-laico/styles'import { createCssGetCached } from '@pro-laico/styles/cache'const processor = createCssProcessor(createCssGetCached(), {cssCacheTagBySlug: { header: 'header', footer: 'footer', designSet: 'designSet', shortcutSet: 'shortcutSet' },cssStorageGlobals: { draft: 'draftStorage', published: 'publishedStorage' },})// inside a beforeChange hook:await processor({ slug, context, draft: true, req })

Location

@pro-laico/styles
CssProcessorOptionstype
The options type for createCssProcessor (cssCacheTagBySlug, cssStorageGlobals).

Location

@pro-laico/styles
CssProcessorGetCachedtype
The (tag: string, draft: boolean) => Promise<unknown> getter type the processor calls.

Location

@pro-laico/styles
createCssHookfunction
Builds the standalone beforeChange hook that collects *ClassName values, runs processDesignSet for the design set, and regenerates the stylesheet without @pro-laico/atomic. It no-ops when the all-in-one atomicHook already ran for the request.

Parameters

getCached:CssProcessorGetCachedUsually createCssGetCached().
options?:CssHookOptionsOverride designSetSlug, cssCacheTagBySlug, cssStorageGlobals, or cssProcessorSkipSlugs.

Returns

CollectionBeforeChangeHookAttach it to any collection whose saves should regenerate CSS.

Example

import { createCssHook } from '@pro-laico/styles'import { createCssGetCached } from '@pro-laico/styles/cache'const cssHook = createCssHook(createCssGetCached())// in a collection config, so saving a page regenerates the stylesheet:export const Pages = {slug: 'pages',hooks: { beforeChange: [cssHook] },fields: [/* ...blocks with ClassNameField... */],}

Location

@pro-laico/styles
CssHookOptionstype
The options type for createCssHook (the slugs and storage globals it reads and writes).

Location

@pro-laico/styles
processDesignSetfunction
Compiles a design-set document (tokens, colors, prose, preflights) into the *Storage fields the CSS processor reads. The cssHook / atomicHook call it for you before generating CSS.

Parameters

data:DesignSet documentThe in-flight design-set data from a beforeChange hook.

Returns

voidMutates data in place, adding the compiled storage fields.

Example

import { processDesignSet } from '@pro-laico/styles'// inside the designSet collection's beforeChange:const beforeChange = [({ data }) => {  processDesignSet(data)  return data},]

Location

@pro-laico/styles

Cache getters

Export

Type

getCachedDesignSetfunction
Cached read of the active design set. Wrapped in unstable_cache and revalidated when the design set is saved, so the page reads it once instead of re-querying Payload on every render.

Parameters

draft:booleanRead the draft (true) or published (false) variant.

Returns

Promise<DesignSet | undefined>The active design-set document.

Example

import { getCachedDesignSet } from '@pro-laico/styles/cache'// app/(frontend)/layout.tsxexport default async function RootLayout({ children }) {const ds = await getCachedDesignSet(false)return <html className={ds?.htmlClassName ?? undefined}>{children}</html>}

Location

@pro-laico/styles/cache
getCachedShortcutSetfunction
Cached read of the active shortcut set.

Parameters

draft:booleanDraft or published variant.

Returns

Promise<ShortcutSet | undefined>The active shortcut-set document.

Example

import { getCachedShortcutSet } from '@pro-laico/styles/cache'const shortcuts = await getCachedShortcutSet(false)

Location

@pro-laico/styles/cache
getCachedSiteCSSfunction
Cached read of the generated stylesheet from the draft / published CSS storage global. This is what you inject into <head>.

Parameters

draft:booleanDraft or published variant.

Returns

Promise<string>The generated CSS (empty string until the first save generates it).

Example

import { getCachedSiteCSS } from '@pro-laico/styles/cache'// app/(frontend)/layout.tsx <head>const css = await getCachedSiteCSS(false)return <style dangerouslySetInnerHTML={{ __html: css }} />

Location

@pro-laico/styles/cache
getCachedAtomicClassesfunction
Cached read of every stored atomic class across page-like docs. The CSS processor uses it as the safelist so authored classes survive UnoCSS generation. createGetCachedAtomicClasses(slug) binds it to a non-default pages collection.

Parameters

draft:booleanDraft or published variant.

Returns

Promise<string[]>Every *ClassName value stored across your pages.

Example

import { getCachedAtomicClasses, createGetCachedAtomicClasses } from '@pro-laico/styles/cache'const classes = await getCachedAtomicClasses(false)// or bind a non-default pages slug:const getArticleClasses = createGetCachedAtomicClasses('articles')

Location

@pro-laico/styles/cache
createCssGetCachedfunction
Builds the (tag, draft) getter the CSS processor calls. It resolves this package's own designSet / shortcutSet / atomic-classes directly, and delegates header / footer to the injected getters (so a header-less app needs neither). Pass its result as the plugin's getCached option.

Parameters

deps?:{ getHeader?, getFooter?, cssCacheTagBySlug? }Inject getHeader / getFooter (from @pro-laico/site/cache) when your app has them; override cssCacheTagBySlug for non-default slugs.

Returns

CssProcessorGetCachedPass it to stylesPlugin({ getCached }) or createCssHook(getCached).

Example

import { createCssGetCached } from '@pro-laico/styles/cache'import { getCachedFooter, getCachedHeader } from '@pro-laico/site/cache'// standalone (no header / footer collections):const getCached = createCssGetCached()// with a site that has header + footer:const withChrome = createCssGetCached({ getHeader: getCachedHeader, getFooter: getCachedFooter })

Location

@pro-laico/styles/cache

Types

Export

Type

DesignSettype
TypeScript type for typed access to your design-set documents.

Location

@pro-laico/styles/schema
ShortcutSettype
TypeScript type for typed access to your shortcut-set documents.

Location

@pro-laico/styles/schema
CollectionThatUsesCSSProcessorSlugtype
The slug type for collections the CSS processor reads.

Location

@pro-laico/styles/schema
CollectionWithStoredAtomicClassesSlugtype
The slug type for collections that store atomic classes.

Location

@pro-laico/styles/schema
CollectionThatUsesCSSProcessortype
The document type for a collection the CSS processor reads (for typing a custom processor or hook).

Location

@pro-laico/styles

On this page