simple-form-vue
v2.1.3
Published
A recursive JSON-schema-driven form renderer for Vue 3
Maintainers
Readme
Simple Form: JSON to HTML form
- Introduction
- Key Features
- Demo
- Installation
- Usage
- Supported Inputs
- Customization
- Types
- Best Practices
- Feature Requests and Reporting issues
Introduction
SimpleForm is a Vue3 library that simplifies the creation of dynamic forms from JSON. It provides a flexible and customizable way to generate HTML forms based on structured data, allowing developers to easily build complex forms with minimal code.
Key Features
- Generates HTML forms from JSON
- Supports configuration of input fields with dynamic properties
- Offers both lazy and greedy validation modes and async validators
- Handles array values and deeply nested objects, including mixed-shape arrays with a per-row type selector
- Includes a text separator for nested attributes
- Allows adding and removing array values dynamically with separate slots for each control
- Comes with built-in support for custom input components (string path or functional components)
- Provides extensive customization through props, events, and 13 named slots
- Fully typed with TypeScript; ships with type guards, enums, and slot shapes
- Exposes the resolved schema via
form.getTemplate()
Demo
A demo of SimpleForm shows several features of the library:
- Basic form usage
- Advanced configuration with
inputDefinitions - Custom components
- Array handling (simple and complex)
- Validation examples
- Slot usage for customization
- Event handling
Installation
Either install it directly from NPM
npm install simple-form-vueor clone this repo
git clone https://gitlab.com/acvm007/SimpleFormAs this library ships the CSS separately, you need to import it once in the root of your app.
import simple-form-vue/dist/style.css
Usage
After installing the package, it can be imported into your Vue project.
Basic Usage
The most basic usage renders basic HTML inputs and uses generated field labels.
<script setup>
import { ref } from "vue";
import SimpleForm from "simple-form-vue";
const loginData = ref({
username: "",
password: "",
});
</script>
<template>
<SimpleForm v-model="loginData"></SimpleForm>
</template>This simple example produces a basic form with a username and password field with no validation. The forms submit button
is disabled as long as the entered data matches the previously submitted/initial data.

Advanced Usage
To customize the form, use the inputDefinitions prop to configure field properties, validations, and more.
<script setup lang="ts">
import { ref, h } from "vue";
import SimpleForm from "simple-form-vue";
import { ref as vueRef } from "vue";
const formData = vueRef({
username: "",
password: "",
loginType: "",
remember: false,
});
const specialChars = [
"!",
"@",
"#",
"$",
"%",
"^",
"&",
"*",
"(",
")",
"_",
"+",
"=",
];
const inputDefinitions = {
// Global attributes affecting all fields
attrs: {
label: "SimpleForm Test - Login Form",
// Disable all fields until a login type is selected
disabled: ({ data }) => !data.loginType.isValid,
},
// Global validation applied to all fields - `validations: false` skips the global validation for individual fields
validations: {
validator: (value) => !!value,
message: (attrs) => `GLOBAL VALIDATION: ${attrs.name} is required!`,
isLazy: false,
},
// Login type selection field
loginType: {
//This overwrites the global form validations above
validations: {
validator: (val) => !!val || "Type of login required!",
isLazy: false,
},
attrs: {
order: 0,
type: "select",
label: "Select the type of login to use",
disabled: false, //This overwrites the global form attribute above
options: [
{
label: "Select the type of login",
value: "",
disabled: true,
},
"Email",
{
label: "Username",
value: "text",
},
],
},
},
// Username field with dynamic type based on selected loginType
username: {
// Basic required and length validation
validations: [
{
isLazy: false,
validator: (value) => {
if (!value) {
return "Username is required!";
}
if (typeof value === "string" && value.length <= 2) {
return "Username must be at least 3 characters!";
}
return true;
},
},
{
// Email validation is only applied if the loginType is email
validator: (value, fields) => {
if (fields.loginType.value === "email") {
return (
/^[\w-.]+@([\w-]+.)+[\w-]{2,4}$/.test(String(value)) ||
"Username must be a valid email!"
);
}
return true;
},
},
],
attrs: {
// Dynamically change input type based on loginType selection
type: ({ data }) => {
if (data.loginType.isValid) {
return (data.loginType.value as string).toLowerCase();
}
return "hidden"; // Only the label is shown until a loginType has been selected
},
label: () => "Username",
},
},
// Password field with complex validation
password: {
validations: [
{
validator: (value) => !!value || "Password is required!",
},
{
validator: (value) => {
const password = value as string;
return password.length > 8 && password.length < 16;
},
message: "The password must be between 8 and 16 characters!",
},
{
validator: (value) => {
return new RegExp(`[${specialChars.join("")}]`).test(value as string);
},
message: `The password must contain at least one special character (${specialChars.join(", ")})`,
},
],
attrs: {
type: "password",
label: ({ data }) => {
const base = "Password";
// Show standard label when username is valid
if (data?.username?.isValid) {
return base;
}
// Show disabled message when username is invalid
return [
h("p", { style: { margin: 0, textAlign: "center" } }, base),
h(
"span",
{ style: { display: "block" } },
"(disabled as long as username is invalid)",
),
];
},
description: ({ data }) => {
if (data?.username?.isValid) {
return null;
}
// Show hint when username is invalid
return h("div", [
h(
"p",
{ style: { margin: 0, textAlign: "center", color: "lightblue" } },
"Disabled",
),
]);
},
// Disable password field when username is invalid
disabled: ({ data }) => !data.username.isValid,
},
},
// Remember me checkbox
remember: {
attrs: {
type: "checkbox",
label: "Remember me",
},
},
};
</script>
<template>
<SimpleForm
v-model="formData"
:input-definitions="inputDefinitions"
></SimpleForm>
</template>Accessing the Template
To inspect the resolved schema the form is rendering against, call the
getTemplate() method on the component ref. It returns the schema (resolved field definitions keyed by dotted path)
and the
uiSchema (the meta information for the layout).
<script setup lang="ts">
import { ref } from "vue";
import SimpleForm from "simple-form-vue";
import type { Template } from "simple-form-vue";
const form = ref<InstanceType<typeof SimpleForm> | null>(null);
const formData = ref({
username: "",
password: "",
});
function logTemplate() {
if (!form.value) return;
const { schema, uiSchema } = form.value.getTemplate();
console.log({ schema, uiSchema });
}
</script>
<template>
<SimpleForm ref="form" v-model="formData">
<template #submit>
<button @click="logTemplate" type="button">Show Template</button>
</template>
</SimpleForm>
</template>The returned shape is Omit<Template, "template">
{
schema: Record<string, FieldDefinition>;
uiSchema: UiDefinition[];
}schema— every leaf field the renderer discovered, keyed by its dotted path (e.g."contacts.email"). This is used to walk per-field definitions (dataType, attrs, validations, defaultValue).uiSchema— the tree ofUiDefinitionnodes the renderer walks, withpath,children,attrs, anddataType. Arrays expose anitemsarray describing each shape the renderer can render as a row.
Supported Inputs
This library supports all HTML Input types out of the
box. Any attributes they accept can be passed as part of the inputDefinitions.attrs object. For select and radio
inputs, the options must be provided as an array of OptionType values.
export type OptionType = string | OptionObject;
export type OptionObject = {
label: string;
value: string | number | boolean;
disabled?: boolean;
};Beyond the standard HTML inputs, this library also supports three custom inputs:
{type: 'timeTag'}: Time Tag{type: 'checkboxGroup'}: A group of checkboxes
{type: 'multiSelect'}: A custom multi select similar to the checkboxGroup input
The last two inputs emit their values as an array of strings, which represents the selected options. Additional form controls may be added in future releases.
Customization
To allow for customization, the component provides several props, events, and slots.
Attributes
The modelValue/v-model for the component is the JavaScript object used to generate the form and synchronize values
using the two-way binding of Vue.js. By using vue.js two way binding it is also possible to update the model without
having a traditional Submit button. The model updates on every form input.
Besides this required prop, the SimpleForm component accepts the following optional props:
| Prop | Description | Type | Default Value |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------- |
| errorClass | CSS classname for error styling | String | 'sfm__error' |
| ignoredAttributes | Array of object attributes to ignore when generating the form | Array | [] |
| showFooter | Whether to show or hide the form footer | Boolean | true |
| showNormalSeparatorSlots | Whether to use the normal before/after slots for text separators | Boolean | true |
| noElementsText | Text displayed when no array elements are present | String | 'No Array elements to display' |
| defaultInvalidText | Default message when field validation fails and no message was provided | String | 'This input has an error!' |
| submitLabel | Label for the form's submit button | String | 'Submit' |
| submitDisable | Disabled state of the form's submit button | Boolean | false |
| inputDefinitions | Configuration for each input field, including validations and attributes. The shape of the object has to follow model passed to the form | Object | {} |
| components | Configuration for custom components. Uses 'condition' to determine when to use built-in vs custom input components | Object | {} |
| isDisabled | Whether to disable the entire form | Boolean | false |
| isLoading | Whether the entire form is in a loading state | Boolean | false |
| useLazyValidation | When true, hide the field's error message until the field has been touched by the user (lazy). When false, show errors immediately, including on the initial render with empty values (greedy). See Lazy vs. greedy validation | Boolean | false |
| multiTypeSelectLabel | Label shown for mixed-type arrays before adding a value | String | 'This array has multiple types. Select a type to add' |
| showAsyncLoader | Wether to show a loader across fields currently evaluating an async prop | Boolean | true |
Global Attributes
Global attributes can be applied to the form or all fields at once by defining a attrs object at the root level of
inputDefinitions, or inside a ROOT key. Both approaches are equivalent:
Directly at the root level:
export default {
attrs: {
label: "Form Title",
disabled: ({ data }) => {
return !data.someField.isValid;
},
},
username: {/* field config */},
// ... other fields
};Via the root key:
export default {
ROOT: {
attrs: {
label: "Form Title",
disabled: ({ data }) => {
return !data.someField.isValid;
},
},
},
username: {/* field config */},
// ... other fields
};[!NOTE] Global attributes are applied to every child field that doesn't already define that prop itself. A prop defined directly on a field always takes precedence over a global prop.
[!NOTE] The
orderattribute cannot be set globally.
Events
SimpleForm emits events that can be listened to using @<EventName>:
| Event | Description | Payload |
| ------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| update:modelValue | Emitted whenever the v-model changes due to user interaction | The entire modified model object. Due to the two way binding, this rarely has to be called explicitly |
| submit | Emitted when submitting the form | The entire modified model object |
| isValid | Emitted when form validity changes | Boolean indicating form validity |
| isDirty | Emitted when form data changes (dirty state) | Boolean indicating if the form has been touched by the user |
| isLoading | Emitted when form loading state changes | Boolean indicating the loading state of the form |
For convenience, the library exports the enum CustomEvents that can be used instead of literal strings inside the script block.
export enum CustomEvents {
IS_DIRTY = "isDirty",
IS_VALID = "isValid",
IS_LOADING = "isLoading",
UPDATE_MODEL = "update:modelValue",
SUBMIT = "submit",
}Example Events:
<script setup lang="ts">
import { ref } from "vue";
import SimpleForm from "simple-form-vue";
const formData = ref({/* ... */});
function handleValidityChange(isValid: boolean) {
console.log("Form validity changed:", isValid);
}
function handleLoadingChange(isLoading: boolean) {
console.log("Form loading changed:", isLoading);
}
function handleDirtyChange(isDirty: boolean) {
console.log("Form dirtiness changed:", isDirty);
}
function handleSubmit() {
console.log(`Form submitted ${new Date().toLocaleString()}:`, model.value);
alert("Data submitted, check the console for the actual data.");
}
</script>
<template>
<SimpleForm
v-model="formData"
@submit="handleSubmit"
@is-valid="handleValidityChange"
@is-loading="handleLoadingChange"
@is-dirty="handleDirtyChange"
/>
</template>Slots
The component exposes 13 slots for deep customization of form rendering. Each slot provides specific contextual data:
| Slot Name | Slot prop shape | Description |
| ------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| top | — | An elemnt to display above the form |
| bottom | — | An elemnt to display below the form |
| error-message | { message: string } | Customizes the error message displayed globally across all invalid input field |
| empty-array | — | Customizes the rendering of empty arrays |
| add-button | { onClick: () => void, disabled: boolean } | Replaces the "add" button rendered for each array field |
| remove-button | { onClick: () => void, disabled: boolean } | Replaces the "remove" button rendered for each array field |
| multi-type-select | { options: OptionObject[], disabled: boolean, onChange: (value: PrimitiveType) => void } | Replaces the per-row type picker shown for mixed-type arrays |
| before | { path: string, attrs: FieldAttrs \| string, type: 'field' \| 'separator' } | Element to the left of an input field or text separator |
| after | { path: string, attrs: FieldAttrs \| string, type: 'field' \| 'separator' } | Element to the right of an input field or text separator |
| separator-before | { path: string, attrs: FieldAttrs \| string, type: 'separator' } | Element to the left of a text separator |
| separator-after | { path: string, attrs: FieldAttrs \| string, type: 'separator' } | Element to the right of a text separator |
| submit | { isValid: boolean, isDirty: boolean, isLoading: boolean, isDisabled: boolean } | Customizes the submit button — render an element with type="submit" |
| form-footer | { isValid: boolean, isDirty: boolean, isLoading: boolean, isDisabled: boolean } | Customizes the entire form footer |
[!NOTE] The
before/afterslots render for fields and text separators by default. Set theshowNormalSeparatorSlotsprop tofalseto only render these slots for fields.
For convenience, the library exports the enum SlotNames that can be used instead of literal strings.
export enum SlotNames {
TOP = "top",
BOTTOM = "bottom",
EMPTY_ARRAY = "empty-array",
ADD_BUTTON = "add-button",
REMOVE_BUTTON = "remove-button",
MULTI_TYPE_SELECT = "multi-type-select",
ERROR_MESSAGE = "error-message",
BEFORE = "before",
AFTER = "after",
SEPARATOR_BEFORE = "separator-before",
SEPARATOR_AFTER = "separator-after",
SUBMIT = CustomEvents.SUBMIT,
FOOTER = "form-footer",
}Example Slots
Custom error message display:
The following examples are equivalent. One uses the literal string for the slot error-message and the other uses the enum value exported by the library.
<template>
<SimpleForm ...>
<template #error-message="{ message }">
<div role="alert">
<span>An error in the field occurred: {{ message }}</span>
</div>
</template>
</SimpleForm>
</template><template>
<SimpleForm ...>
<template #[SlotNames.ERROR_MESSAGE]="{ message }">
<div role="alert">
<span>An error in the field occurred: {{ message }}</span>
</div>
</template>
</SimpleForm>
</template>Custom array buttons (add and remove):
<template>
<SimpleForm v-model="formData">
<!-- Each array field renders these slots -->
<template #add-button="{ onClick, disabled }">
<button
type="button"
class="btn-add"
:disabled="disabled"
@click="onClick"
>
Add item
</button>
</template>
<template #remove-button="{ onClick, disabled }">
<button
type="button"
class="btn-remove"
:disabled="disabled"
@click="onClick"
>
Remove
</button>
</template>
</SimpleForm>
</template>Custom mixed-type picker:
<template>
<SimpleForm v-model="formData">
<template #multi-type-select="{ options, disabled, onChange }">
<ul class="type-picker">
<li v-for="opt in options" :key="String(opt.value)">
<button
type="button"
:disabled="disabled || opt.disabled"
@click="onChange(opt.value)"
>
{{ opt.label }}
</button>
</li>
</ul>
</template>
</SimpleForm>
</template>Custom field prepend / append:
<template>
<SimpleForm v-model="formData">
<template #before="{ path, type }">
<span
v-if="type === 'field' && path.endsWith('price')"
class="input-prefix"
>$</span
>
</template>
<template #after="{ path, type }">
<span
v-if="type === 'field' && path.endsWith('percentage')"
class="input-suffix"
>%</span
>
</template>
</SimpleForm>
</template>Custom submit / footer:
<template>
<SimpleForm v-model="formData" @submit="handleSubmit">
<template #submit="{ isValid, isDisabled, isLoading }">
<button type="submit" :disabled="isDisabled">
<span v-if="isLoading">Saving…</span>
<span v-else-if="isValid">Save changes</span>
<span v-else>Fill required fields</span>
</button>
</template>
<template #form-footer="{ isDirty }">
<footer class="form-footer">
<span v-if="isDirty" class="dirty-indicator">Unsaved changes</span>
<!-- default submit is replaced if you render one above -->
</footer>
</template>
</SimpleForm>
</template>Input Fields
The inputDefinitions object allows customization of each input field. Every field (including root) has these
attributes:
| Attribute | Description | Default Value |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
| renderChildren | Determines whether children of a nested object should be rendered as individual inputs. If false, a defaultValue must be provided! | true |
| defaultValue | Default value when the field is initially generated | String: " "Number: 0Boolean: falseObject: {}Array: [] |
| attrs | Attributes for the input (see Attribute Functions below) | - |
| validations | Validation rules for the field (see Validations below) | - |
Attributes
Input field attributes define how the field renders and behaves. Each prop can be a constant value or an (async) function returning that value.
| Prop Name | Type | Description |
| ------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| show | boolean \| (args) => boolean \| Promise<boolean> | Controls whether to render or hide the field |
| type | string \| (args) => string \| Promise<string> | HTML input type (e.g., "text", "email", "password", "checkbox", "select"). Use "timeTag" for an HTML <time> element |
| label | string \| VNode \| null \| (args) => string \| VNode \| null \| Promise<string \| VNode \| null> | Field label. Can be string, VNode (via h() function), or null to hide |
| description | string \| VNode \| null \| (args) => string \| VNode \| null \| Promise<string \| VNode \| null> | Field description/help text |
| showError | boolean \| (args) => boolean \| Promise<boolean> | Override field-level error message visibility |
| order | number | Field position in form (zero-based index). Cannot be set globally |
| datetime | string \| (args) => string \| Promise<string> | Used together with type: "timeTag" to set the datetime attribute |
| ... | any | Additional HTML attributes (refer to MDN input documentation) |
Attribute Functions
Field attributes that are functions receive one argument FieldAttrFunctionArgument these properties:
| Parameter | Type | Description |
| --------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| value | PrimitiveType | Current value of the field |
| isValid | boolean | Current validity state of the field |
| data | ValidityValueObject | Contains { value, isValid } for each field in the form, enabling cross-field references like data.username.isValid |
Return values depend on the prop being set:
- For boolean attributes (i.e.
show,disabled): return boolean - For
options: return Array ofOptionTypeobjects - For
labelordescription: return string, VNode, or null (null skips rendering) - For other attributes: return appropriate types (string, number, etc.)
Examples
// Dynamic input type based on another field
type: ({ data }) => {
if (data.accountType.value === "business") return "text";
if (data.accountType.value === "personal") return "tel";
return "text";
};
// Conditional label based on field value
label: ({ value }) => (value === "other" ? "Please specify:" : "Category");
// Show field only when another field has specific value
show: ({ data }) => data.country.value === "US" && data.state.value === "CA";
// Dynamically set options based on form state
options: ({ data }) => {
if (data.selectedCategory === "fruits") {
return ["Apple", "Banana", "Orange"];
}
if (data.selectedCategory === "colors") {
return ["Red", "Green", "Blue"];
}
return [];
};Async Attribute Functions
Any prop that accepts a function also accepts an async function — the type is
MaybeFunction<T> = T | ((args) => T | Promise<T>). Async functions are useful when the value depends on remote data:
{
// Async label computed from a remote lookup
label: async ({value}) => {
if (!value) return null;
const user = await fetchUser(value);
return user?.displayName ?? value;
},
// Async options list
options
:
async ({data}) => {
const res = await fetch(`/api/options?category=${data.category.value}`);
return res.json();
}
}The isLoading prop is passed to the input accordingly. At the same time, the class smf__loading is being set on the
outer sfm__input-wrapper div to allow CSS styling. Fields evaluating an async prop function will be automatically
disabled. Additionally, they will show the loading component over the field. This behaviour can be changed by setting
the show-async-loader prop to false.
Validations
Validations determine when a field is considered valid. They can be defined as a single object or as an array of objects, which all must pass (logical AND).
Each validation object requires a validator: (val: PrimitiveType, fields: ResolvedFormFields) function that returns
boolean or string (error message), or a Promise thereof.
The validator function receives:
value: Current field valuefields: Object containing{ value, isValid, ... }for each field in the form (enables cross-field validation)- Besides this required attribute, there are also optional validation attributes:
isLazy(boolean|(fields, name) => boolean | Promise<boolean>): Override the globaluseLazyValidationsetting for this validation only. See Lazy vs. greedy validation. When multiple validations are applied to the same field, the first one processed wins for the field's error-visibility flag.message(string|(attrs: FieldAttrs) => string | Promise<string>): Error message to display when validator returns booleanfalse. Can be a function receiving the field attributes.
Note that useLazyValidation (and the per-validation isLazy override) gates when the error message is displayed,
not when the validator runs. Validators always run on mount, on every keystroke, and whenever a cross-field dependency
changes — regardless of the lazy setting.
Validation return values:
- Return `true`` or any non-string truthy value → field is valid
- Return
falseor non-empty string → field is invalid, if a string is returned, it is shown as error - Return empty string → field is valid (useful when using
messageattribute) - Return
Promiseresolving to any of the above values → async validation
Global Validations
Like attributes, validations can be applied globally using the root level or ROOT key:
{
// Applies to ALL fields unless overridden
validations: [{
validator: (value) => !!value,
message: (attrs) => {
return `GLOBAL VALIDATION: ${attrs.name} is required!`;
},
isLazy: false
}],
username
:
{ /* field-specific config */
}
,
// ... other fields
}Setting validations: false on a subsequent form field, disables the global validation only for that field.
Validation Examples
Basic required validation:
validations: {
validator: (value) => !!value || "This field is required!";
}Email format validation:
validations: {
validator: (value) =>
/^[\w-.]+@([\w-]+.)+[\w-]{2,4}$/.test(String(value)) ||
"Please enter a valid email address";
}Password strength validation:
validations: [
{
validator: (value) => {
const password = value as string;
if (password.length < 8) return "Password must be at least 8 characters";
if (!/[A-Z]/.test(password))
return "Password must contain at least one uppercase letter";
if (!/[a-z]/.test(password))
return "Password must contain at least one lowercase letter";
if (!/[0-9]/.test(password))
return "Password must contain at least one number";
if (!/[!@#$%^&*]/.test(password))
return "Password must contain at least one special character";
return true;
},
},
];Cross-field validation (confirm password):
validations: {
validator: (value, fields) => {
return value === fields.password.value || "Passwords must match";
};
}Async validation (check username availability):
validations: {
validator: async (value) => {
if (!value) return "Username is required";
try {
const response = await fetch(`/api/users/check?username=${value}`);
const data = await response.json();
if (data.available) {
return true;
}
return "Username is already taken";
} catch (error) {
return "Unable to check username availability";
}
};
}Conditional validation based on another field:
validations: {
validator: (value, fields) => {
// Only require tax ID if business type is selected
if (fields.businessType.value === "corporation") {
return !!value || "Tax ID is required for corporations";
}
return true; // Not required for other business types
};
}Lazy vs. greedy validation
useLazyValidation controls when the error message is visible, not when the validator executes. The validator runs
on mount, on every keystroke, and on cross-field changes either way — the lazy setting only suppresses the rendered
error until the field has been touched.
- Lazy (
useLazyValidation: true, the default) — the error is hidden until the field becomesisDirty. A field is marked dirty the first time the user edits it. This means a freshly-rendered form with empty values will not show "Required" until the user types something (and then clears it, or types invalid input). - Greedy (
useLazyValidation: false) — the error is shown as soon as the validator reports invalidity. This includes the very first render with empty values, which is what you usually want for required-field prompts.
A field's isDirty flag flips to true only on user-initiated update:modelValue events. Programmatic updates to the
form's v-model from outside do not mark the field dirty — so a lazy field can hold a non-empty value with the error
still hidden until the user actually edits it.
Compare the two for a typical required validator:
// Lazy (default): "Required" is invisible until the user touches the field
useLazyValidation: true,
validations
:
{
validator: (value) => !!value || "This field is required",
}
// Greedy: "Required" shows on initial render and after every change
useLazyValidation: false,
validations
:
{
validator: (value) => !!value || "This field is required",
}A single validation can override the global setting with isLazy:
validations: {
validator: (value) => /^[\w-.]+@([\w-]+.)+[\w-]{2,4}$/.test(String(value)) || "Invalid email",
isLazy
:
false, // show email format errors immediately, even if other validations stay lazy
}isLazy also accepts a function for cases where the choice should depend on other fields:
validations: {
validator: (value) => !!value || "Tax ID is required",
isLazy
:
(fields) => fields.businessType.value !== "individual",
}When a field has multiple validations, the first one processed determines the field's error-visibility flag for that cycle, so order matters when mixing lazy and non-lazy rules on the same field.
Input Components
The components object enables conditional rendering of custom components for inputs. Each component definition can
include:
| Property | Description |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| component | A functional component (props: CustomComponentProps, context: SetupContext) => VNode |
| loader | An import function passed to defineAsyncComponent. This can be used to load a fiel form outside the library |
| condition | (type, name?) => boolean — when omitted, the custom component is selected by matching the field's type to the key in components |
| useCustomLabel | boolean — when true, the library skips rendering the default <label> for the field (default: false) |
| useCustomError | boolean — when true, the library skips rendering the default error message under the field (default: false) |
[!NOTE] The keys
loaderandseparatorare reserved by the library and used to override its built-in loader and text-separator components respectively. To override the loader, supply acomponents.loader.component— theconditionis irrelevant because the library filters those keys out during normal lookup.
Component Functions
A functional component receives:
props: CustomComponentProps—{ modelValue, name, attrs, validationResult }context: SetupContext— the second argument of any setup function. Usecontext.emitto dispatch updates.
The function should return either:
- A VNode via
h()(the standard pattern) - A Vue component instance
Custom components must emit update:modelValue with the new value to keep the form's v-model synchronized. To
stay consistent across the codebase, prefer the CustomEvents.UPDATE_MODEL enum value over the literal string.
The attrs object is a ResolvedFieldAttrs — every field prop has been evaluated (sync or async), so attrs.label is
either a string, a VNode, or null, never a function.
If your custom component needs to render the field's resolved label or description (which can be a string, a VNode,
or a lazy
{ fn, args } resolver), use the exported RenderDataFnComponent, a functional component that handles all three shapes
for you:
<script setup lang="ts">
import {
CustomComponentProps,
RenderDataFnComponent,
RenderDataInput,
} from "simple-form-vue";
const model = defineModel();
const { attrs, validationResult } = defineProps<CustomComponentProps>();
defineEmits<{ "update:modelValue": [unknown] }>();
</script>
<template>
<label :for="attrs.id">
<RenderDataFnComponent :data="attrs.label as RenderDataInput" />
</label>
<input v-model="model" :id="attrs.id" type="email" />
<RenderDataFnComponent v-if="attrs.description" :data="attrs.description" />
<p v-if="!validationResult.isValid">{{ validationResult.message }}</p>
</template>Extending Component Attribute Types
Every custom component receives a ResolvedFieldAttrs object typed strictly against the library's built-in FieldAttrs
shape. If your component needs its own props like a toggle's checkedLabel/uncheckedLabel or anything else that isn't
a standard HTML input attribute CustomComponentProps accepts a generic parameter, so those extra props are typed and
autocompleted just like the built-in ones.
export type CustomComponentProps<Attrs = {}> = {
modelValue: any;
name: string;
attrs: ResolvedFieldAttrs<Attrs> & CommonAttrs;
validationResult: ValidationResult;
};Pass your component-specific shape as the generic argument:
<script setup lang="ts">
import type { CustomComponentProps } from "simple-form-vue";
type ToggleAttrs = {
checkedLabel?: string;
uncheckedLabel?: string;
};
const props = defineProps<CustomComponentProps<ToggleAttrs>>();
defineEmits<{ "update:modelValue": [boolean] }>();
</script>
<template>
<button type="button" @click="$emit('update:modelValue', !modelValue)">
{{ modelValue ? props.attrs.checkedLabel : props.attrs.uncheckedLabel }}
</button>
</template>props.attrs.checkedLabel and props.attrs.checkedLabel are now typed as
string | undefined — a typo like onLbel is now a compiler error instead of a silent undefined at runtime.
Declaring extended attrs in inputDefinitions
The same generic exists on CustomInputAttributes (and therefore
FieldAttrs), so the attrs object you write inside
inputDefinitions can be typed against the same extension:
import type { CustomInputAttributes } from "simple-form-vue";
type ToggleAttrs = {
checkedLabel?: string;
uncheckedLabel?: string;
};
const inputDefinitions = {
useCustomComponents: {
attrs: {
label: "Components",
checkedLabel: "Custom",
uncheckedLabel: "Default",
} as CustomInputAttributes<ToggleAttrs>,
},
};[!NOTE] Pick names that don't collide with a real HTML attribute.
FieldAttrsalready includes every key fromInputHTMLAttributes(checked,value,disabled,readonly,type, and so on), and a named property always wins over an extension generic in an intersection. ATogglecomponent usingchecked/uncheckedas label props, for example, will silently collide with the native checkboxcheckedattribute — prefer distinct names likecheckedLabel/uncheckedLabelinstead.
Component Examples
Functional component: number input with increment / decrement buttons
import {
ComponentDefinition,
CustomComponentProps,
CustomEvents,
} from "simple-form-vue";
import { h, SetupContext } from "vue";
const components: ComponentDefinition = {
number: {
condition: (type: string) => type === "number",
component: (props: CustomComponentProps, { emit }: SetupContext) => {
const { attrs, modelValue } = props;
const value = modelValue as number;
const min = (attrs.min as number | undefined) ?? -Infinity;
const max = (attrs.max as number | undefined) ?? Infinity;
const step = (attrs.step as number | undefined) ?? 1;
return h(
"div",
{ style: "display: flex; align-items: center; gap: 0.5rem" },
[
h(
"button",
{
type: "button",
onClick: () =>
emit(CustomEvents.UPDATE_MODEL, Math.max(min, value - step)),
},
"-",
),
h("input", { type: "number", value, ...attrs }),
h(
"button",
{
type: "button",
onClick: () =>
emit(CustomEvents.UPDATE_MODEL, Math.min(max, value + step)),
},
"+",
),
],
);
},
},
};SFC: drop-in replacement via a loader function
import {import} from "@babel/types";
const components: ComponentDefinition = {
// Match by type key — no condition needed
date: {
component: () => import("./components/DatePicker.vue"),
useCustomLabel: true, // DatePicker renders its own label
useCustomError: true, // DatePicker renders its own error UI
},
// Match by condition (works for multiple types)
text: {
useCustomLabel: true,
useCustomError: true,
component: import("./components/CustomText.vue"),
condition: (type: string) => ["email", "password", "text"].includes(type),
},
};Reserved keys: loader and separator
const components: ComponentDefinition = {
loader: {
// Replaces the spinner shown while async components load
component: () => import("./components/CustomLoader.vue"),
},
separator: {
// Replaces the default text separator rendered between sibling fields
component: () => import("./components/TextSeparator.vue"),
},
};Wiring it up
<script setup lang="ts">
import SimpleForm from "simple-form-vue";
import components from "./components";
const formData = ref({ age: 30, email: "" });
</script>
<template>
<SimpleForm v-model="formData" :components="components" />
</template>Working with Arrays
The library handles arrays automatically, providing add/remove capabilities:
- Simple arrays (of primitives): Each element becomes an input of the inferred type
- Object arrays: Each object in the array becomes a repeatable group of inputs
- Mixed type arrays: When array contains different object shapes, a type selector is shown below the section
Example array configuration:
{
// Simple string array — every row is a single text input
tags: ['Tag 1', 'Tag 2', 'Tag 3'],
// Object array for contacts — each row is a group of named inputs
contacts
:
[{
name: 'Name 1',
email: 'Email 1',
phone: 'Phone 1'
}, {
name: 'Name 2',
email: 'Email 2',
phone: 'Phone 2'
}, {
name: 'Name 2',
email: 'Email 2',
phone: 'Phone 2'
}],
// Mixed type array for payment methods
paymentMethods
:
[{
'cardNumber': '123456789',
'expiry': '2030-01-01',
'cvv': '456'
}, {
'acountNumber': '123456789',
'iban': 'DE86500105178223221274',
},
'PayPal Email address'
]
}Mixed-Type Arrays
Mixed-type arrays render a type-selector at the end of the section. The selector's default placeholder text comes from
the multiTypeSelectLabel prop (default: "This array has multiple types. Select a type to add").
When a user picks a type, the row's child fields are filtered to the subset declared in the matching items entry, and
the row's initial values come from each field's defaultValue (see
Reactive Defaults and Optional Inputs).
Customize the picker with the multi-type-select slot:
<template>
<SimpleForm v-model="formData">
<template #multi-type-select="{ options, disabled, onChange }">
<ul class="type-picker">
<li v-for="opt in options" :key="String(opt.value)">
<button
type="button"
:disabled="disabled || opt.disabled"
@click="onChange(opt.value)"
>
{{ opt.label }}
</button>
</li>
</ul>
</template>
</SimpleForm>
</template>The slot receives:
options: OptionObject[]— pre-built list with a disabled placeholder row first, then one entry per declareditemstype.disabled: boolean—truewhen no item can be added due to invalidity.onChange: (value: PrimitiveType) => void— call with the chosenOptionObject.valueto commit the selection and add the selected item type.
Reactive Defaults and Optional Inputs
Every input definition accepts two top-level attributes that control how the field participates in the form:
| Attribute | Description | Default Value |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
| renderChildren | Determines whether children of a nested object should be rendered as individual inputs. If false, a defaultValue must be provided! | true |
| defaultValue | Default value when the field is initially generated | String: " "Number: 0Boolean: falseObject: {}Array: [] |
When renderChildren is false, the object field becomes a single input that emits a structured value instead of
expanding into child inputs. This is useful for passing a JSON blob to a custom component or for storing a server-side
representation locally.
{
config: {
renderChildren: false,
defaultValue
:
{
theme: "dark", density
:
"compact"
}
,
attrs: {
label: "Server config (JSON)",
// A custom textarea or code editor would consume this field
}
,
}
,
}[!IMPORTANT] If
renderChildrenisfalseanddefaultValueis not provided (neither as an explicit attribute nor via adefaultValueprop function), the renderer throws at mount time. The library does this because without a default it has no way to produce a sensible empty field.
CSS and Styling
Class names and ids
To avoid clashing with common class names like error or base-input, the library prefixes its internal class names with
sfm__. Ids, however, are not prefixed. The outer wrapper element's id can be set explicitly via the attrs.id field
of the inputDefinitions. If it is omitted, it defaults to the field's name in the provided model,
ensuring it's always set. For labels and the input components (custom or default ones), the for/id attributes are prefixed with
input_ instead. The id you provide (or its field-name default) is applied only to the surrounding wrapper element
containing the label and input. The same goes for nested forms like an array or objects in the model.
Below is an example of the structure for a username field from a basic login form.
<div id="username" class="sfm__base-input sfm__text">
<label for="input_username">form.labels.username</label>
<section class="sfm__input-wrapper">
<!--Before element-->
<input id="input_username" name="username" class="sfm__input" type="text" />
<!--After element-->
</section>
</div>This keeps the input easy to target with a predictable id, while still satisfying HTML's requirement that every id in
a document be unique.
#username {
label {
text-decoration: underline;
}
input {
border: blueviolet solid 3px;
}
}Styling
To make styling easier, the library exposes some CSS variables that can be overwritten using the :root selector.
| Variable | Default value | Use |
| -------------------------------- | ------------- | ---------------------------------------------------------- |
| --sfm__color-background | white | Main background color |
| --sfm__color-font | black | Default font/text color |
| --sfm__color-light | white | Light theme color |
| --sfm__color-dark | black | Dark theme color |
| --sfm__color-lightgray | #ccc | Light gray color |
| --sfm__color-positive | #00ac00 | Color for positive states, confirmations, success messages |
| --sfm__color-negative | #f00 | Color for negative states, errors, warnings |
| --sfm__form-padding | 1rem | Padding inside form containers |
| --sfm__separator-color | black | Background color used for sub field separators |
| --sfm__border-color | #ccc | Default border color |
| --sfm__label-bg | #e5e5e5 | Background color for labels |
| --sfm__button-size | 2rem | Default button dimension |
| --sfm__button-font-size | 1.5rem | Font size used for buttons |
| --sfm__input-gap | 0.3rem | Spacing between input elements |
| --sfm__input-wrapper-gap | 0.3rem | Spacing between input wrappers |
| --sfm__separator-gap | 0.3rem | Spacing around separators |
| --sfm__radio-group-gap | 0.2rem | Spacing between radio groups |
| --sfm__radio-item-gap | 0.5rem | Spacing between radio items |
| --sfm__radio-item-border-color | #ccc | Border color for radio items |
| --sfm__error-message-height | 1rem | Height of the error messages |
| --sfm__loader-color-sun | #fb9100 | Color of the sun element in the default loader animation |
| --sfm__loader-color-earth | #1717db | Color of the earth element in the default loader animation |
| --sfm__loader-color-moon | #1c023c | Color of the moon element in the default loader animation |
Types
This library is written in TypeScript. Every type, interface, enum, type guard, and a few helpers from
lib/types/ is re-exported from the package root. Import them by name from
simple-form-vue:
import type {
// Component props
SimpleFormProps,
RendererProps,
ArrayControlProps,
CustomInputProps,
TextSeparatorProps,
// Field / input configuration
InputDefinition,
InputDefinitionSchema,
FieldAttrs,
FieldDefinition,
ResolvedFieldAttrs,
FieldAttrFunction,
FieldAttrFunctionArgument,
// Validation
Validation,
ValidationType,
ValidationResult,
// Custom components
ComponentDefinition,
CustomComponentDefinition,
CustomComponentProps,
FunctionalComponent,
// Options / data
OptionType,
OptionObject,
PrimitiveType,
// Validity plumbing
ValidFieldsMap,
ValidityTransfer,
ValidityValue,
ValidityValueObject,
UpdateTransfer,
ResolvedFormFields,
FormFieldsState,
// Template / schema
Template,
UiElement,
UiChild,
Ui,
UiDefinition,
ItemShape,
ItemTemplate,
// Render helpers
RenderDataInput,
getLabelId,
// Slot prop shapes
FieldSlotType,
ArrayButtonSlotType,
MultiSelectSlotType,
FooterSlotType,
StringSlotType,
// Enums
DataTypes,
FieldTypes,
CustomEvents,
SlotNames,
} from "simple-form-vue";Most users will only need the prop interfaces (SimpleFormProps,
CustomInputProps), the data types (InputDefinition, Validation,
ComponentDefinition), and the slot prop shapes. The schema and validity plumbing types (Ui, UiChild,
UiDefinition,
ValidityValueObject) are useful when building custom components that need to inspect or react to the rendered form's
structure.
Public API Reference
Beyond the type-only surface above, the package also re-exports a small set of runtime values and functions:
Enums
| Enum | Members |
| -------------- | ------------------------------------------------------------------------------------------------------ |
| DataTypes | ARRAY, OBJECT, PRIMITIVE, BOOLEAN, NUMBER, DATE, FUNCTION, STRING, NULL, UNDEFINED |
| FieldTypes | TEXT, NUMBER, CHECKBOX, DATE, RADIO, SELECT |
| CustomEvents | UPDATE_MODEL, IS_VALID, IS_DIRTY, IS_LOADING, SUBMIT, ADD, REMOVE |
| SlotNames | String enum matching every slot name documented above (e.g. SlotNames.ADD_BUTTON === "add-button") |
Use these instead of string literals when wiring custom components to keep call sites refactor-safe:
import { CustomEvents, SlotNames } from "simple-form-vue";
defineEmits<{ [CustomEvents.UPDATE_MODEL]: [unknown] }>();<template #[SlotNames.ADD_BUTTON]="{ onClick, disabled }">
<button :disabled="disabled" @click="onClick">Add</button>
</template>Type guards
These narrow the type of a value when implementing custom components or inspecting the renderer output. Each is a function exported by name:
| Function | Narrows to | Use when |
| ---------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------ |
| isPrimitive(value) | PrimitiveType | Checking if a field's current value is a string, number, boolean, etc. |
| isField(node) | UiChild | Distinguishing a leaf field from a parent Ui node in the renderer tree. |
| isInput(node) | UiChild with field | Same as isField, but only for nodes that carry a field definition (always true today). |
| isUiNode(node) | Ui | The inverse of isField — true for parent nodes that may render child inputs. |
| isUpdateTransfer(t) | UpdateTransfer | Checking that a custom component's emitted payload has the { name, data } shape the library expects. |
| isNonNullable(value) | NonNullable<T> | Filtering out null and undefined from arbitrary values (e.g. while building options). |
| dataTypeIsPrimitive | boolean | Checking a DataTypes string against the primitive union. |
Render helpers
| Export | Kind | Purpose |
| ----------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| RenderDataFnComponent | Functional component | Render a RenderDataInput value (a string, a VNode, or a { fn, args } lazy resolver) inside a custom input component. Use this whenever you render attrs.label or attrs.description from a custom component. |
| RenderDataInput | Type | The un
