While JSON Schema defines the structure and validation of your data, UI Schema controls how the form is presented to users. It determines the layout, arrangement, and styling of form elements.
What is UI Schema?
UI Schema is an optional declarative description of how your form should be rendered. Without a UI Schema, JSON Forms generates a default vertical layout. With UI Schema, you gain fine-grained control over:
Element layout (vertical, horizontal, groups)
Element order and grouping
Element visibility and behavior
Custom options and styling
UI Schema is completely optional. If you don’t provide one, JSON Forms will automatically generate a simple vertical layout from your JSON Schema.
UI Schema Elements
All UI schema elements extend the base interface:
interface BaseUISchemaElement {
type : string ; // Element type
rule ?: Rule ; // Conditional visibility/behavior
options ?: { [ key : string ] : any }; // Custom options
}
Control
A control binds to a data property using a scope:
{
"type" : "Control" ,
"scope" : "#/properties/name" ,
"label" : "Full Name"
}
interface ControlElement extends BaseUISchemaElement {
type : 'Control' ;
scope : string ; // JSON Pointer to data property
label ?: string | boolean | LabelDescription ;
}
Scope syntax:
#/properties/name - Top-level property
#/properties/address/properties/city - Nested property
#/properties/items/items/properties/title - Array item property
Vertical Layout
Stacks elements vertically (top to bottom):
{
"type" : "VerticalLayout" ,
"elements" : [
{ "type" : "Control" , "scope" : "#/properties/firstName" },
{ "type" : "Control" , "scope" : "#/properties/lastName" }
]
}
interface VerticalLayout extends Layout {
type : 'VerticalLayout' ;
elements : UISchemaElement [];
}
Horizontal Layout
Arranges elements horizontally (left to right):
{
"type" : "HorizontalLayout" ,
"elements" : [
{ "type" : "Control" , "scope" : "#/properties/firstName" },
{ "type" : "Control" , "scope" : "#/properties/lastName" }
]
}
interface HorizontalLayout extends Layout {
type : 'HorizontalLayout' ;
elements : UISchemaElement [];
}
Group
Groups elements with an optional label:
{
"type" : "Group" ,
"label" : "Personal Information" ,
"elements" : [
{ "type" : "Control" , "scope" : "#/properties/name" },
{ "type" : "Control" , "scope" : "#/properties/age" }
]
}
interface GroupLayout extends Layout {
type : 'Group' ;
label ?: string ;
elements : UISchemaElement [];
}
Label
Displays static text:
{
"type" : "Label" ,
"text" : "Please fill out all required fields"
}
interface LabelElement extends BaseUISchemaElement {
type : 'Label' ;
text : string ;
}
Categorization
Creates tabbed or stepped navigation:
{
"type" : "Categorization" ,
"elements" : [
{
"type" : "Category" ,
"label" : "Personal" ,
"elements" : [
{ "type" : "Control" , "scope" : "#/properties/name" }
]
},
{
"type" : "Category" ,
"label" : "Address" ,
"elements" : [
{ "type" : "Control" , "scope" : "#/properties/street" }
]
}
]
}
interface Categorization extends BaseUISchemaElement {
type : 'Categorization' ;
label : string ;
elements : ( Category | Categorization )[];
}
interface Category extends Layout {
type : 'Category' ;
label : string ;
elements : UISchemaElement [];
}
Scopes and Data Binding
Scopes use JSON Pointer syntax to reference schema properties:
Simple Property
Nested Property
Array Items
// Schema
{
"type" : "object" ,
"properties" : {
"name" : { "type" : "string" }
}
}
// UI Schema
{
"type" : "Control" ,
"scope" : "#/properties/name"
}
// Schema
{
"type" : "object" ,
"properties" : {
"address" : {
"type" : "object" ,
"properties" : {
"city" : { "type" : "string" }
}
}
}
}
// UI Schema
{
"type" : "Control" ,
"scope" : "#/properties/address/properties/city"
}
// Schema
{
"type" : "object" ,
"properties" : {
"comments" : {
"type" : "array" ,
"items" : {
"type" : "object" ,
"properties" : {
"text" : { "type" : "string" }
}
}
}
}
}
// UI Schema for array control
{
"type" : "Control" ,
"scope" : "#/properties/comments"
}
JSON Forms automatically converts scopes to data paths internally using the toDataPath utility from packages/core/src/util/path.ts.
Rules
Rules allow conditional visibility and behavior:
interface Rule {
effect : RuleEffect ; // HIDE, SHOW, ENABLE, DISABLE
condition : Condition ; // When to apply the effect
}
enum RuleEffect {
HIDE = 'HIDE' ,
SHOW = 'SHOW' ,
ENABLE = 'ENABLE' ,
DISABLE = 'DISABLE'
}
Leaf Condition
Based on a specific value:
{
"type" : "Control" ,
"scope" : "#/properties/vegetarianOptions" ,
"rule" : {
"effect" : "SHOW" ,
"condition" : {
"type" : "LEAF" ,
"scope" : "#/properties/vegetarian" ,
"expectedValue" : true
}
}
}
interface LeafCondition extends BaseCondition {
type : 'LEAF' ;
scope : string ;
expectedValue : any ;
}
Schema-Based Condition
Based on JSON Schema validation:
{
"type" : "Control" ,
"scope" : "#/properties/postalCode" ,
"rule" : {
"effect" : "ENABLE" ,
"condition" : {
"scope" : "#/properties/country" ,
"schema" : {
"const" : "US"
}
}
}
}
interface SchemaBasedCondition extends BaseCondition {
scope : string ;
schema : JsonSchema ;
failWhenUndefined ?: boolean ;
}
Composable Conditions
Combine multiple conditions:
{
"rule" : {
"effect" : "SHOW" ,
"condition" : {
"type" : "OR" ,
"conditions" : [
{
"type" : "LEAF" ,
"scope" : "#/properties/isStudent" ,
"expectedValue" : true
},
{
"type" : "LEAF" ,
"scope" : "#/properties/age" ,
"expectedValue" : 65
}
]
}
}
}
interface OrCondition extends ComposableCondition {
type : 'OR' ;
conditions : Condition [];
}
interface AndCondition extends ComposableCondition {
type : 'AND' ;
conditions : Condition [];
}
Options
Options provide renderer-specific customization:
{
"type" : "Control" ,
"scope" : "#/properties/comments" ,
"options" : {
"multi" : true ,
"rows" : 5
}
}
Common options:
multi: true - Multi-line text input
format: "date" - Override format
slider: true - Render number as slider
detail - Custom detail view for arrays
Complete Example
Here’s a real-world example combining multiple concepts:
{
"type" : "VerticalLayout" ,
"elements" : [
{
"type" : "HorizontalLayout" ,
"elements" : [
{
"type" : "Control" ,
"scope" : "#/properties/name"
},
{
"type" : "Control" ,
"scope" : "#/properties/personalData/properties/age"
}
]
},
{
"type" : "Group" ,
"label" : "Additional Information" ,
"elements" : [
{
"type" : "HorizontalLayout" ,
"elements" : [
{
"type" : "Control" ,
"scope" : "#/properties/birthDate"
},
{
"type" : "Control" ,
"scope" : "#/properties/nationality"
}
]
}
]
},
{
"type" : "Control" ,
"scope" : "#/properties/vegetarian"
},
{
"type" : "Control" ,
"scope" : "#/properties/vegetarianOptions" ,
"rule" : {
"effect" : "SHOW" ,
"condition" : {
"type" : "LEAF" ,
"scope" : "#/properties/vegetarian" ,
"expectedValue" : true
}
}
}
]
}
TypeScript Interfaces
From packages/core/src/models/uischema.ts:
type UISchemaElement =
| BaseUISchemaElement
| ControlElement
| Layout
| LabelElement
| GroupLayout
| Category
| Categorization
| VerticalLayout
| HorizontalLayout ;
interface Scopable {
scope ?: string ;
}
interface Scoped extends Scopable {
scope : string ; // Required
}
interface Labelable < T = string > {
label ?: string | T ;
}
Internationalization
UI Schema supports i18n keys:
{
"type" : "Control" ,
"scope" : "#/properties/name" ,
"i18n" : "person.name"
}
JSON Forms will look for translations like:
person.name.label
person.name.description
See the i18n documentation for details.
Best Practices
Begin with just a JSON Schema and let JSON Forms generate the default layout. Only add UI Schema when you need custom layouts.
Avoid deeply nested layouts. Use Groups to organize related fields instead of complex nesting.
Complex rule logic can make forms hard to maintain. Consider breaking complex forms into multiple steps.
Use options to customize renderer behavior without creating custom renderers.
Next Steps
Renderers Learn how renderers interpret UI Schema elements
Data Binding Understand how scopes connect to your data