A simple Flutter widget capable of using JSON Schema to declaratively build and customize web forms.
Inspired by react-jsonschema-form
Add dependency to pubspec.yaml
dependencies:
...
flutter_jsonschema_builder: ^0.0.1+1
Run in your terminal
flutter packages get
See the File Picker Installation for file fields.
Use data-url for files and describe the media type in the UI schema:
"video": {
"type": "string",
"format": "data-url",
"title": "Video answer"
}"video": {
"ui:options": {
"fileType": "video",
"accept": ".mp4,.mov,.m4v"
}
}The package handles form state but leaves picking and storage to the app.
Provide a fileHandler that returns SchemaFormFile objects: name is shown
to the user, value is saved in the form data, and bytes can be used for a
preview or upload. The example's compact adapter shows camera, photo-library,
Files, and preview handling in
demo_file_handling.dart.
import 'package:flutter_jsonschema_builder/flutter_jsonschema_builder.dart';
final jsonSchema = {
"title": "A registration form",
"description": "A simple form example.",
"type": "object",
"required": [
"firstName",
"lastName"
],
"properties": {
"firstName": {
"type": "string",
"title": "First name",
"default": "Chuck"
},
"lastName": {
"type": "string",
"title": "Last name"
},
"telephone": {
"type": "string",
"title": "Telephone",
"minLength": 10
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: JsonForm(
jsonSchema: jsonSchema,
onFormDataSaved: (data) {
inspect(data);
},
),
);
} final json = '''
{
"title": "Example 2",
"type": "object",
"properties": {
"listOfStrings": {
"type": "array",
"title": "A list of strings",
"items": {
"type": "string",
"title" : "Write your item",
"default": "bazinga"
}
},
"files": {
"type": "array",
"title": "Multiple files",
"items": {
"type": "string",
"format": "data-url"
}
}
}
}
''';
### Using UI Schema
```dart
final uiSchema = '''
{
"selectYourCola": {
"ui:widget": "radio"
}
}
''';
Conditional fields use the same JSON Schema annotations as RJSF. Put
if/then/else directly on an object, or wrap rules in allOf. Active
branches can add properties and make them required; classic and stepped modes
update automatically.
{
"type": "object",
"properties": {
"pet": {"type": "string", "enum": ["none", "cat"]}
},
"allOf": [{
"if": {
"required": ["pet"],
"properties": {"pet": {"const": "cat"}}
},
"then": {
"properties": {
"petName": {"type": "string", "title": "Pet name"}
},
"required": ["petName"]
}
}]
}Conditions support the common JSON Schema predicates const, enum,
required, nested properties, type, not, allOf, anyOf, oneOf,
string length/pattern, and numeric bounds. The earlier RJSF-compatible
dependencies syntax remains supported.
JsonForm can walk through the form one question at a time — a
conversational, wizard-style experience — instead of rendering everything on
one page.
Enable it with displayMode:
JsonForm(
jsonSchema: jsonSchema,
uiSchema: uiSchema,
displayMode: JsonFormDisplayMode.stepped,
steppedConfig: JsonFormSteppedConfig(
transitionAxis: Axis.vertical, // or Axis.horizontal
showReviewStep: true, // summary page before submitting
),
onFormDataSaved: (data) => inspect(data),
)The stepped mode expands to fill its parent, so give it a bounded height
(a Scaffold body, Expanded, SizedBox...) instead of wrapping it in a
scroll view. It shows a progress bar with a step counter, validates the
current step before advancing, and keeps entered values when navigating back.
Back/next buttons, the progress bar and all labels can be customized through
JsonFormSteppedConfig; the submit button reuses
JsonFormSchemaUiConfig.submitButtonBuilder.
Steps are derived from the structure of the JSON schema itself — a plain schema works in both display modes without any ui schema:
- every scalar field and every array is its own step
- a nested object becomes a single step holding all its fields, and the
object's own
titleanddescriptionrender as the step's header — the same title and description classic mode shows as a section header
{
"type": "object",
"properties": {
"name": {
"type": "object",
"title": "What should we call you?",
"description": "First things first — introduce yourself.",
"required": ["first"],
"properties": {
"first": {"type": "string", "title": "First name"},
"last": {"type": "string", "title": "Last name"}
}
},
"email": {"type": "string", "title": "Email", "format": "email"}
}
}produces two steps — "What should we call you?" with both name fields, then
email — and the submitted data mirrors the schema:
{"name": {"first": ..., "last": ...}, "email": ...}.
Wrapping fields in a nested object also nests the submitted data. When you want
several top-level fields on the same step but a flat data shape, give them a
shared ui:group value in the ui schema:
{
"type": "object",
"properties": {
"first": {"type": "string", "title": "First name", "ui:group": "name"},
"last": {"type": "string", "title": "Last name", "ui:group": "name"},
"email": {"type": "string", "title": "Email", "format": "email"}
}
}first and last share one step (placed where the first group member appears),
while the data stays flat: {"first": ..., "last": ..., "email": ...}. The
group's step takes its ui:media from the first member that declares one.
Each step can show an image or an animation above its fields, declared in the
ui schema with ui:media on a field or on a nested object:
"email": {
"ui:media": {
"type": "image",
"src": "https://example.com/mail.png",
"height": 160,
"fit": "cover"
}
}The package renders the types image (network url) and asset (bundled
asset) out of the box. Any other type is handed to
JsonFormSteppedConfig.mediaBuilder, so the package stays dependency-free
while apps bring their own players — e.g. Lottie:
steppedConfig: JsonFormSteppedConfig(
mediaBuilder: (context, media) {
if (media.type == 'lottie') {
return Lottie.asset(media.src, height: media.height ?? 160);
}
return null; // fall back to the built-in image/asset rendering
},
),See example/lib/main.dart for a runnable demo with both modes, grouped
steps, an image step and a Lottie step.
Customization follows the usual Flutter layering — most apps need nothing beyond their existing theme:
- Ambient
Theme— the defaults are built fromTheme.of(context): the progress bar honorsProgressIndicatorThemeDataandColorScheme.primary, step titles/descriptions useTextTheme.headlineSmall/bodyMedium, and the navigation buttons are plainElevatedButton/TextButton, soElevatedButtonThemeDataetc. apply. A themed app gets a matching stepped form with zero configuration. JsonFormSteppedConfig— behavior and text: transition axis, duration and curve, review step, button labels, and explicitstepTitleStyle/stepDescriptionStyleoverrides (widget style beats theme, as with Material widgets).- Builders — replace whole pieces when styling isn't enough:
progressBuilder,mediaBuilder,nextButtonBuilder,backButtonBuilder, and the existingJsonFormSchemaUiConfig.submitButtonBuilderfor the final button.
The default building blocks are exported as plain widgets —
JsonFormStepProgress, JsonFormStepHeader, JsonFormStepMedia — so a
builder override can compose them instead of starting from scratch:
steppedConfig: JsonFormSteppedConfig(
progressBuilder: (context, current, total) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: JsonFormStepProgress(currentStep: current, totalSteps: total),
),
),customFileHandler: () => {
'profile_photo': () async {
return [
File(
'https://cdn.mos.cms.futurecdn.net/LEkEkAKZQjXZkzadbHHsVj-970-80.jpg')
];
},
'*': null
}As file can be represented as any string, even a URL, so we need a way to convert back that string into an actual file value, we can provide initialFileValueHandler for this case
initialFileValueHandler: () => {
'profile_photo': (dynamic defaultValue) async {
if(defaultValue is List)
// fetch list of images logic here
return;
if(defaultValue is String){
final file = await fetchOurFileFromUrl(defaultValue);
return [SchemaFormFile(name: file.name, bytes: await file.readAsBytes(), value: defaultValue )]
}
},
'*': null
}
Future<List<SchemaFormFile>?> _defaultInitialFileValueHandler(
dynamic defaultValue) async {
Future<SchemaFormFile?> schemaFileFromUrl(String url) async {
// file fetching logic here
}
if (defaultValue is List) {
final result =
await Future.wait(defaultValue.cast<String>().map(schemaFileFromUrl));
return result.whereType<SchemaFormFile>().toList();
}
if (defaultValue is String) {
final file = await schemaFileFromUrl(defaultValue);
if (file != null) return [file];
}
return null;
}customValidatorHandler: () => {
'selectYourCola': (value) {
if (value == 0) {
return 'Cola 0 is not allowed';
}
}
},- Add all examples
- OnChanged
- References
- pub.dev





