If you’ve used Payload CMS 2.x or 1.x, you probably used the payload-better-fields-plugin library for color input. However, Payload CMS 3.0 introduced breaking changes to custom fields in the admin panel, rendering inputs from this library incompatible with the new version. While this issue may be addressed in future updates, for now, we will build a color input field from scratch.
If you are only interested in the code, check out the sections: field in Payload, custom field, frontend implementation, GitHub.
Creating reusable field
The first step is to create a reusable field. This will be based on a text field that incorporates a custom component for the admin panel.
0123456789101112131415161718
import type { TextField } from 'payload';
export const COLOR = (overrides?: Omit<TextField, 'type'>): TextField => {
const { name = 'color', label = 'Color', admin, ...rest } = overrides ?? {};
return {
type: 'text',
name,
label,
admin: {
...admin,
components: {
Field: '@/payload/components/ColorPicker/',
},
},
...rest,
} as TextField;
};
0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
import { Field, NumberFieldSingleValidation } from 'payload';
import { COLOR } from '@/payload/fields/color';
import { SectionLayout } from '@/payload-types';
const minValueValidator: NumberFieldSingleValidation = (value: number | null | undefined) =>
Number(value) >= 0 ? true : 'Value must be equal or greater than 0';
const rewriteFieldDefaults = (
fields: Field[],
overrideDefaults?: Partial<SectionLayout>,
): Field[] => {
if (!overrideDefaults) {
return fields;
}
return fields.map((field) => {
if (!('name' in field)) {
return field;
}
const defaultValueFromOverrides = overrideDefaults[field.name as keyof SectionLayout];
if (defaultValueFromOverrides !== undefined) {
if (field.type !== 'ui') {
field.defaultValue = defaultValueFromOverrides;
}
}
return field;
});
};
export const SECTION_LAYOUT = (overrideDefaults?: Partial<SectionLayout>): Field => {
const fields: Field[] = [
{
type: 'checkbox',
name: 'hideSection',
label: 'Hide section',
defaultValue: false,
},
COLOR({
name: 'backgroundColor',
label: 'Background Color',
defaultValue: '#FFFFFF',
}),
{
type: 'number',
name: 'paddingTop',
label: 'Padding top, px',
defaultValue: 24,
required: true,
validate: minValueValidator,
},
{
type: 'number',
name: 'paddingBottom',
label: 'Padding Bottom, px',
defaultValue: 24,
required: true,
validate: minValueValidator,
},
{
type: 'array',
name: 'breakpoints',
label: 'Breakpoints',
fields: [
{
type: 'number',
name: 'minWidth',
label: 'Min width, px',
required: true,
validate: minValueValidator,
},
{
type: 'number',
name: 'paddingTop',
label: 'Padding top, px',
defaultValue: 24,
required: true,
validate: minValueValidator,
},
{
type: 'number',
name: 'paddingBottom',
label: 'Padding Bottom, px',
defaultValue: 24,
required: true,
validate: minValueValidator,
},
],
defaultValue: [
{
minWidth: 767,
paddingTop: 48,
paddingBottom: 48,
},
],
},
];
return {
type: 'group',
name: 'sectionLayout',
label: 'Section layout',
interfaceName: 'sectionLayout',
fields: rewriteFieldDefaults(fields, overrideDefaults),
};
};
We define a COLOR
function, which builds on TextField
by enforcing the type
as "text"
. This ensures consistency. The function accepts optional parameters, allowing you to customize settings like name
, label
, required
, validate
, defaultValue
, etc. If no parameters are provided, it defaults to:
- name: "color"
- label: "Color"
Also, for the admin panel, custom component is used which is described below. In the previous article we did the same for inline rich text.
The second tab shows the use of the COLOR
. The custom field sectionLayout
described in this article is used as an example. In this way, we will add the ability to set the background color for all sections.
Building a custom field component
The next step is to create the custom component located at @/payload/components/ColorPicker/
. This component combines two inputs:
- A color input (HTML
<input type="color">
) - A text input (Payload CMS-provided
TextInput
component)
This setup allows users to set a color either by typing the value or selecting it from a color palet
012345678910111213141516171819202122232425262728293031323334
'use client';
import { useField, TextInput } from '@payloadcms/ui';
import './styles.css';
const ColorPicker = ({
field: { label, required = false },
path,
}: {
field: { label: string; required?: boolean };
path: string;
}) => {
const { value, setValue } = useField<string>({ path });
return (
<div className={'color-picker'}>
<label className={'field-label'}>
{label} {required && <span className="required">*</span>}
</label>
<div className={'color-picker-row'}>
<input type="color" value={value} onChange={(e) => setValue(e.target.value)} />
<TextInput
label=""
path={path}
onChange={(e: { target: { value: string } }) => setValue(e.target.value)}
value={value}
/>
</div>
</div>
);
};
export default ColorPicker;
0123456789101112131415161718
.color-picker .color-picker-row {
margin-top: 8px;
margin-bottom: 8px;
display: flex;
gap: 16px;
}
.color-picker .color-picker-row .field-type {
width: 100%;
}
.color-picker .color-picker-row input[type="color"] {
width: 40px;
height: 40px;
}
.color-picker .color-picker-row input[type="text"] {
width: 100%;
}
The following props are passed automatically to the component:
path
: the path to the customization (e.g., "layout.0.sectionLayout.backgroundColor"). It is used to get the current value and save its value after changes.field
: all field settings that were specified infields/color.ts
. In our case, we uselabel
andrequired
.
The useField
hook provided by Payload CMS synchronizes the value between these inputs. Any changes made to either input are written back to the CMS seamlessly. The field’s label appears above the grid for a more user-friendly interface.
For a minimal and clean appearance, a small set of styles (shown in the second tab) is applied to ensure the component looks polished in the admin panel.
Appearance of the component in the admin panel:
Frontend implementation
Implementing the frontend is straightforward. The value retrieved from the settings is a simple text value, which can be directly applied to CSS styles.
012345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
import { ComponentProps, useId } from 'react';
import { SectionLayout } from '@/payload-types';
type SectionProps = SectionLayout & ComponentProps<'section'>;
const generateMediaQuery = (
id: string,
breakpoint: NonNullable<SectionLayout['breakpoints']>[number],
): string => {
if (!breakpoint) {
return '';
}
const { minWidth, paddingTop, paddingBottom } = breakpoint;
return `
@media screen and (min-width: ${minWidth}px) {
[id="${id}"] {
padding-top: ${paddingTop}px;
padding-bottom: ${paddingBottom}px;
}
}
`;
};
export const Section = ({
hideSection = false,
paddingTop,
paddingBottom,
breakpoints,
backgroundColor,
children,
...rest
}: SectionProps) => {
if (hideSection) {
return null;
}
const id = rest.id ?? useId();
const initialStyles = `
[id="${id}"] {
padding-top: ${paddingTop}px;
padding-bottom: ${paddingBottom}px;
background-color: ${backgroundColor};
}
`;
const styles =
initialStyles +
(breakpoints
? breakpoints.map((breakpoint) => generateMediaQuery(id, breakpoint)).join('\n')
: '');
return (
<section {...rest} id={id}>
<style>{styles}</style>
{children}
</section>
);
};
The setting was added to the Section
component described in this article. It is used for demonstration purposes (see video below).
Conclusion
In this article, we implemented a color input field for Payload CMS 3. The result is:
- A reusable field that integrates seamlessly with customization settings.
- A color picker component that allows users to set colors either via text input or palette selection.
To see it in action, check out the accompanying video demonstration.
All the code from the article is available on GitHub. Feel free to use.