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

Installed for you: @pro-laico/core and server-only. Provide yourself (peers): payload, react, and @payloadcms/ui come with your app; also install unocss (the CSS engine these styles are generated with) and the @pro-laico/zap plugin.

How the CSS reaches your app

The plugin registers the designSet (theme tokens) and shortcutSet (reusable class groupings) collections, plus two hidden CSS storage globals. Saving a set, or any document carrying authored classes, runs a beforeChange hook that regenerates the stylesheet with UnoCSS into those globals; your frontend reads the stored CSS into <head> and the active design set's <html> / <body> class names, so switching the active set swaps the whole look with no rebuild. The Setup tabs below show the two ways to wire the hook: standalone via getCached, or through the template's atomicHook.

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

Pass a getCached getter (built with createCssGetCached) so the plugin attaches its standalone CSS hook and generates the stylesheet itself. generateLivePreviewPath is optional: omit it to use @pro-laico/core's default, or pass your own.

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.

The CSS hook collects every field whose name ends in ClassName (recursively, including fields nested in blocks). That's how ClassNameField works, 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. Avoid the suffix on fields you don't want processed.

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.

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,
})

Environment variables

@pro-laico/styles reads no environment variables of its own. Live preview inherits PREVIEW_SECRET and NEXT_PUBLIC_SERVER_URL through the default generateLivePreviewPath from @pro-laico/core.

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.

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