
Updating Custom Branding to Tailwind v4 at 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.ts2import type { GlobalConfig, Field } from 'payload'3import { z } from 'zod'45const validateURL = z6 .string({7 required_error: 'URL is required',8 })9 .url({10 message: 'Please enter a valid URL',11 })1213const 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/undefined17 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}2324const fontConfig = ({25 remoteFont,26 fontName,27}: {28 remoteFont: string29 fontName: string30}): 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]7172export const Theme: GlobalConfig = {73 slug: 'theme',74 fields: [75 {76 type: 'row',77 fields: [78 {79 type: 'group',80 name: 'lightMode',81 fields: [82 // background83 {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 // foreground95 {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 colors107 ],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 // foreground123 {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 // Fonts137 {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 // Radius175 {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.tsx2'use client'34import { useField, useForm, useFormFields } from '@payloadcms/ui'5import { TextFieldClientProps } from 'payload'678function parseCssVars(css: string): Record<string, string> {9 const result: Record<string, string> = {}1011 // Match all lines like --key: value;12 const matches = css.match(/--[\w-]+:\s*[^;]+;/g)13 if (!matches) return result1415 for (const match of matches) {16 const [key, ...rest] = match.split(':')17 let value = rest.join(':').replace(/;/g, '').trim()1819 // Remove leading '--' and convert to camelCase20 const cleanedKey = key.trim().replace(/^--/, '')21 const camelKey = cleanedKey.replace(/-([a-z0-9])/gi, (_, char) =>22 char.toUpperCase(),23 )2425 // ✅ Normalize different color formats26 if (/^hsl\(/i.test(value)) {27 // already valid hsl28 result[camelKey] = value29 } else if (/^rgb\(/i.test(value)) {30 result[camelKey] = value31 } else if (/^oklch\(/i.test(value)) {32 result[camelKey] = value33 } else if (/^oklab\(/i.test(value)) {34 result[camelKey] = value35 } else if (/^#[0-9a-f]{3,8}$/i.test(value)) {36 // hex → keep as hex37 result[camelKey] = value38 } else {39 // fallback: wrap raw string40 result[camelKey] = value41 }42 }4344 return result45}4647const 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()5455 const label = typeof props.field.label === 'string' ? props.field.label : ''5657 return (58 <>59 <label htmlFor={props.path}>{label}</label>6061 <div className='color-field-container'>62 <div63 className='color-field-preview'64 style={{ backgroundColor: value }}65 />6667 <input68 type='text'69 id={props.path}70 value={value}71 onChange={e => {72 const newValue = e.target.value7374 if (newValue.includes(':root') || newValue.includes('--')) {75 const lightTheme = parseCssVars(newValue.split('.dark')[0])76 const darkTheme = parseCssVars(newValue.split('.dark')[1] || '')7778 // update lightTheme79 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 }8990 // update darkTheme91 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 }101102 setModified(true)103 } else {104 // todo: parse value to hsl105 setValue(newValue)106 }107 }}108 />109 </div>110 </>111 )112}113114export default ColorField
- Radius field is used to control default border-radius.
1// src/payload/fields/theme/RadiusField.tsx2'use client'34import { useField } from '@payloadcms/ui'5import { SelectFieldClientProps } from 'payload'67const 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.options1112 return (13 <>14 <label>{label}</label>1516 <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 }2223 return (24 <label25 data-option-active={value === option.value}26 className='radius-field-label'27 key={index}>28 <input29 type='radio'30 name='radius'31 value={option.value}32 onChange={e => {33 setValue(e.target.value)34 }}35 />36 <div37 className='radius-option'38 style={{ borderTopLeftRadius: `var(--radius-${option.value})` }}39 />4041 <span className='radius-option-name'>{option.value}</span>42 </label>43 )44 })}45 </div>46 </>47 )48}4950export default RadiusField
- Add this in
custom.scss
for styling UI-components in payload-admin panel.
1// src/app/payload/custom.scss2$radius-none: 0rem;3$radius-small: 0.25rem;4$radius-medium: 0.375rem;5$radius-large: 0.5rem;6$radius-full: 999rem;78: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}1516// Color field styles17.color-field-container {18 display: flex;19 gap: 0.5rem;20 margin-top: 0.25rem;21 margin-bottom: 1rem;2223 & .color-field-preview {24 width: 2rem;25 height: 2rem;26 border-radius: 0.25rem;27 }28}2930// Radius field styles31.radius-field-container {32 display: flex;33 justify-content: space-between;34 max-width: 30rem;35 margin-top: 0.5rem;3637 & .radius-field-label[data-option-active='true'] {38 border-color: #8b5cf6;39 }40}4142.radius-field-label {43 position: relative;44 border: 1px solid #334154;45 border-radius: 0.25rem;46 cursor: pointer;4748 & .radius-option-name {49 position: absolute;50 bottom: -2rem;51 }52}5354.radius-field-label input {55 position: absolute;56 appearance: none;57 inset: 0;58 height: 100%;59 width: 100%;60}6162.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.tsx2import configPromise from '@payload-config'3import { getPayload } from 'payload'4import Branding from '@/components/Branding'56export default async function RootLayout({7 children,8}: {9 children: React.ReactNode10}) {11 const payload = await getPayload({12 config: configPromise,13 })1415 const theme = await payload.findGlobal({16 slug: 'theme',17 })1819 return (20 <html>21 <head>22 {theme && <Branding theme={theme} />}23 </head>2425 <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.tsx2import { env } from 'env'3import { Fragment } from 'react'45import { fontType, getCSSAndLinkGoogleFonts, mimeTypes } from '@/lib/googleFont'6import type { Theme as ThemeType } from '@/payload-types'78type ThemeStylesType = {9 colors: ThemeType['lightMode']10 fontName: {11 display: string12 body: string13 }14 radius: ThemeType['radius']15}1617const borderRadius = {18 none: `0rem`,19 small: `0.25rem`,20 medium: `0.375rem`,21 large: `0.5rem`,22 full: `999rem`,23} as const2425// All the color variables are generated using generateThemeStyles function for light & dark mode26function 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}6465const Branding = async ({ theme }: { theme: ThemeType }) => {66 const { lightMode, darkMode, radius, fonts } = theme6768 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 mimeTypes77 ],78 fontName: 'Display',79 }80 : undefined81 : {82 googleFontURL: fonts.display.remoteFont ?? '',83 fontName: fonts.display.fontName ?? '',84 }8586 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 mimeTypes95 ],96 fontName: 'Body',97 }98 : undefined99 : {100 googleFontURL: fonts.body.remoteFont ?? '',101 fontName: fonts.body.fontName ?? '',102 }103104 const googleFontsList = [105 displayFont?.googleFontURL ?? '',106 bodyFont?.googleFontURL ?? '',107 ].filter(url => Boolean(url))108109 const response = await getCSSAndLinkGoogleFonts({110 fontUrlList: googleFontsList,111 })112113 const lightModeVariables = generateThemeVariables({114 colors: lightMode,115 fontName: {116 display: displayFont?.fontName ?? '',117 body: bodyFont?.fontName ?? '',118 },119 radius,120 })121122 const darkModeVariables = generateThemeVariables({123 colors: darkMode,124 fontName: {125 display: displayFont?.fontName ?? '',126 body: bodyFont?.fontName ?? '',127 },128 radius,129 })130131 return (132 <>133 {displayFont?.url && (134 <link135 rel='preload'136 href={`${env.NEXT_PUBLIC_WEBSITE_URL}${displayFont.url}`}137 as='font'138 type={displayFont.format}139 crossOrigin='anonymous'140 />141 )}142143 {bodyFont?.url && (144 <link145 rel='preload'146 href={`${env.NEXT_PUBLIC_WEBSITE_URL}${bodyFont.url}`}147 as='font'148 type={bodyFont.format}149 crossOrigin='anonymous'150 />151 )}152153 {/* If user uploads custom font setting styles of that font */}154 <style155 dangerouslySetInnerHTML={{156 __html: `${157 displayFont?.url158 ? `@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 }\n167 ${168 bodyFont?.url169 ? `@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 />180181 {/* 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 <link187 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 ))}199200 <style201 dangerouslySetInnerHTML={{202 __html: `203 :root {204 ${lightModeVariables}205 }206 \n207 .dark {208 ${darkModeVariables}209 }210 `,211 }}212 />213 </>214 )215}216217export default Branding
- Pre-fetches the font-url on server-side, so there won't be layout-shifts on client-side
1// src/lib/googleFont.ts2export 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 const1011export 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 const1920export type FontPreloadAttributes = {21 href: string22 type: keyof typeof mimeTypes | string23}2425export type ExtractedListType = {26 preloadLinks: FontPreloadAttributes[]27 cssText: string28}2930export const getFontMimeType = (url: string) => {31 const ext = url.split('.').pop() as keyof typeof mimeTypes // Get the file extension32 return mimeTypes[ext] || 'font/woff2'33}3435export const fetchGoogleFonts = async (fontUrl: string) => {36 try {37 const response = await fetch(fontUrl)38 const cssText = await response.text()3940 // Extract font URLs41 const fontUrls = Array.from(cssText.matchAll(/url\(([^)]+)\)/g)).map(42 match => match[1].replace(/['"]+/g, ''), // Clean up the URL43 )4445 // Generate preload links as attribute objects46 const preloadLinks = fontUrls.map(url => ({47 href: url,48 type: getFontMimeType(url),49 }))5051 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}6566export const getCSSAndLinkGoogleFonts = async ({67 fontUrlList,68}: {69 fontUrlList: string[]70}) => {71 const list: ExtractedListType[] = []7273 for await (const fontURL of fontUrlList) {74 const { preloadLinks, cssText } = await fetchGoogleFonts(fontURL)75 list.push({ preloadLinks, cssText })76 }7778 return list79}
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';45@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✌️