Custom Field Types
A custom field type allows you to create reusable, complex input controls and previews available in the CMS interface. Registered field types can be used in your collection just like built-in field types.
Compatibility Note
Because there is little Netlify/Decap CMS documentation on this topic, Sveltia CMS may not be fully compatible with existing preview templates. Our implementation does not include undocumented component props, other than the entry prop for control components. The undocumented onPersistMedia prop is replaced with the addFile prop, which is designed for the way Sveltia CMS saves entries, and the undocumented onOpenMediaLibrary and mediaPaths props are replaced with the pickFile prop, which resolves with what the user picked instead of leaving the control to watch a Redux store. Additionally, we haven’t verified that all of the examples below work with Sveltia CMS. If you encounter any issues, please report them to us.
Naming Convention
In Sveltia CMS, what was previously referred to as a widget in Netlify/Decap CMS is now called a field type. This change was made to better align with common content management terminology, as originally proposed by Netlify CMS maintainers themselves.
The registerWidget method from Netlify/Decap CMS has been renamed to registerFieldType in Sveltia CMS to reflect this terminology change, but the old name remains available as an alias for backward compatibility. The signature and behavior are identical.
Registering a Custom Field Type
To register a custom field type, use the registerFieldType method on the CMS object:
CMS.registerFieldType(name, control, [preview], [schema]);For backward compatibility with Netlify/Decap CMS, the registerWidget method is available as an alias with the same signature.
Parameters
name(string, required): The name of the custom field type. This is the name you will use in your collection configuration to reference this type. It should be unique and not conflict with built-in field types names.control(React component, required): A React class component that defines the control (input) part of the field.preview(React component, optional): A React class component that defines how the field’s value is previewed in the CMS preview pane. If not provided, no preview will be shown.schema(object, optional): A JSON schema object that defines the configuration options for the field type.
You can use either JSX or non-JSX syntax to define the component — see the Writing React Components section for more details.
Control Component Props
The control component receives the following props:
value(any): The current field value. Your component should display this value and callonChangewhen the user modifies it.field(Immutable Map): An Immutable Map of the current field configuration from the CMS config. Contains all field properties includingname,label,widget, and any custom properties you define in your schema. Access properties using methods likefield.get('name')orfield.getIn(['custom', 'property']).forID(string): The HTMLidattribute that should be used for the main input element. This enables proper label association and accessibility.classNameWrapper(string): A CSS class name that can be applied to your input element for consistent styling with built-in field controls.entry(Immutable Map): The data of the entry being edited. Read the content withentry.getIn(['data', 'fieldName']). This lets your control display values derived from other fields in the same entry, such as dynamically generated select options. The prop is updated whenever any field in the entry is modified, so your control always sees the latest content. See the Dependent Select example below.onChange(function): A callback function that must be called with the new value whenever the user modifies the field. This updates the entry draft in the CMS.addFile(function): A function that adds a file to the entry draft, so that the file is uploaded along with the entry when it’s saved. It returns a Promise that resolves to a temporary URL to be stored in the field value. See Uploading Files below.pickFile(function): A function that opens the same file selection dialog as a built-in File or Image field, so the user can pick an existing file, upload a new one, enter a URL or choose a stock photo. It returns a Promise that resolves to the picked file, with the value to be stored in the field. See Picking Files below.
Uploading Files
A control that produces files — an image editor, a control that downloads a remote image, or one that derives a thumbnail from an upload, for example — can hand them to the CMS with the addFile prop:
const url = await this.props.addFile(file, options);file(FileorBlob, required): The file to be added.options.name(string): The file name, including the extension. It’s required when aBlobis given, given that aBlobhas no name of its own. When aFileis given, the option overrides its name.
The function resolves to a temporary blob: URL. Store it in the field value with onChange, either as the value itself or anywhere within an object or array value, just like the Image with Derived Files example below does. The URL can also be used to display the file in the control and the preview pane while the entry is being edited.
When the entry is saved, the CMS replaces each URL in the value with the public path of the uploaded file, and commits the file along with the entry. Everything works the same way as a file selected in a built-in File or Image field:
- The file is saved to the field’s own
media_folderif the option is defined on the field, otherwise to the collection-level or top-level folder. See Configuring Folder Paths. The file name is sanitized and, if another file in the folder already has the same name, made unique. - The internal media storage options, such as
max_file_sizeandtransformations, are applied. If the file exceeds the size limit or cannot be decoded, the Promise is rejected with an error, so wrap the call intry/catchto show a message to the user. - A file identical to one already uploaded, or already added to the draft, is not uploaded twice; the existing path or URL is returned instead.
- The file is included in the same commit as the entry, so the Editorial Workflow and Open Authoring work as usual. A file that is no longer referenced in the value when the entry is saved is simply discarded.
The function is only available while an entry is being edited; it rejects otherwise.
Keep the URL intact
The CMS finds the files to be uploaded by looking for the temporary URLs in the field value. If your control transforms the URL before storing it — by encoding it or embedding it in a string that is later serialized differently, for example — the file won’t be uploaded and the value will end up with a dangling blob: URL.
Picking Files
A control that stores a file reference in a shape of its own — an image with alt text and a focal point, a gallery with per-item captions, a download with a label — shouldn’t have to reimplement file browsing. The pickFile prop opens the same dialog as a built-in File or Image field, complete with the folder list, search, uploads, URL input and stock photo integrations:
const picked = await this.props.pickFile(options);All the options are optional:
options.kind(imageorfile): The kind of file to pick. Withimage, the dialog is limited to images, just like an Image field. If omitted, the dialog is limited to images whenacceptonly lists image types, and offers any file otherwise.options.accept(string): A comma-separated list of accepted file types, such asimage/*or.pdf,.docx, applied to files uploaded through the dialog. Same as theacceptoption of a File field.options.multiple(boolean): Whether to let the user pick several files at once. Default:false.options.allowURL(boolean): Whether to let the user enter a URL instead of picking a file. Same as thechoose_urloption of a File field. Default:true.
The function resolves once the dialog is closed with the Insert button, to an object with the following properties, or to an array of such objects when multiple is enabled:
value(string): The value to be stored in the field, exactly what a built-in File or Image field would store for the same pick: the public path of an existing file, a temporaryblob:URL for a file uploaded through the dialog, or an external URL. Store it withonChange, either as the value itself or anywhere within an object or array value. A temporary URL is handled like one returned fromaddFile, so the file is uploaded when the entry is saved and the URL is replaced with the public path.file(Blob): The contents of the file, for a control that needs the bytes, such as one deriving a thumbnail like the Image with Derived Files example below. It’sundefinedfor an external URL.credit(string): Attribution HTML for a stock photo, including the photographer and service links. It’sundefinedotherwise.
It resolves to null when the dialog is dismissed, or when none of the picked files can be used, so a control can simply return early:
const picked = await this.props.pickFile({ accept: 'image/*' });
if (picked) {
this.props.onChange({ src: picked.value, alt: '' });
}The dialog lists the folders a File or Image field in the same place would offer: the field’s own media_folder if the option is defined on the field, otherwise the collection-level and top-level folders. See Configuring Folder Paths. Files uploaded through the dialog are handled exactly like files given to addFile, including naming, deduplication and the internal media storage options. A file that exceeds the size limit or cannot be decoded is reported to the user in a dialog, the way a built-in field does, rather than rejecting the Promise; the Promise is only rejected if the contents of a picked file cannot be retrieved.
The function is only available while an entry is being edited; it rejects otherwise.
Custom Validation
Control components may optionally implement an isValid instance method for custom validation. The method should return:
truewhen the value is valid.falseor{ error: { message: "text" } }when the value is invalid.- A Promise that resolves to any of the above formats for async validation.
Preview Component Props
The preview component receives the following props:
value(any): The current field value to display in the preview.field(Immutable Map): An Immutable Map of the current field configuration. Usefield.get('name')to access properties.metadata(Immutable Map): Any available metadata for the current field. For relation fields, contains referenced entry data. Use Immutable Map methods to access nested data.
Field Schema
The schema parameter is a JSON schema object that defines the configuration options for your field type. When users include your custom field type in their collection config, they can set these configuration options. For example:
const schema = {
properties: {
separator: { type: 'string' },
maxItems: { type: 'integer' },
},
};Users would then configure the field like:
fields:
- name: tags
label: Tags
widget: array # custom field type name
separator: ', ' # custom configuration option
maxItems: 10 # custom configuration optionThe schema is applied to the whole field object whenever a field uses your field type, as part of the runtime validation that runs each time the CMS loads. A configuration that doesn’t match is reported on the login screen, alongside the built-in checks:
Posts collection,
tagsfield: ThemaxItemsoption must be an integer.
Only the options your schema describes are checked. Anything else stays valid, so a schema that lists separator doesn’t stop a user from setting the common field options such as label and required.
Keep the schema valid
If the schema itself can’t be compiled — a misspelled type such as int instead of integer, for example — it is ignored, and a warning naming your field type is logged to the browser console. The rest of the configuration is still validated.
Getting a Field Type
To get the definition of a registered field type, use the getFieldType method on the CMS object:
CMS.getFieldType(name);For backward compatibility with Netlify/Decap CMS, the getWidget method is available as an alias with the same signature.
The method returns an object with the following properties, or undefined if the field type is unavailable:
control(React component): The control component of the field type.preview(React component): The preview component of the field type, if any.schema(object): The field schema, if any. Built-in field types don’t provide a schema.
This is mainly useful for building a custom field type on top of an existing one, so you don’t have to reimplement a control from scratch. For example, you can reuse the built-in Select control while providing your own dynamically generated options. See the Dependent Select example below.
Reusing a Built-In Field Type
Sveltia CMS is built with Svelte rather than React, so built-in field controls and previews are Svelte components. The components returned from getFieldType are React wrappers that render those Svelte components for you, which means you can compose them into your own React components as usual.
Only the built-in field types that work outside the entry editor can be reused this way:
boolean, color, datetime, map, number, select, string, text, uuid
For any other built-in field type, such as list or object, the method returns undefined and logs a warning to the browser console, because those editors read from and write to the entry draft directly and can’t be rendered on their own.
The returned components accept the same value, field, forID and onChange props as a custom control, with two differences:
- The
fieldprop can be an Immutable Map, a plain object, or any object exposing an Immutable Map-likegetmethod. A plain object is the simplest way to pass an ad hoc field configuration, while the other shapes let you reuse a control wrapper ported from Netlify/Decap CMS as is. - The
classNameWrapperprop is ignored, given that built-in components come with their own styles.
You can also pass the optional locale, keyPath, required, readonly and invalid props. When they are omitted, they are inherited from the field being edited, so a reused control behaves consistently with the rest of the CMS: it’s marked required, read-only and invalid exactly when your custom field is. This inheritance takes precedence over the ad hoc field configuration, given that the configuration typically describes how to render the input rather than the field itself. For example, a required: false option there won’t make a required field optional, which would otherwise let the user select an empty value that the CMS then rejects.
Compatibility Note
In Netlify/Decap CMS, the undocumented getWidget method returns any built-in or custom widget. In Sveltia CMS, the method is limited to the field types listed above for the reason described. The returned object also omits the Netlify/Decap CMS-specific globalStyles and allowMapValue properties, which have no equivalent in Sveltia CMS.
Examples
With or without JSX
The following JSX examples assume you have a build step to transpile JSX to JavaScript. If you are not using JSX, see the non-JSX examples below. See Writing React Components for more details.
Simple Text Array
A custom field type that converts a comma-separated string to an array and back:
const ArrayControl = createClass({
handleChange: function (e) {
const separator = this.props.field.get('separator', ', ');
this.props.onChange(e.target.value.split(separator).map((item) => item.trim()));
},
render: function () {
const separator = this.props.field.get('separator', ', ');
const value = this.props.value;
return h('input', {
id: this.props.forID,
className: this.props.classNameWrapper,
type: 'text',
value: value ? value.join(separator) : '',
onChange: this.handleChange,
});
},
});
const ArrayPreview = createClass({
render: function () {
const value = this.props.value;
return h(
'ul',
{ style: { margin: '0', paddingLeft: '20px' } },
Array.isArray(value) && value.map((item, index) => h('li', { key: index }, item)),
);
},
});
const schema = {
properties: {
separator: { type: 'string' },
},
};
CMS.registerFieldType('array', ArrayControl, ArrayPreview, schema);class ArrayControl extends React.Component {
handleChange = (e) => {
const separator = this.props.field.get('separator', ', ');
this.props.onChange(e.target.value.split(separator).map((item) => item.trim()));
};
render() {
const separator = this.props.field.get('separator', ', ');
const value = this.props.value;
return (
<input
id={this.props.forID}
className={this.props.classNameWrapper}
type="text"
value={value ? value.join(separator) : ''}
onChange={this.handleChange}
/>
);
}
}
class ArrayPreview extends React.Component {
render() {
const value = this.props.value;
return (
<ul style={{ margin: '0', paddingLeft: '20px' }}>
{Array.isArray(value) && value.map((item, index) => <li key={index}>{item}</li>)}
</ul>
);
}
}
const schema = {
properties: {
separator: { type: 'string' },
},
};
CMS.registerFieldType('array', ArrayControl, ArrayPreview, schema);Color Picker
A custom field type with a color input and preview:
const ColorControl = createClass({
render: function () {
return h('input', {
id: this.props.forID,
className: this.props.classNameWrapper,
type: 'color',
value: this.props.value || '#000000',
onChange: (e) => this.props.onChange(e.target.value),
});
},
});
const ColorPreview = createClass({
render: function () {
return h('div', {
style: {
display: 'inline-block',
width: '30px',
height: '30px',
backgroundColor: this.props.value || '#000000',
border: '1px solid #ddd',
borderRadius: '4px',
},
});
},
});
CMS.registerFieldType('color', ColorControl, ColorPreview);class ColorControl extends React.Component {
render() {
return (
<input
id={this.props.forID}
className={this.props.classNameWrapper}
type="color"
value={this.props.value || '#000000'}
onChange={(e) => this.props.onChange(e.target.value)}
/>
);
}
}
class ColorPreview extends React.Component {
render() {
return (
<div
style={{
display: 'inline-block',
width: '30px',
height: '30px',
backgroundColor: this.props.value || '#000000',
border: '1px solid #ddd',
borderRadius: '4px',
}}
/>
);
}
}
CMS.registerFieldType('color', ColorControl, ColorPreview);Number with Validation
A field type for numbers with custom validation and constraints:
const NumberControl = createClass({
isValid: function (value) {
const min = this.props.field.get('min');
const max = this.props.field.get('max');
if (isNaN(value)) {
return { error: { message: 'Must be a number' } };
}
if (min !== undefined && value < min) {
return { error: { message: `Value must be at least ${min}` } };
}
if (max !== undefined && value > max) {
return { error: { message: `Value must be no more than ${max}` } };
}
return true;
},
render: function () {
const min = this.props.field.get('min');
const max = this.props.field.get('max');
return h('input', {
id: this.props.forID,
className: this.props.classNameWrapper,
type: 'number',
value: this.props.value || '',
min: min,
max: max,
onChange: (e) => this.props.onChange(parseFloat(e.target.value) || null),
});
},
});
const NumberPreview = createClass({
render: function () {
return h('span', {}, String(this.props.value ?? ''));
},
});
const schema = {
properties: {
min: { type: 'number' },
max: { type: 'number' },
},
};
CMS.registerFieldType('number', NumberControl, NumberPreview, schema);class NumberControl extends React.Component {
isValid(value) {
const min = this.props.field.get('min');
const max = this.props.field.get('max');
if (isNaN(value)) {
return { error: { message: 'Must be a number' } };
}
if (min !== undefined && value < min) {
return { error: { message: `Value must be at least ${min}` } };
}
if (max !== undefined && value > max) {
return { error: { message: `Value must be no more than ${max}` } };
}
return true;
}
render() {
const min = this.props.field.get('min');
const max = this.props.field.get('max');
return (
<input
id={this.props.forID}
className={this.props.classNameWrapper}
type="number"
value={this.props.value || ''}
min={min}
max={max}
onChange={(e) => this.props.onChange(parseFloat(e.target.value) || null)}
/>
);
}
}
class NumberPreview extends React.Component {
render() {
return <span>{String(this.props.value ?? '')}</span>;
}
}
const schema = {
properties: {
min: { type: 'number' },
max: { type: 'number' },
},
};
CMS.registerFieldType('number', NumberControl, NumberPreview, schema);JSON Editor
A field type for editing JSON data with validation:
const JsonControl = createClass({
isValid: function (value) {
if (typeof value !== 'string') {
return true; // Allow null/undefined
}
try {
JSON.parse(value);
return true;
} catch (e) {
return { error: { message: `Invalid JSON: ${e.message}` } };
}
},
render: function () {
const value = this.props.value;
const stringValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
return h('textarea', {
id: this.props.forID,
className: this.props.classNameWrapper,
value: stringValue || '',
onChange: (e) => this.props.onChange(e.target.value),
style: {
fontFamily: 'monospace',
fontSize: '12px',
minHeight: '200px',
},
});
},
});
const JsonPreview = createClass({
render: function () {
const value = this.props.value;
let parsed;
try {
parsed = typeof value === 'string' ? JSON.parse(value) : value;
} catch (e) {
return h('div', { style: { color: 'red' } }, 'Invalid JSON');
}
return h(
'pre',
{
style: {
backgroundColor: '#f5f5f5',
padding: '10px',
borderRadius: '4px',
overflow: 'auto',
maxHeight: '300px',
},
},
JSON.stringify(parsed, null, 2),
);
},
});
CMS.registerFieldType('json', JsonControl, JsonPreview);class JsonControl extends React.Component {
isValid(value) {
if (typeof value !== 'string') {
return true; // Allow null/undefined
}
try {
JSON.parse(value);
return true;
} catch (e) {
return { error: { message: `Invalid JSON: ${e.message}` } };
}
}
render() {
const value = this.props.value;
const stringValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
return (
<textarea
id={this.props.forID}
className={this.props.classNameWrapper}
value={stringValue || ''}
onChange={(e) => this.props.onChange(e.target.value)}
style={{
fontFamily: 'monospace',
fontSize: '12px',
minHeight: '200px',
}}
/>
);
}
}
class JsonPreview extends React.Component {
render() {
const value = this.props.value;
let parsed;
try {
parsed = typeof value === 'string' ? JSON.parse(value) : value;
} catch (e) {
return <div style={{ color: 'red' }}>Invalid JSON</div>;
}
return (
<pre
style={{
backgroundColor: '#f5f5f5',
padding: '10px',
borderRadius: '4px',
overflow: 'auto',
maxHeight: '300px',
}}
>
{JSON.stringify(parsed, null, 2)}
</pre>
);
}
}
CMS.registerFieldType('json', JsonControl, JsonPreview);Image with Metadata
A field type that stores both image path and alt text:
const ImageMetaControl = createClass({
handleChange: function (field, value) {
const current = this.props.value || {};
this.props.onChange({
...current,
[field]: value,
});
},
render: function () {
const value = this.props.value || {};
return h(
'div',
{ style: { display: 'flex', flexDirection: 'column', gap: '10px' } },
h(
'div',
{},
h('label', { htmlFor: `${this.props.forID}-image` }, 'Image path:'),
h('input', {
id: `${this.props.forID}-image`,
type: 'text',
value: value.image || '',
onChange: (e) => this.handleChange('image', e.target.value),
style: { width: '100%', padding: '8px' },
}),
),
h(
'div',
{},
h('label', { htmlFor: `${this.props.forID}-alt` }, 'Alt text:'),
h('textarea', {
id: `${this.props.forID}-alt`,
value: value.alt || '',
onChange: (e) => this.handleChange('alt', e.target.value),
style: { width: '100%', padding: '8px', minHeight: '60px' },
}),
),
);
},
});
const ImageMetaPreview = createClass({
render: function () {
const value = this.props.value || {};
return h(
'div',
{},
value.image &&
h('img', { src: value.image, alt: value.alt || '', style: { maxWidth: '200px' } }),
value.alt && h('p', { style: { fontSize: '12px', color: '#666' } }, `Alt: ${value.alt}`),
);
},
});
CMS.registerFieldType('imageMeta', ImageMetaControl, ImageMetaPreview);class ImageMetaControl extends React.Component {
handleChange = (field, value) => {
const current = this.props.value || {};
this.props.onChange({
...current,
[field]: value,
});
};
render() {
const value = this.props.value || {};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div>
<label htmlFor={`${this.props.forID}-image`}>Image path:</label>
<input
id={`${this.props.forID}-image`}
type="text"
value={value.image || ''}
onChange={(e) => this.handleChange('image', e.target.value)}
style={{ width: '100%', padding: '8px' }}
/>
</div>
<div>
<label htmlFor={`${this.props.forID}-alt`}>Alt text:</label>
<textarea
id={`${this.props.forID}-alt`}
value={value.alt || ''}
onChange={(e) => this.handleChange('alt', e.target.value)}
style={{ width: '100%', padding: '8px', minHeight: '60px' }}
/>
</div>
</div>
);
}
}
class ImageMetaPreview extends React.Component {
render() {
const value = this.props.value || {};
return (
<div>
{value.image && (
<img src={value.image} alt={value.alt || ''} style={{ maxWidth: '200px' }} />
)}
{value.alt && <p style={{ fontSize: '12px', color: '#666' }}>Alt: {value.alt}</p>}
</div>
);
}
}
CMS.registerFieldType('imageMeta', ImageMetaControl, ImageMetaPreview);Image with Derived Files
A field type that lets the user pick an image with the pickFile prop, generates a small WebP thumbnail in the browser, and stores the paths of both files along with the aspect ratio. The user can either upload a new image or choose one already in the repository. The thumbnail is added to the entry draft with the addFile prop, and any new file is uploaded when the entry is saved.
Given the following field configuration, the files are saved to the static/photos folder and referenced as /photos/... in the entry:
fields:
- name: photo
label: Photo
widget: photo # custom field type name
media_folder: /static/photos
public_folder: /photos/**
* Resize an image file to the given width and return it as a WebP `Blob`.
*/
const createThumbnail = async (file, width) => {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
const scale = width / bitmap.width;
canvas.width = width;
canvas.height = Math.round(bitmap.height * scale);
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return {
blob: await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp')),
aspectRatio: bitmap.width / bitmap.height,
};
};
const PhotoControl = createClass({
getInitialState: function () {
return { error: null };
},
handlePick: async function () {
try {
// The URL option is disabled because the thumbnail can only be derived from the file contents
const picked = await this.props.pickFile({ accept: 'image/*', allowURL: false });
if (!picked) {
return;
}
const { blob, aspectRatio } = await createThumbnail(picked.file, 50);
const fileName = picked.file.name || picked.value.split('/').pop();
const baseName = fileName.replace(/\.[^.]+$/, '');
// `original` is either the public path of an existing image or a temporary URL of a new
// upload; `thumbnail` is always a temporary URL. Temporary URLs are replaced with the public
// paths of the uploaded files when the entry is saved
const original = picked.value;
const thumbnail = await this.props.addFile(blob, { name: `${baseName}-thumb.webp` });
this.setState({ error: null });
this.props.onChange({ original, thumbnail, aspectRatio });
} catch (error) {
this.setState({ error: error.message });
}
},
render: function () {
const value = this.props.value || {};
return h(
'div',
{ style: { display: 'flex', flexDirection: 'column', gap: '10px' } },
h(
'button',
{ id: this.props.forID, type: 'button', onClick: this.handlePick },
'Choose Image',
),
value.thumbnail && h('img', { src: value.thumbnail, alt: '', width: 50 }),
this.state.error && h('p', { style: { color: 'red' } }, this.state.error),
);
},
});
const PhotoPreview = createClass({
render: function () {
const value = this.props.value || {};
if (!value.original) {
return null;
}
return h('img', { src: value.original, alt: '', style: { maxWidth: '300px' } });
},
});
CMS.registerFieldType('photo', PhotoControl, PhotoPreview);/**
* Resize an image file to the given width and return it as a WebP `Blob`.
*/
const createThumbnail = async (file, width) => {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
const scale = width / bitmap.width;
canvas.width = width;
canvas.height = Math.round(bitmap.height * scale);
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return {
blob: await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp')),
aspectRatio: bitmap.width / bitmap.height,
};
};
class PhotoControl extends React.Component {
state = { error: null };
handlePick = async () => {
try {
// The URL option is disabled because the thumbnail can only be derived from the file contents
const picked = await this.props.pickFile({ accept: 'image/*', allowURL: false });
if (!picked) {
return;
}
const { blob, aspectRatio } = await createThumbnail(picked.file, 50);
const fileName = picked.file.name || picked.value.split('/').pop();
const baseName = fileName.replace(/\.[^.]+$/, '');
// `original` is either the public path of an existing image or a temporary URL of a new
// upload; `thumbnail` is always a temporary URL. Temporary URLs are replaced with the public
// paths of the uploaded files when the entry is saved
const original = picked.value;
const thumbnail = await this.props.addFile(blob, { name: `${baseName}-thumb.webp` });
this.setState({ error: null });
this.props.onChange({ original, thumbnail, aspectRatio });
} catch (error) {
this.setState({ error: error.message });
}
};
render() {
const value = this.props.value || {};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<button id={this.props.forID} type="button" onClick={this.handlePick}>
Choose Image
</button>
{value.thumbnail && <img src={value.thumbnail} alt="" width={50} />}
{this.state.error && <p style={{ color: 'red' }}>{this.state.error}</p>}
</div>
);
}
}
class PhotoPreview extends React.Component {
render() {
const value = this.props.value || {};
if (!value.original) {
return null;
}
return <img src={value.original} alt="" style={{ maxWidth: '300px' }} />;
}
}
CMS.registerFieldType('photo', PhotoControl, PhotoPreview);Once saved, the entry holds the public paths, which your site can use for a blurred placeholder, a srcset, or a fixed-ratio box that avoids layout shift:
photo:
original: /photos/sunset.jpg
thumbnail: /photos/sunset-thumb.webp
aspectRatio: 1.5Dependent Select
A field type that reuses the built-in Select control, with options generated from another field in the same entry. This solves a common need that a static options list or a Relation field can’t cover: the choices are defined by the user in the entry they are editing.
Given a collection where a groups list field defines named items, and each item of a content list field has to reference one of those groups:
fields:
- name: groups
label: Groups
widget: list
fields:
- name: name
label: Name
widget: string
- name: text
label: Text
widget: markdown
- name: content
label: Content
widget: list
fields:
- name: group
label: Referenced Group
widget: group-select # custom field type nameThe group-select control reads the group names from the entry prop and passes them to the built-in Select control as options. Because the entry prop is updated whenever any field in the entry is modified, the options reflect the group names as they are typed, with no need to save and reload:
const SelectControl = CMS.getFieldType('select').control;
const GroupSelectControl = createClass({
render: function () {
const groups = this.props.entry.getIn(['data', 'groups']);
const options = (groups?.toJS() ?? [])
.filter((group) => !!group.name)
.map((group) => ({ label: group.name, value: group.name }));
return h(SelectControl, {
field: { name: this.props.field.get('name'), options },
value: this.props.value,
forID: this.props.forID,
onChange: this.props.onChange,
});
},
});
CMS.registerFieldType('group-select', GroupSelectControl);const SelectControl = CMS.getFieldType('select').control;
class GroupSelectControl extends React.Component {
render() {
const groups = this.props.entry.getIn(['data', 'groups']);
const options = (groups?.toJS() ?? [])
.filter((group) => !!group.name)
.map((group) => ({ label: group.name, value: group.name }));
return (
<SelectControl
field={{ name: this.props.field.get('name'), options }}
value={this.props.value}
forID={this.props.forID}
onChange={this.props.onChange}
/>
);
}
}
CMS.registerFieldType('group-select', GroupSelectControl);TIP
The field configuration passed to a built-in control doesn’t have to come from your CMS config, as shown above. Any option supported by the field type can be set, such as multiple or dropdown_threshold for the Select field type.
Showcase
Real-world examples of custom field types can be found in our showcase.