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, and the schema parameter is unimplemented. 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.
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 optionGetting 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);Dependent 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.