@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/stylesInstalled 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*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.
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.