Building Custom Field Types in Scalar
Extend Scalar's content modeling capabilities with custom fields tailored to your specific business needs.
Building Custom Field Types in Scalar
Extend Scalar’s content modeling capabilities with custom fields tailored to your specific business needs.
Beyond Standard Fields
Scalar comes with a robust set of built-in field types that cover most content modeling needs:
- Text-based fields: text, multiline, richText, markdown, etc.
- Numeric fields: number, integer, decimal, etc.
- Selection fields: select, multiSelect, boolean, etc.
- Media fields: image, file, video, audio, etc.
- Relationship fields: reference, relation, etc.
- Structural fields: object, array, etc.
However, there are always domain-specific requirements that call for specialized content structures. This is where custom field types come in.
Custom fields allow you to extend Scalar’s type system with your own field implementations that perfectly match your business domain. In this guide, we’ll explore how to build, implement, and deploy custom fields in Scalar.
Use Cases for Custom Fields
Before diving into implementation, let’s consider some real-world scenarios where custom fields can add significant value:
E-commerce Product Specifications
Standard fields might be insufficient for complex product data like:
- Size charts with unit conversion
- Color swatches with hex values and named colors
- Nutrition facts for food products
- Technical specifications with standardized values
Location and Geospatial Data
Projects dealing with location data might need:
- Address fields with formatting and validation
- Map coordinates with visual selection
- Region selectors with hierarchical data
- Distance calculators
Specialized Content Types
Domain-specific content often requires custom structures:
- Code snippets with syntax highlighting
- Mathematical equations with rendering
- Chemical formulas
- Medical data with standardized codes
Interactive Elements
Custom fields can power interactive content:
- Quizzes and assessments
- Interactive calculators
- Configurable forms
- Embedded widgets
Let’s build some practical examples to demonstrate how custom fields work in Scalar.
Building Your First Custom Field
We’ll start with a relatively simple but practical example: a color picker field that stores and validates color values.
1. Setting Up the Development Environment
First, create a new package for your custom field:
mkdir scalar-color-fieldcd scalar-color-fieldnpm init -ynpm install --save-dev @scalar/field-types react typescriptCreate a TypeScript configuration:
{ "compilerOptions": { "target": "es2018", "module": "esnext", "moduleResolution": "node", "declaration": true, "outDir": "./dist", "strict": true, "jsx": "react", "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"], "exclude": ["node_modules", "dist"]}2. Defining the Field Schema
Create the field type definition:
import { defineField, FieldValidators } from '@scalar/field-types';
// Define the color field schemaexport interface ColorFieldSchema { defaultValue?: string; allowedFormats?: ('hex' | 'rgb' | 'rgba' | 'hsl')[]; presets?: { name: string; value: string; }[];}
// Define color field specific validatorsexport interface ColorFieldValidators extends FieldValidators { hexOnly?: boolean; includeAlpha?: boolean;}
// Define the field typeexport const colorField = defineField< ColorFieldSchema, ColorFieldValidators, string>({ // Type name used in content models name: 'color',
// Default configuration defaultConfig: { allowedFormats: ['hex', 'rgb', 'rgba'], presets: [], },
// Field schema validations validateConfig: (config) => { const errors = [];
// Validate default value matches allowed formats if ( config.defaultValue && !isValidColor(config.defaultValue, config.allowedFormats) ) { errors.push( `Default value "${config.defaultValue}" must be a valid color in one of the allowed formats.`, ); }
// Return validation results return { valid: errors.length === 0, errors }; },
// Value validation logic validateValue: (value, config, validators) => { if (value === undefined || value === null) { return { valid: true }; }
const errors = [];
// Check if it's a valid color if (!isValidColor(value, config.allowedFormats)) { errors.push(`Value must be a valid color in one of the allowed formats.`); }
// Check hex-only validation if specified if (validators?.hexOnly && !value.startsWith('#')) { errors.push('Value must be in hexadecimal format.'); }
// Check alpha validation if specified if ( validators?.includeAlpha === false && (value.startsWith('rgba') || (value.includes(',') && value.split(',').length > 3)) ) { errors.push('Alpha values are not allowed.'); }
return { valid: errors.length === 0, errors }; },});
// Helper function to validate colorsfunction isValidColor( value: string, allowedFormats?: ('hex' | 'rgb' | 'rgba' | 'hsl')[],): boolean { // Basic validation for various color formats const patterns = { hex: /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/, rgb: /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/, rgba: /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*(0|0\.\d+|1|1\.0+)\s*\)$/, hsl: /^hsl\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*\)$/, };
// If no formats are specified, allow all const formats = allowedFormats || ['hex', 'rgb', 'rgba', 'hsl'];
// Check if the value matches any of the allowed formats return formats.some((format) => patterns[format].test(value));}3. Creating the Input Component
Next, create the React component that renders the color picker:
import React, { useState, useEffect } from 'react';import { FieldInputProps } from '@scalar/field-types';import { ColorFieldSchema } from './schema';
export const ColorInput: React.FC< FieldInputProps<string, ColorFieldSchema>> = ({ value, onChange, config, disabled, error }) => { const [colorValue, setColorValue] = useState( value || config.defaultValue || '#000000', );
// Update the parent when local value changes useEffect(() => { if (value !== colorValue) { onChange(colorValue); } }, [colorValue]);
// Update local state when the value prop changes useEffect(() => { if (value && value !== colorValue) { setColorValue(value); } }, [value]);
// Handle input changes const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { setColorValue(e.target.value); };
// Handle preset selection const handlePresetClick = (presetValue: string) => { setColorValue(presetValue); };
return ( <div className="color-field-container"> <div className="color-picker-row"> <input type="color" value={colorValue.startsWith('#') ? colorValue : '#000000'} onChange={handleInputChange} disabled={disabled} className="color-picker" /> <input type="text" value={colorValue} onChange={handleInputChange} disabled={disabled} className={`color-text-input ${error ? 'has-error' : ''}`} placeholder="Enter color value" /> </div>
{config.presets && config.presets.length > 0 && ( <div className="color-presets"> {config.presets.map((preset, index) => ( <button key={index} type="button" className="color-preset-button" style={{ backgroundColor: preset.value }} title={preset.name} onClick={() => handlePresetClick(preset.value)} disabled={disabled} /> ))} </div> )}
{error && <div className="color-field-error">{error}</div>} </div> );};4. Defining the Preview Component
Create a component for displaying the color in read-only contexts:
import React from 'react';import { FieldPreviewProps } from '@scalar/field-types';import { ColorFieldSchema } from './schema';
export const ColorPreview: React.FC< FieldPreviewProps<string, ColorFieldSchema>> = ({ value, config }) => { if (!value) { return <span className="empty-color">No color selected</span>; }
return ( <div className="color-preview"> <div className="color-swatch" style={{ backgroundColor: value }} /> <span className="color-value">{value}</span> </div> );};5. Creating the Main Export
Tie everything together in your main file:
import { registerFieldType } from '@scalar/field-types';import { colorField } from './schema';import { ColorInput } from './ColorInput';import { ColorPreview } from './ColorPreview';
// Register the field typeregisterFieldType({ type: colorField, Input: ColorInput, Preview: ColorPreview,});
// Export all components and typesexport { colorField, ColorInput, ColorPreview };export * from './schema';6. Building and Publishing
Create a build script in your package.json:
{ "name": "scalar-color-field", "version": "1.0.0", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc", "prepare": "npm run build" }, "peerDependencies": { "@scalar/field-types": "^1.0.0", "react": "^17.0.0 || ^18.0.0" }, "devDependencies": { "@scalar/field-types": "^1.0.0", "@types/react": "^18.0.0", "react": "^18.0.0", "typescript": "^4.5.5" }}Run the build and publish your package:
npm run buildnpm publishUsing Custom Fields in Scalar
Now that we’ve created our custom color field, let’s see how to use it in a Scalar project.
Installing the Custom Field
In your Scalar project:
npm install scalar-color-fieldRegistering the Field
In your Scalar configuration:
import { defineConfig } from '@scalar/config';import 'scalar-color-field'; // This registers the field type
export default defineConfig({ // Your Scalar configuration});Using the Field in a Content Model
import { defineType, fields } from 'scalar';import { colorField } from 'scalar-color-field';
const Product = defineType({ name: 'product', fields: { name: fields.text({ required: true }),
// Use the custom color field primaryColor: colorField({ allowedFormats: ['hex', 'rgb'], presets: [ { name: 'Brand Blue', value: '#0066cc' }, { name: 'Brand Red', value: '#cc0000' }, { name: 'Brand Green', value: '#00cc66' }, ], validation: { hexOnly: true, }, }),
// Other fields... },});Building More Complex Custom Fields
Now that we understand the basics, let’s explore a more complex custom field: an address field with formatting and validation.
Address Field Schema
import { defineField, FieldValidators } from '@scalar/field-types';
// Define the address data structureexport interface AddressData { street1: string; street2?: string; city: string; state: string; zipCode: string; country: string; coordinates?: { latitude: number; longitude: number; };}
// Define the address field schemaexport interface AddressFieldSchema { defaultValue?: Partial<AddressData>; availableCountries?: string[]; requireCoordinates?: boolean; formatString?: string;}
// Define address field specific validatorsexport interface AddressFieldValidators extends FieldValidators { validateZipCode?: boolean; requireStreet2?: boolean;}
// Define the field typeexport const addressField = defineField< AddressFieldSchema, AddressFieldValidators, AddressData>({ name: 'address',
defaultConfig: { availableCountries: ['US', 'CA', 'GB'], requireCoordinates: false, formatString: '{{street1}}{{#if street2}}, {{street2}}{{/if}}, {{city}}, {{state}} {{zipCode}}, {{country}}', },
// Custom serialization to JSON toJSON: (value) => { if (!value) return null; return JSON.stringify(value); },
// Custom parsing from JSON fromJSON: (json) => { if (!json) return null; try { return JSON.parse(json); } catch (e) { return null; } },
// Format the address for display format: (value, config) => { if (!value) return '';
// Use the format string to format the address let formatted = config.formatString || '';
// Replace simple tokens Object.entries(value).forEach(([key, val]) => { if (typeof val === 'string') { formatted = formatted.replace(new RegExp(`{{${key}}}`, 'g'), val); } });
// Handle conditionals const conditionalRegex = /{{#if (\w+)}}(.*?){{\/if}}/g; formatted = formatted.replace(conditionalRegex, (match, field, content) => { return value[field] ? content : ''; });
return formatted; },
// Validate the field configuration validateConfig: (config) => { const errors = [];
// Validate format string if (config.formatString && !config.formatString.includes('{{street1}}')) { errors.push('Format string must include {{street1}} token'); }
return { valid: errors.length === 0, errors }; },
// Validate the field value validateValue: (value, config, validators) => { if (!value) { return { valid: true }; }
const errors = [];
// Check required fields ['street1', 'city', 'state', 'zipCode', 'country'].forEach((field) => { if (!value[field]) { errors.push(`${field} is required`); } });
// Check if street2 is required if (validators?.requireStreet2 && !value.street2) { errors.push('street2 is required'); }
// Check if coordinates are required if (config.requireCoordinates && !value.coordinates) { errors.push('Coordinates are required'); }
// Check available countries if ( value.country && config.availableCountries && !config.availableCountries.includes(value.country) ) { errors.push( `Country must be one of: ${config.availableCountries.join(', ')}`, ); }
// Validate zip code format if enabled if (validators?.validateZipCode && value.zipCode) { // Simple zip code validation for US if (value.country === 'US' && !/^\d{5}(-\d{4})?$/.test(value.zipCode)) { errors.push('Invalid US zip code format'); } // Add other country postal code validations as needed }
return { valid: errors.length === 0, errors }; },});The implementation would follow a similar pattern to our color field, but with more complex input and preview components. This example demonstrates the flexibility of Scalar’s custom field API to handle complex, structured data types.
Advanced Field Features
Let’s explore some advanced features you can implement in your custom fields:
1. Remote Data Integration
Fields can fetch data from external sources:
// ProductSkuInput componentconst ProductSkuInput: React.FC<FieldInputProps<string>> = ({ value, onChange, config,}) => { const [skuData, setSkuData] = useState(null); const [loading, setLoading] = useState(false);
// Fetch product data when SKU changes useEffect(() => { if (!value) return;
setLoading(true); fetch(`/api/products/validate-sku?sku=${value}`) .then((res) => res.json()) .then((data) => { setSkuData(data); setLoading(false); }) .catch(() => { setSkuData(null); setLoading(false); }); }, [value]);
return ( <div className="sku-field"> <input type="text" value={value || ''} onChange={(e) => onChange(e.target.value)} />
{loading && <div className="sku-loading">Validating SKU...</div>}
{skuData && ( <div className="sku-data"> <div className="sku-valid">✓ Valid SKU</div> <div className="sku-details"> <div>Product: {skuData.name}</div> <div>Stock: {skuData.stockLevel}</div> <div>Category: {skuData.category}</div> </div> </div> )} </div> );};2. Custom Validation Rules
Implement complex validation logic:
// ISBN field validatorvalidateValue: (value, config, validators) => { if (!value) return { valid: true };
const errors = [];
// Remove hyphens and spaces const cleanISBN = value.replace(/[-\s]/g, '');
// Validate ISBN-10 if (cleanISBN.length === 10) { let sum = 0; for (let i = 0; i < 9; i++) { sum += parseInt(cleanISBN[i]) * (10 - i); }
// Handle X check digit if (cleanISBN[9].toUpperCase() === 'X') { sum += 10; } else { sum += parseInt(cleanISBN[9]); }
if (sum % 11 !== 0) { errors.push('Invalid ISBN-10 check digit'); } } // Validate ISBN-13 else if (cleanISBN.length === 13) { let sum = 0; for (let i = 0; i < 12; i++) { sum += parseInt(cleanISBN[i]) * (i % 2 === 0 ? 1 : 3); }
const checkDigit = (10 - (sum % 10)) % 10; if (parseInt(cleanISBN[12]) !== checkDigit) { errors.push('Invalid ISBN-13 check digit'); } } else { errors.push('ISBN must be 10 or 13 characters (excluding hyphens)'); }
return { valid: errors.length === 0, errors };};3. Compound Fields
Create fields that combine multiple inputs:
// PriceInput componentconst PriceInput: React.FC< FieldInputProps<{ amount: number; currency: string }>> = ({ value, onChange, config }) => { const currentValue = value || { amount: 0, currency: 'USD' };
const handleAmountChange = (e) => { onChange({ ...currentValue, amount: parseFloat(e.target.value), }); };
const handleCurrencyChange = (e) => { onChange({ ...currentValue, currency: e.target.value, }); };
return ( <div className="price-field"> <div className="price-inputs"> <select value={currentValue.currency} onChange={handleCurrencyChange} className="currency-select" > <option value="USD">$</option> <option value="EUR">€</option> <option value="GBP">£</option> <option value="JPY">¥</option> </select>
<input type="number" value={currentValue.amount} onChange={handleAmountChange} step="0.01" min="0" className="amount-input" /> </div> </div> );};4. Rich Media Fields
Create fields for specialized media:
// 360 Degree Product View fieldconst ProductView360Input: React.FC<FieldInputProps<string[]>> = ({ value, onChange, config,}) => { const [images, setImages] = useState(value || []); const [uploadProgress, setUploadProgress] = useState(0);
const handleFileUpload = async (files) => { setUploadProgress(0);
const uploadedUrls = []; let completed = 0;
for (const file of files) { // Upload the file const formData = new FormData(); formData.append('file', file);
const response = await fetch('/api/upload', { method: 'POST', body: formData, onUploadProgress: (progressEvent) => { const progress = (completed + progressEvent.loaded / progressEvent.total) / files.length; setUploadProgress(progress * 100); }, });
const result = await response.json(); uploadedUrls.push(result.url); completed += 1 / files.length; }
// Sort images by filename to ensure correct order const sortedUrls = uploadedUrls.sort();
setImages(sortedUrls); onChange(sortedUrls); setUploadProgress(100); };
return ( <div className="product-view-360"> <div className="upload-zone"> <input type="file" multiple accept="image/*" onChange={(e) => handleFileUpload(e.target.files)} /> <div className="upload-instructions"> Upload 24-36 images taken at equal intervals around the product </div> </div>
{uploadProgress > 0 && uploadProgress < 100 && ( <div className="progress-bar"> <div className="progress" style={{ width: `${uploadProgress}%` }} /> </div> )}
{images.length > 0 && ( <div className="preview-360"> <div className="image-count">{images.length} images</div> <div className="preview-container"> {/* 360 view preview component */} </div> </div> )} </div> );};Sharing and Publishing Field Types
To make your custom fields reusable across projects and teams:
1. Field Type Registries
Create a central registry for your organization’s field types:
// @your-org/scalar-fields/src/index.ts
// Import and re-export all custom fieldsexport { colorField } from './color-field';export { addressField } from './address-field';export { priceField } from './price-field';export { skuField } from './sku-field';// And so on...
// Register all fieldsimport './color-field';import './address-field';import './price-field';import './sku-field';// And so on...2. Documentation
Add comprehensive documentation:
// Documented field typeexport const productSpecificationField = defineField<ProductSpecSchema>({ name: 'productSpec',
/** * Product specification field for standardized technical specifications. * * @example * ```typescript * const Product = defineType({ * fields: { * technicalSpecs: productSpecificationField({ * categories: ['dimensions', 'performance', 'connectivity'], * allowCustomSpecs: true * }) * } * }); * ``` * * @see Documentation at https://your-docs-site.com/fields/product-spec */
// Field implementation...});3. Testing
Add tests to ensure quality:
// Field type testdescribe('colorField', () => { it('validates hex colors correctly', () => { const field = colorField({ allowedFormats: ['hex'], });
const valid = field.validateValue('#FF5733', field.config); expect(valid.valid).toBe(true);
const invalid = field.validateValue('rgb(255, 87, 51)', field.config); expect(invalid.valid).toBe(false); expect(invalid.errors).toContain( 'Value must be a valid color in one of the allowed formats.', ); });
// More tests...});Case Study: Domain-Specific Fields
Here’s how one organization leveraged custom fields for their specific domain:
Healthcare Document Management System
A healthcare provider built custom fields for their clinical documentation system:
1. Medication Field
- Autocomplete from medication database
- Dose calculator with unit conversion
- Interaction checker
- Formulary status indicator
2. Diagnosis Code Field
- ICD-10 code validator
- Hierarchical code browser
- Related codes suggestion
3. Vitals Field
- Range validation by patient demographics
- Trend visualization
- Anomaly highlighting
These custom fields significantly improved data quality and clinical workflow efficiency. By building these as reusable field types, they maintained consistency across their suite of healthcare applications.
Conclusion
Custom field types allow you to extend Scalar’s capabilities to perfectly match your business domain. By creating specialized fields, you can:
- Improve data quality through custom validation
- Enhance the editing experience with domain-specific inputs
- Ensure consistent data structures across your content models
- Reduce implementation complexity for specialized content types
Whether you’re building simple UI enhancements like color pickers or complex domain-specific fields like medical record components, Scalar’s field type system provides the flexibility to meet your needs.
Ready to build your own custom fields? Check out our field type SDK documentation and join our developer community to share your creations with other Scalar developers.
Wrap-up
A CMS shouldn't slow you down. Scalar aims to expand into your workflow — whether you're coding content models, collaborating on product copy, or launching updates at 2am.
If that sounds like the kind of tooling you want to use — try Scalar or join us on Discord .