Updating Custom Branding blog cover-pic

Updating Custom Branding to Tailwind v4 at dFlow

Avatar
Pavan Bhaskar
28 Aug, 2025
dflow

We recently updated dFlow Custom Branding logic to Tailwind V4. Here’s a detailed guide of the steps we took to make Tailwind V4 work seamlessly with Custom Branding.

How Custom Branding works

Here's a demo of how customize color-palette, font-family from admin-panel.



Implementation

  • We've created a payload-global theme to store shadcn color-palette, font-family.
1// src/payload/globals/theme.ts
2import type { GlobalConfig, Field } from 'payload'
3import { z } from 'zod'
4
5const validateURL = z
6 .string({
7 required_error: 'URL is required',
8 })
9 .url({
10 message: 'Please enter a valid URL',
11 })
12
13const fontValidation = (
14 value: string | string[] | null | undefined,
15): true | string => {
16 // Ensure value is a string, as it can also be an array or null/undefined
17 if (typeof value === 'string') {
18 const { success } = validateURL.safeParse(value)
19 return success || 'Google Font URL is invalid'
20 }
21 return 'Google Font URL is invalid'
22}
23
24const fontConfig = ({
25 remoteFont,
26 fontName,
27}: {
28 remoteFont: string
29 fontName: string
30}): Field[] => [
31 {
32 name: 'customFont',
33 label: 'Custom Font',
34 type: 'upload',
35 relationTo: 'media',
36 admin: {
37 width: '50%',
38 condition: (_data, siblingData) => {
39 return siblingData.type === 'customFont'
40 },
41 },
42 },
43 {
44 name: 'remoteFont',
45 type: 'text',
46 required: true,
47 label: 'Google Font URL',
48 admin: {
49 width: '50%',
50 condition: (_data, siblingData) => {
51 return siblingData.type === 'googleFont'
52 },
53 },
54 defaultValue: remoteFont,
55 validate: fontValidation,
56 },
57 {
58 name: 'fontName',
59 type: 'text',
60 required: true,
61 label: 'Font Name',
62 admin: {
63 width: '50%',
64 condition: (_data, siblingData) => {
65 return siblingData.type === 'googleFont'
66 },
67 },
68 defaultValue: fontName,
69 },
70]
71
72export const Theme: GlobalConfig = {
73 slug: 'theme',
74 fields: [
75 {
76 type: 'row',
77 fields: [
78 {
79 type: 'group',
80 name: 'lightMode',
81 fields: [
82 // background
83 {
84 type: 'text',
85 name: 'background',
86 admin: {
87 components: {
88 Field: '@/payload/fields/theme/ColorField',
89 },
90 },
91 required: true,
92 defaultValue: 'oklch(0.973 0.0133 286.1503)',
93 },
94 // foreground
95 {
96 type: 'text',
97 name: 'foreground',
98 admin: {
99 components: {
100 Field: '@/payload/fields/theme/ColorField',
101 },
102 },
103 required: true,
104 defaultValue: 'oklch(0.3015 0.0572 282.4176)',
105 },
106 // ...add rest colors
107 ],
108 },
109 {
110 type: 'group',
111 name: 'darkMode',
112 fields: [
113 {
114 type: 'text',
115 name: 'background',
116 admin: {
117 components: { Field: '@/payload/fields/theme/ColorField' },
118 },
119 required: true,
120 defaultValue: 'oklch(0.2069 0.0403 263.9914)',
121 },
122 // foreground
123 {
124 type: 'text',
125 name: 'foreground',
126 admin: {
127 components: { Field: '@/payload/fields/theme/ColorField' },
128 },
129 required: true,
130 defaultValue: 'oklch(0.9309 0.0269 285.8648)',
131 },
132 ],
133 },
134 ],
135 },
136 // Fonts
137 {
138 type: 'group',
139 name: 'fonts',
140 fields: [
141 {
142 type: 'group',
143 name: 'display',
144 label: 'Display Font',
145 fields: [
146 {
147 name: 'type',
148 type: 'radio',
149 required: true,
150 options: [
151 {
152 label: 'Custom Font',
153 value: 'customFont',
154 },
155 {
156 label: 'Google Font',
157 value: 'googleFont',
158 },
159 ],
160 defaultValue: 'googleFont',
161 },
162 {
163 type: 'row',
164 fields: fontConfig({
165 remoteFont:
166 'https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap',
167 fontName: 'Geist',
168 }),
169 },
170 ],
171 }
172 ],
173 },
174 // Radius
175 {
176 admin: {
177 components: {
178 Field: '@/payload/fields/theme/RadiusField',
179 },
180 },
181 type: 'select',
182 name: 'radius',
183 options: [
184 {
185 label: 'None',
186 value: 'none',
187 },
188 {
189 label: 'Small',
190 value: 'small',
191 },
192 {
193 label: 'Medium',
194 value: 'medium',
195 },
196 {
197 label: 'Large',
198 value: 'large',
199 },
200 {
201 label: 'Full',
202 value: 'full',
203 },
204 ],
205 required: true,
206 defaultValue: 'medium',
207 },
208 ],
209}
  • We've created custom UI components which makes customization easier.
1// src/payload/fields/theme/ColorField.tsx
2'use client'
3
4import { useField, useForm, useFormFields } from '@payloadcms/ui'
5import { TextFieldClientProps } from 'payload'
6
7
8function parseCssVars(css: string): Record<string, string> {
9 const result: Record<string, string> = {}
10
11 // Match all lines like --key: value;
12 const matches = css.match(/--[\w-]+:\s*[^;]+;/g)
13 if (!matches) return result
14
15 for (const match of matches) {
16 const [key, ...rest] = match.split(':')
17 let value = rest.join(':').replace(/;/g, '').trim()
18
19 // Remove leading '--' and convert to camelCase
20 const cleanedKey = key.trim().replace(/^--/, '')
21 const camelKey = cleanedKey.replace(/-([a-z0-9])/gi, (_, char) =>
22 char.toUpperCase(),
23 )
24
25 // ✅ Normalize different color formats
26 if (/^hsl\(/i.test(value)) {
27 // already valid hsl
28 result[camelKey] = value
29 } else if (/^rgb\(/i.test(value)) {
30 result[camelKey] = value
31 } else if (/^oklch\(/i.test(value)) {
32 result[camelKey] = value
33 } else if (/^oklab\(/i.test(value)) {
34 result[camelKey] = value
35 } else if (/^#[0-9a-f]{3,8}$/i.test(value)) {
36 // hex → keep as hex
37 result[camelKey] = value
38 } else {
39 // fallback: wrap raw string
40 result[camelKey] = value
41 }
42 }
43
44 return result
45}
46
47const ColorField = ({ ...props }: TextFieldClientProps) => {
48 const { value = '', setValue } = useField<string>({ path: props.path })
49 const { fields, dispatch } = useFormFields(([fields, dispatch]) => ({
50 fields,
51 dispatch,
52 }))
53 const { setModified } = useForm()
54
55 const label = typeof props.field.label === 'string' ? props.field.label : ''
56
57 return (
58 <>
59 <label htmlFor={props.path}>{label}</label>
60
61 <div className='color-field-container'>
62 <div
63 className='color-field-preview'
64 style={{ backgroundColor: value }}
65 />
66
67 <input
68 type='text'
69 id={props.path}
70 value={value}
71 onChange={e => {
72 const newValue = e.target.value
73
74 if (newValue.includes(':root') || newValue.includes('--')) {
75 const lightTheme = parseCssVars(newValue.split('.dark')[0])
76 const darkTheme = parseCssVars(newValue.split('.dark')[1] || '')
77
78 // update lightTheme
79 for (const [variableName, value] of Object.entries(lightTheme)) {
80 if (fields[`lightMode.${variableName}`]) {
81 dispatch({
82 type: 'UPDATE',
83 path: `lightMode.${variableName}`,
84 value,
85 valid: true,
86 })
87 }
88 }
89
90 // update darkTheme
91 for (const [variableName, value] of Object.entries(darkTheme)) {
92 if (fields[`darkMode.${variableName}`]) {
93 dispatch({
94 type: 'UPDATE',
95 path: `darkMode.${variableName}`,
96 value,
97 valid: true,
98 })
99 }
100 }
101
102 setModified(true)
103 } else {
104 // todo: parse value to hsl
105 setValue(newValue)
106 }
107 }}
108 />
109 </div>
110 </>
111 )
112}
113
114export default ColorField
  • Radius field is used to control default border-radius.
1// src/payload/fields/theme/RadiusField.tsx
2'use client'
3
4import { useField } from '@payloadcms/ui'
5import { SelectFieldClientProps } from 'payload'
6
7const RadiusField = ({ ...props }: SelectFieldClientProps) => {
8 const { value = '', setValue } = useField<string>({ path: props.path })
9 const label = typeof props.field.label === 'string' ? props.field.label : ''
10 const options = props.field.options
11
12 return (
13 <>
14 <label>{label}</label>
15
16 <div className='radius-field-container'>
17 {options.map((field, index) => {
18 const option =
19 typeof field === 'object'
20 ? { label: field.label, value: field.value }
21 : { label: field, value: field }
22
23 return (
24 <label
25 data-option-active={value === option.value}
26 className='radius-field-label'
27 key={index}>
28 <input
29 type='radio'
30 name='radius'
31 value={option.value}
32 onChange={e => {
33 setValue(e.target.value)
34 }}
35 />
36 <div
37 className='radius-option'
38 style={{ borderTopLeftRadius: `var(--radius-${option.value})` }}
39 />
40
41 <span className='radius-option-name'>{option.value}</span>
42 </label>
43 )
44 })}
45 </div>
46 </>
47 )
48}
49
50export default RadiusField
  • Add this in custom.scss for styling UI-components in payload-admin panel.
1// src/app/payload/custom.scss
2$radius-none: 0rem;
3$radius-small: 0.25rem;
4$radius-medium: 0.375rem;
5$radius-large: 0.5rem;
6$radius-full: 999rem;
7
8:root {
9 --radius-none: #{$radius-none};
10 --radius-small: #{$radius-small};
11 --radius-medium: #{$radius-medium};
12 --radius-large: #{$radius-large};
13 --radius-full: #{$radius-full};
14}
15
16// Color field styles
17.color-field-container {
18 display: flex;
19 gap: 0.5rem;
20 margin-top: 0.25rem;
21 margin-bottom: 1rem;
22
23 & .color-field-preview {
24 width: 2rem;
25 height: 2rem;
26 border-radius: 0.25rem;
27 }
28}
29
30// Radius field styles
31.radius-field-container {
32 display: flex;
33 justify-content: space-between;
34 max-width: 30rem;
35 margin-top: 0.5rem;
36
37 & .radius-field-label[data-option-active='true'] {
38 border-color: #8b5cf6;
39 }
40}
41
42.radius-field-label {
43 position: relative;
44 border: 1px solid #334154;
45 border-radius: 0.25rem;
46 cursor: pointer;
47
48 & .radius-option-name {
49 position: absolute;
50 bottom: -2rem;
51 }
52}
53
54.radius-field-label input {
55 position: absolute;
56 appearance: none;
57 inset: 0;
58 height: 100%;
59 width: 100%;
60}
61
62.radius-field-label .radius-option {
63 height: 32px;
64 width: 32px;
65 background: rgba(139, 92, 246, 0.3137254902);
66 border-left: 2px solid #8b5cf6;
67 border-top: 2px solid #8b5cf6;
68 margin: 1rem;
69 position: relative;
70}
  • Fetching the theme global in nextjs root-layout.
1// src/app/layout.tsx
2import configPromise from '@payload-config'
3import { getPayload } from 'payload'
4import Branding from '@/components/Branding'
5
6export default async function RootLayout({
7 children,
8}: {
9 children: React.ReactNode
10}) {
11 const payload = await getPayload({
12 config: configPromise,
13 })
14
15 const theme = await payload.findGlobal({
16 slug: 'theme',
17 })
18
19 return (
20 <html>
21 <head>
22 {theme && <Branding theme={theme} />}
23 </head>
24
25 <body>
26 {children}
27 </body>
28 </html>
29 )
30}
  • Rendering <Branding> component which appends <style> tag in <head>, this overrides the css-variables from the global.css file.
1// src/componentes/Branding.tsx
2import { env } from 'env'
3import { Fragment } from 'react'
4
5import { fontType, getCSSAndLinkGoogleFonts, mimeTypes } from '@/lib/googleFont'
6import type { Theme as ThemeType } from '@/payload-types'
7
8type ThemeStylesType = {
9 colors: ThemeType['lightMode']
10 fontName: {
11 display: string
12 body: string
13 }
14 radius: ThemeType['radius']
15}
16
17const borderRadius = {
18 none: `0rem`,
19 small: `0.25rem`,
20 medium: `0.375rem`,
21 large: `0.5rem`,
22 full: `999rem`,
23} as const
24
25// All the color variables are generated using generateThemeStyles function for light & dark mode
26function generateThemeVariables({ colors, radius, fontName }: ThemeStylesType) {
27 return `
28 --primary: ${colors.primary};
29 --primary-foreground: ${colors.primaryForeground};
30 --secondary: ${colors.secondary};
31 --secondary-foreground: ${colors.secondaryForeground};
32 --accent: ${colors.accent};
33 --accent-foreground: ${colors.accentForeground};
34 --background: ${colors.background};
35 --foreground: ${colors.foreground};
36 --card: ${colors.card};
37 --card-foreground: ${colors.cardForeground};
38 --popover: ${colors.popover};
39 --popover-foreground: ${colors.popoverForeground};
40 --muted: ${colors.muted};
41 --muted-foreground: ${colors.mutedForeground};
42 --destructive: ${colors.destructive};
43 --border: ${colors.border};
44 --input: ${colors.input};
45 --ring: ${colors.ring};
46 --chart-1: ${colors.chart1};
47 --chart-2: ${colors.chart2};
48 --chart-3: ${colors.chart3};
49 --chart-4: ${colors.chart4};
50 --chart-5: ${colors.chart5};
51 --sidebar: ${colors.sidebar};
52 --sidebar-foreground: ${colors.sidebarForeground};
53 --sidebar-primary: ${colors.sidebarPrimary};
54 --sidebar-primary-foreground: ${colors.sidebarPrimaryForeground};
55 --sidebar-accent: ${colors.sidebarAccent};
56 --sidebar-accent-foreground: ${colors.sidebarAccentForeground};
57 --sidebar-border: ${colors.sidebarBorder};
58 --sidebar-ring: ${colors.sidebarRing};
59 --font-display: ${fontName.display || ''};
60 --font-body: ${fontName.body || ''};
61 --border-radius: ${borderRadius[radius]};
62 `
63}
64
65const Branding = async ({ theme }: { theme: ThemeType }) => {
66 const { lightMode, darkMode, radius, fonts } = theme
67
68 const displayFont =
69 fonts.display.type === 'customFont'
70 ? typeof fonts.display.customFont === 'object'
71 ? {
72 url: fonts.display.customFont?.url ?? '',
73 format:
74 mimeTypes[
75 ((fonts.display?.customFont?.url ?? '').split('.')?.[1] ??
76 'otf') as keyof typeof mimeTypes
77 ],
78 fontName: 'Display',
79 }
80 : undefined
81 : {
82 googleFontURL: fonts.display.remoteFont ?? '',
83 fontName: fonts.display.fontName ?? '',
84 }
85
86 const bodyFont =
87 fonts.body.type === 'customFont'
88 ? typeof fonts.body.customFont === 'object'
89 ? {
90 url: fonts.body.customFont?.url ?? '',
91 format:
92 mimeTypes[
93 ((fonts.body?.customFont?.url ?? '').split('.')?.[1] ??
94 'otf') as keyof typeof mimeTypes
95 ],
96 fontName: 'Body',
97 }
98 : undefined
99 : {
100 googleFontURL: fonts.body.remoteFont ?? '',
101 fontName: fonts.body.fontName ?? '',
102 }
103
104 const googleFontsList = [
105 displayFont?.googleFontURL ?? '',
106 bodyFont?.googleFontURL ?? '',
107 ].filter(url => Boolean(url))
108
109 const response = await getCSSAndLinkGoogleFonts({
110 fontUrlList: googleFontsList,
111 })
112
113 const lightModeVariables = generateThemeVariables({
114 colors: lightMode,
115 fontName: {
116 display: displayFont?.fontName ?? '',
117 body: bodyFont?.fontName ?? '',
118 },
119 radius,
120 })
121
122 const darkModeVariables = generateThemeVariables({
123 colors: darkMode,
124 fontName: {
125 display: displayFont?.fontName ?? '',
126 body: bodyFont?.fontName ?? '',
127 },
128 radius,
129 })
130
131 return (
132 <>
133 {displayFont?.url && (
134 <link
135 rel='preload'
136 href={`${env.NEXT_PUBLIC_WEBSITE_URL}${displayFont.url}`}
137 as='font'
138 type={displayFont.format}
139 crossOrigin='anonymous'
140 />
141 )}
142
143 {bodyFont?.url && (
144 <link
145 rel='preload'
146 href={`${env.NEXT_PUBLIC_WEBSITE_URL}${bodyFont.url}`}
147 as='font'
148 type={bodyFont.format}
149 crossOrigin='anonymous'
150 />
151 )}
152
153 {/* If user uploads custom font setting styles of that font */}
154 <style
155 dangerouslySetInnerHTML={{
156 __html: `${
157 displayFont?.url
158 ? `@font-face {
159 font-family: 'Display';
160 src: url(${env.NEXT_PUBLIC_WEBSITE_URL}${displayFont.url}) format(${fontType[displayFont.format]});
161 font-weight: normal;
162 font-style: normal;
163 font-display: swap;
164 }`
165 : ''
166 }\n
167 ${
168 bodyFont?.url
169 ? `@font-face {
170 font-family: 'Body';
171 src: url(${env.NEXT_PUBLIC_WEBSITE_URL}${bodyFont.url}) format(${fontType[bodyFont.format]});
172 font-weight: normal;
173 font-style: normal;
174 font-display: swap;
175 }`
176 : ''
177 }`,
178 }}
179 />
180
181 {/* Link & Style tags are created from googleFonts response */}
182 {response.map(({ cssText, preloadLinks }, index) => (
183 <Fragment key={index}>
184 {preloadLinks.map(({ href, type }) =>
185 href ? (
186 <link
187 rel='preload'
188 as='font'
189 crossOrigin='anonymous'
190 href={href}
191 type={type}
192 key={href}
193 />
194 ) : null,
195 )}
196 <style dangerouslySetInnerHTML={{ __html: cssText }} />
197 </Fragment>
198 ))}
199
200 <style
201 dangerouslySetInnerHTML={{
202 __html: `
203 :root {
204 ${lightModeVariables}
205 }
206 \n
207 .dark {
208 ${darkModeVariables}
209 }
210 `,
211 }}
212 />
213 </>
214 )
215}
216
217export default Branding
  • Pre-fetches the font-url on server-side, so there won't be layout-shifts on client-side
1// src/lib/googleFont.ts
2export const mimeTypes = {
3 woff: 'font/woff',
4 woff2: 'font/woff2',
5 ttf: 'font/ttf',
6 otf: 'font/otf',
7 eot: 'application/vnd.ms-fontobject',
8 svg: 'image/svg+xml',
9} as const
10
11export const fontType = {
12 'font/otf': 'opentype',
13 'font/ttf': 'truetype',
14 'font/woff': 'woff',
15 'font/woff2': 'woff2',
16 'application/vnd.ms-fontobject': 'embedded-opentype',
17 'image/svg+xml': 'svg',
18} as const
19
20export type FontPreloadAttributes = {
21 href: string
22 type: keyof typeof mimeTypes | string
23}
24
25export type ExtractedListType = {
26 preloadLinks: FontPreloadAttributes[]
27 cssText: string
28}
29
30export const getFontMimeType = (url: string) => {
31 const ext = url.split('.').pop() as keyof typeof mimeTypes // Get the file extension
32 return mimeTypes[ext] || 'font/woff2'
33}
34
35export const fetchGoogleFonts = async (fontUrl: string) => {
36 try {
37 const response = await fetch(fontUrl)
38 const cssText = await response.text()
39
40 // Extract font URLs
41 const fontUrls = Array.from(cssText.matchAll(/url\(([^)]+)\)/g)).map(
42 match => match[1].replace(/['"]+/g, ''), // Clean up the URL
43 )
44
45 // Generate preload links as attribute objects
46 const preloadLinks = fontUrls.map(url => ({
47 href: url,
48 type: getFontMimeType(url),
49 }))
50
51 return { preloadLinks, cssText }
52 } catch (error) {
53 console.error('Failed to load Google Fonts:', error)
54 return {
55 preloadLinks: [
56 {
57 href: '',
58 type: '',
59 },
60 ],
61 cssText: '',
62 }
63 }
64}
65
66export const getCSSAndLinkGoogleFonts = async ({
67 fontUrlList,
68}: {
69 fontUrlList: string[]
70}) => {
71 const list: ExtractedListType[] = []
72
73 for await (const fontURL of fontUrlList) {
74 const { preloadLinks, cssText } = await fetchGoogleFonts(fontURL)
75 list.push({ preloadLinks, cssText })
76 }
77
78 return list
79}

That's how we're overriding the shadcn-themes dynamically at run-time!

Migration Guide v3-v4

Previously we've implemented custom-branding in Tailwind v3. Here're migration steps we followed from Tailwind V3-V4.

Step 1:

  • Run tailwind upgrade command, pnpm dlx @tailwindcss/upgrade this does heavy lifting!

Step 2:

  • Note: The above command will replace the shadcn outline variant to -> outline-none so we need to look-out for the shadcn-components
  • Re-initialize shadcn again pnpm dlx shadcn@latest init , this suggested to delete components.json & try-again, I've done that it just made the tailwind.config as empty in components.json
1{
2 "$schema": "https://ui.shadcn.com/schema.json",
3 "style": "new-york",
4 "rsc": true,
5 "tsx": true,
6 "tailwind": {
7 "config": "",
8 "css": "src/app/(frontend)/globals.css",
9 "baseColor": "gray",
10 "cssVariables": true,
11 "prefix": ""
12 },
13 "aliases": {
14 "components": "@/components",
15 "utils": "@/lib/utils",
16 "ui": "@/components/ui",
17 "lib": "@/lib",
18 "hooks": "@/hooks"
19 },
20 "iconLibrary": "lucide"
21}

Step 3:

  • Migrated all tailwind plugins to global.css
1/* global.css */
2@import 'tailwindcss';
3@import 'tw-animate-css';
4
5@plugin "@tailwindcss/typography";

References

These are the steps we did for Tailwind v4 migration. Check out these references! let's us know if there're an alternative approaches for implementing custom-branding. Until then peace✌️



dFlow logodFlow

dFlow simplifies cloud deployments with powerful tools for managing servers, services and domains.

© 2025 dFlow. All rights reserved.
Updating Custom Branding to Tailwind v4 at dFlow | dFlow