Author
Dan Ribbens headshot
Dan Ribbens
Published On

How To Swap in Your Own Field Components

Author
Dan Ribbens headshot
Dan Ribbens
Published On
How To Swap in Your Own Field Components
How To Swap in Your Own Field Components

You can build completely custom field types in Payload by swapping in your own React components for any field in your app. In this tutorial, we'll be showing you how.

Building your own custom fields in Payload is as easy as writing a React component. Any field type can be extended further to make your own custom field, right down to how it works in the admin panel. In this way, you can avoid reinventing everything about a field and only work on adding your custom business logic exactly how you need.

To demonstrate this, we're going to create a simple color picker component for use right in the Payload CMS admin panel. By the end of this guide, we'll have created a modular, reusable custom field that can be dropped into any Payload CMS app with ease.

The component will:

  • Store its value in the database as a string—just like the built-in text field type
  • Use a custom validator function for the color format, to enforce consistency on the frontend and backend
  • Handle sending and receiving data to the Payload API by leveraging Payload's useFieldType hook
  • Store and retrieve user-specific preferences using Payload's Preferences feature
  • Render a custom Cell component, showing the selected color in the List view of the admin panel

All the code written for this guide can be seen in the Custom Field Guide repository.

Payload CMS Color Picker

Get Started

You can use your own Payload app or start a new one for this guide. If you haven't started a project yet, you can get started easily by running npx create-payload-app in your terminal.

For more details on how to start an application, including how to do so from scratch, read the installation documentation.

Write the base field config

The first step is to create a new file in your app for our new field's config. That will let us import it to different collections wherever it is needed. Because we want our field to store a string in the database, just like the built-in text field type does, we'll set our field's type equal to text. That will tell Payload how to handle storing the data. We'll also write a simple validation function to tell the backend and frontend what to allow to be saved.

1
import { Field } from 'payload/types';
2
3
export const validateHexColor = (value: string): boolean | string => {
4
return value.match(/^#(?:[0-9a-fA-F]{3}){1,2}$/).length === 1 || `${value} is not a valid hex color`;
5
}
6
7
const colorField: Field = {
8
name: 'color',
9
type: 'text',
10
validate: validateHexColor,
11
required: true,
12
};
13
14
export default colorField;

Import the field in a collection

We'll import the field to an existing collection, so we can see it in use, before building it up a bit more.

/src/collections/ToDoLists.ts:

1
import { CollectionConfig } from 'payload/types';
2
import colorField from '../color-picker/config';
3
4
const Todo: CollectionConfig = {
5
fields: [
6
colorField,
7
]
8
}

This is a good time to mention that because we're just dealing with JavaScript, you could import this field and use it anywhere. You could also change individual properties specific to this collection by destructuring the object and add extra properties you wish to set. To do that, in place of the imported colorField instead do { ...colorField, required: false }, or any other properties as needed.

Build the Edit Component

So far, the default Text component is still rendering in the admin panel. Let's swap that out with a custom component, and modify the field's config to include it.

Custom field components are just basic React components, so let's scaffold that out and then build the extra features one-by-one. Create a new file for the Field component:

/src/color-picker/InputField.tsx:

1
import React from 'react'
2
3
// this is how we'll interface with Payload itself
4
import { useFieldType } from 'payload/components/forms';
5
6
// we'll re-use the built in Label component directly from Payload
7
import { Label } from 'payload/components/forms';
8
9
// we can use existing Payload types easily
10
import { Props } from 'payload/components/fields/Text';
11
12
// we'll import and reuse our existing validator function on the frontend, too
13
import { validateHexColor } from './config';
14
15
// Import the SCSS stylesheet
16
import './styles.scss';
17
18
// keep a list of default colors to choose from
19
const defaultColors = [
20
'#0F0F0F',
21
'#9A9A9A',
22
'#F3F3F3',
23
'#FF6F76',
24
'#FDFFA4',
25
'#B2FFD6',
26
'#F3DDF3',
27
];
28
29
const baseClass = 'custom-color-picker';
30
31
const InputField: React.FC<Props> = (props) => {
32
const {
33
path,
34
label,
35
required
36
} = props;
37
38
const {
39
value = '',
40
setValue,
41
} = useFieldType({
42
path,
43
validate: validateHexColor,
44
});
45
46
return (
47
<div className={baseClass}>
48
<Label
49
htmlFor={path}
50
label={label}
51
required={required}
52
/>
53
<ul className={`${baseClass}__colors`}>
54
{defaultColors.map((color, i) => (
55
<li key={i}>
56
<button
57
type="button"
58
key={color}
59
className={`chip ${color === value ? 'chip--selected' : ''} chip--clickable`}
60
style={{ backgroundColor: color }}
61
aria-label={color}
62
onClick={() => setValue(color)}
63
/>
64
</li>
65
)
66
)}
67
</ul>
68
</div>
69
)
70
};
71
72
export default InputField;

You'll see above that Payload automatically provides our React component with the props that it needs. The most important prop is the path, which we pass on to the useFieldType hook. This hook allows us to set the field's value and have it work with the rest of the Payload form.

The component returns the markup for the component, complete with a Label and a list of clickable colors.

This won't be very functional until we add styling. Let's add a new line to import a new stylesheet: import './styles.scss';. Create that file and paste in the following SCSS:

/src/color-picker/styles.scss:

1
@import '~payload/scss';
2
3
.custom-color-picker {
4
&__colors {
5
display: flex;
6
flex-wrap: wrap;
7
list-style: none;
8
padding: 0;
9
margin: 0;
10
}
11
}
12
13
.chip {
14
border-radius: 50%;
15
border: $style-stroke-width-m solid #fff;
16
height: base(1.25);
17
width: base(1.25);
18
margin-right: base(.5);
19
box-shadow: none;
20
21
&--selected {
22
box-shadow: 0 0 0 $style-stroke-width-m $color-dark-gray;
23
}
24
25
&--clickable {
26
cursor: pointer;
27
}
28
}

The simple styles above will give the color "chips" a clickable circle to set the value and show which is currently selected.

Build the Cell

Another part of the custom component that we can add is a nice way to display the color right in a collection List. There, we can create the following:

/src/color-picker/Cell.tsx:

1
2
import React from 'react';
3
import { Props } from 'payload/components/views/Cell';
4
import './styles.scss';
5
6
const Cell: React.FC<Props> = (props) => {
7
const { cellData } = props;
8
9
if (!cellData) return null;
10
11
return (
12
<div
13
className="chip"
14
style={{ backgroundColor: cellData as string }}
15
/>
16
)
17
}
18
19
export default Cell;

Note that we can reuse our styles here as we want the color "chip" to appear the same. We get the cellData from the Prop and that will be our saved hex values for the field.

Add the components to the Field

Now that we have a functional component to serve as our input, we can update color-picker/config.ts with a new admin property:

1
import { Field } from 'payload/types';
2
import InputField from './InputField';
3
import Cell from './Cell';
4
5
const colorField: Field = {
6
// ...
7
admin: {
8
components: {
9
Field: InputField,
10
Cell,
11
},
12
},
13
};

Now is a good time to see it working! After you login and navigate to the url to create a new Todo item you will see the component and can use it to create a new Todo list

Payload CMS Basic Color Picker

Back in the List view, you should also be able to see the color that was chosen right in the table. If you don't see the color column, expand the column list to include it.

Payload CMS Custom Field Cell

Allowing users to add their own colors

What we have is nice if we want to control available color options closely, but we know our users want to add their own too. Let's add to the UI a way to do that and while we're at it we should store the user's newly added colors in Payload's user preferences to re-use color options without re-entering them every time.

To make the interactions possible, we'll add more state variables and useEffect hooks. We also need to import and use the validation logic from the config, and set the value in a new Input which we can import styles directly from Payload to make it look right.

User Preferences

By adding Payload's usePreferences() hook, we can get and set user specific data relevant to the color picker all persisted in the database without needing to write new endpoints. You will see we call setPreference() and getPreference() to get and set the array of color options specific to the authenticated user.

Note that the preferenceKey should be something completely unique across your app to avoid overwriting other preference data.

Now, for the complete component code:

/src/color-picker/InputField.tsx:

1
import React, { useEffect, useState, useCallback, Fragment } from 'react'
2
3
// this is how we'll interface with Payload itself
4
import { useFieldType } from 'payload/components/forms';
5
6
// retrieve and store the last used colors of your users
7
import { usePreferences } from 'payload/components/preferences';
8
9
// re-use Payload's built-in button component
10
import { Button } from 'payload/components';
11
12
// we'll re-use the built in Label component directly from Payload
13
import { Label } from 'payload/components/forms';
14
15
// we can use existing Payload types easily
16
import { Props } from 'payload/components/fields/Text';
17
18
// we'll import and reuse our existing validator function on the frontend, too
19
import { validateHexColor } from './config';
20
21
// Import the SCSS stylesheet
22
import './styles.scss';
23
24
// keep a list of default colors to choose from
25
const defaultColors = [
26
'#0F0F0F',
27
'#9A9A9A',
28
'#F3F3F3',
29
'#FF6F76',
30
'#FDFFA4',
31
'#B2FFD6',
32
'#F3DDF3',
33
];
34
const baseClass = 'custom-color-picker';
35
36
const preferenceKey = 'color-picker-colors';
37
38
const InputField: React.FC<Props> = (props) => {
39
const {
40
path,
41
label,
42
required
43
} = props;
44
45
const {
46
value = '',
47
setValue,
48
} = useFieldType({
49
path,
50
validate: validateHexColor,
51
});
52
53
const { getPreference, setPreference } = usePreferences();
54
const [colorOptions, setColorOptions] = useState(defaultColors);
55
const [isAdding, setIsAdding] = useState(false);
56
const [colorToAdd, setColorToAdd] = useState('');
57
58
useEffect(() => {
59
const mergeColorsFromPreferences = async () => {
60
const colorPreferences = await getPreference<string[]>(preferenceKey);
61
if (colorPreferences) {
62
setColorOptions(colorPreferences);
63
}
64
};
65
mergeColorsFromPreferences();
66
}, [getPreference, setColorOptions]);
67
68
const handleAddColor = useCallback(() => {
69
setIsAdding(false);
70
setValue(colorToAdd);
71
72
// prevent adding duplicates
73
if (colorOptions.indexOf(colorToAdd) > -1) return;
74
75
let newOptions = colorOptions;
76
newOptions.unshift(colorToAdd);
77
78
// update state with new colors
79
setColorOptions(newOptions);
80
// store the user color preferences for future use
81
setPreference(preferenceKey, newOptions);
82
}, [colorOptions, setPreference, colorToAdd, setIsAdding, setValue]);
83
84
return (
85
<div className={baseClass}>
86
<Label
87
htmlFor={path}
88
label={label}
89
required={required}
90
/>
91
{isAdding && (
92
<div>
93
<input
94
className={`${baseClass}__input`}
95
type="text"
96
placeholder="#000000"
97
onChange={(e) => setColorToAdd(e.target.value)}
98
value={colorToAdd}
99
/>
100
<Button
101
className={`${baseClass}__btn`}
102
buttonStyle="primary"
103
iconPosition="left"
104
iconStyle="with-border"
105
size="small"
106
onClick={handleAddColor}
107
disabled={validateHexColor(colorToAdd) !== true}
108
>
109
Add
110
</Button>
111
<Button
112
className={`${baseClass}__btn`}
113
buttonStyle="secondary"
114
iconPosition="left"
115
iconStyle="with-border"
116
size="small"
117
onClick={() => setIsAdding(false)}
118
>
119
Cancel
120
</Button>
121
</div>
122
)}
123
{!isAdding && (
124
<Fragment>
125
<ul className={`${baseClass}__colors`}>
126
{colorOptions.map((color, i) => (
127
<li key={i}>
128
<button
129
type="button"
130
key={color}
131
className={`chip ${color === value ? 'chip--selected' : ''} chip--clickable`}
132
style={{ backgroundColor: color }}
133
aria-label={color}
134
onClick={() => setValue(color)}
135
/>
136
</li>
137
)
138
)}
139
</ul>
140
<Button
141
className="add-color"
142
icon="plus"
143
buttonStyle="icon-label"
144
iconPosition="left"
145
iconStyle="with-border"
146
onClick={() => {
147
setIsAdding(true);
148
setValue('');
149
}}
150
/>
151
</Fragment>
152
)}
153
</div>
154
)
155
};
156
export default InputField;

We made a lot of changes—hopefully the code speaks for itself. Everything we did adds to the interactivity and usability of the field.

Styling the input to look like Payload UI

Lastly we want to finish off the styles of our input with a few new pieces.

Update your styles.scss with the following:

/src/color-picker/styles.scss:

1
@import '~payload/scss';
2
3
.add-color.btn {
4
margin: 0;
5
padding: 0;
6
border: $style-stroke-width-m solid #fff;
7
}
8
9
.custom-color-picker {
10
&__btn.btn {
11
margin: base(.25);
12
13
&:first-of-type {
14
margin-left: unset;
15
}
16
}
17
18
&__input {
19
// Payload exports a mixin from the vars file for quickly applying formInput rules to the class for our input
20
@include formInput
21
}
22
23
&__colors {
24
display: flex;
25
flex-wrap: wrap;
26
list-style: none;
27
padding: 0;
28
margin: 0;
29
}
30
}
31
32
.chip {
33
border-radius: 50%;
34
border: $style-stroke-width-m solid #fff;
35
height: base(1.25);
36
width: base(1.25);
37
margin-right: base(.5);
38
box-shadow: none;
39
40
&--selected {
41
box-shadow: 0 0 0 $style-stroke-width-m $color-dark-gray;
42
}
43
44
&--clickable {
45
cursor: pointer;
46
}
47
}
48

Closing Remarks

The custom color picker in this guide serves as an example of one way you could extend the UI to create a better authoring experience for users.

I hope you're inspired to create your own fantastic UI components using Payload CMS. Feel free to share what you build in the GitHub discussions.