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-field
cd scalar-color-field
npm init -y
npm install --save-dev @scalar/field-types react typescript
Create a TypeScript configuration:
// tsconfig.json
{
"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:
// src/schema.ts
import { defineField, FieldValidators } from '@scalar/field-types';
// Define the color field schema
export interface ColorFieldSchema {
defaultValue?: string;
allowedFormats?: ('hex' | 'rgb' | 'rgba' | 'hsl')[];
presets?: {
name: string;
value: string;
}[];
}
// Define color field specific validators
export interface ColorFieldValidators extends FieldValidators {
hexOnly?: boolean;
includeAlpha?: boolean;
}
// Define the field type
export 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 colors
function 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:
// src/ColorInput.tsx
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:
// src/ColorPreview.tsx
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:
// src/index.ts
import { registerFieldType } from '@scalar/field-types';
import { colorField } from './schema';
import { ColorInput } from './ColorInput';
import { ColorPreview } from './ColorPreview';
// Register the field type
registerFieldType({
type: colorField,
Input: ColorInput,
Preview: ColorPreview,
});
// Export all components and types
export { 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 build
npm publish
Using 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-field
Registering the Field
In your Scalar configuration:
// scalar.config.ts
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
// content-types.ts
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
// src/schema.ts
import { defineField, FieldValidators } from '@scalar/field-types';
// Define the address data structure
export interface AddressData {
street1: string;
street2?: string;
city: string;
state: string;
zipCode: string;
country: string;
coordinates?: {
latitude: number;
longitude: number;
};
}
// Define the address field schema
export interface AddressFieldSchema {
defaultValue?: Partial<AddressData>;
availableCountries?: string[];
requireCoordinates?: boolean;
formatString?: string;
}
// Define address field specific validators
export interface AddressFieldValidators extends FieldValidators {
validateZipCode?: boolean;
requireStreet2?: boolean;
}
// Define the field type
export 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 component
const 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 validator
validateValue: (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 component
const 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 field
const 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 fields
export { colorField } from './color-field';
export { addressField } from './address-field';
export { priceField } from './price-field';
export { skuField } from './sku-field';
// And so on...
// Register all fields
import './color-field';
import './address-field';
import './price-field';
import './sku-field';
// And so on...
2. Documentation
Add comprehensive documentation:
// Documented field type
export 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 test
describe('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 .