@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/stylesnpm install @pro-laico/stylesyarn add @pro-laico/stylespayload, @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
getCachedgetter (fromcreateCssGetCached) and the plugin attaches its owncssHook, 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*ClassNamevalue 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
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/stylesStylesPluginOptionstype
Location
@pro-laico/stylesStylesDesignSetOptionstype
Location
@pro-laico/stylesStylesShortcutSetOptionstype
Location
@pro-laico/stylesFields
Export
Type
ClassNameFieldfield
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/classNameCSS processing
Export
Type
createCssProcessorfunction
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/stylesCssProcessorOptionstype
createCssProcessor (cssCacheTagBySlug, cssStorageGlobals).Location
@pro-laico/stylesCssProcessorGetCachedtype
(tag: string, draft: boolean) => Promise<unknown> getter type the processor calls.Location
@pro-laico/stylescreateCssHookfunction
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/stylesCssHookOptionstype
createCssHook (the slugs and storage globals it reads and writes).Location
@pro-laico/stylesprocessDesignSetfunction
*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/stylesCache getters
Export
Type
getCachedDesignSetfunction
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/cachegetCachedShortcutSetfunction
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/cachegetCachedSiteCSSfunction
<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/cachegetCachedAtomicClassesfunction
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/cachecreateCssGetCachedfunction
(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/cacheTypes
Export
Type
DesignSettype
Location
@pro-laico/styles/schemaShortcutSettype
Location
@pro-laico/styles/schemaCollectionThatUsesCSSProcessorSlugtype
Location
@pro-laico/styles/schemaCollectionWithStoredAtomicClassesSlugtype
Location
@pro-laico/styles/schemaCollectionThatUsesCSSProcessortype
Location
@pro-laico/stylesRelated
@pro-laico/core
The shared foundation every Atomic Payload package builds on: end-to-end type safety, tag-based caching and revalidation, JSON-schema type generation, and reusable admin and frontend building blocks.
@pro-laico/icons
Manage your SVG icons in the Payload admin: upload them, group them into reusable sets, and render any icon by name on your site.