Blog
February 19, 2025

Building Custom Field Types in Scalar

Extend Scalar's content modeling capabilities with custom fields tailored to your specific business needs.

Fedir DavydovYassine Zaanouni
Fedir Davydov and Yassine Zaanouni
15 mins read

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:

Terminal window
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:

Terminal window
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:

Terminal window
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 .