npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

simple-form-vue

v2.1.3

Published

A recursive JSON-schema-driven form renderer for Vue 3

Readme

Simple Form: JSON to HTML form

npm version npm downloads license

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-vue

or clone this repo

git clone https://gitlab.com/acvm007/SimpleForm

As 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. Simple usage

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 of UiDefinition nodes the renderer walks, with path, children, attrs, and dataType. Arrays expose an items array 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:

  1. {type: 'timeTag'}: Time Tag
  2. {type: 'checkboxGroup'}: A group of checkboxes Checkbox Group
  3. {type: 'multiSelect'}: A custom multi select similar to the checkboxGroup input Multi Select

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 order attribute 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 / after slots render for fields and text separators by default. Set the showNormalSeparatorSlots prop to false to 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 of OptionType objects
  • For label or description: 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 value
  • fields: 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 global useLazyValidation setting 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 boolean false. 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 false or 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 message attribute)
  • Return Promise resolving 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 becomes isDirty. 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 loader and separator are reserved by the library and used to override its built-in loader and text-separator components respectively. To override the loader, supply a components.loader.component — the condition is 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. Use context.emit to 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. FieldAttrs already includes every key from InputHTMLAttributes (checked, value, disabled, readonly, type, and so on), and a named property always wins over an extension generic in an intersection. A Toggle component using checked/unchecked as label props, for example, will silently collide with the native checkbox checked attribute — prefer distinct names like checkedLabel/uncheckedLabel instead.

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:

  1. Simple arrays (of primitives): Each element becomes an input of the inferred type
  2. Object arrays: Each object in the array becomes a repeatable group of inputs
  3. 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 declared items type.
  • disabled: booleantrue when no item can be added due to invalidity.
  • onChange: (value: PrimitiveType) => void — call with the chosen OptionObject.value to 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 renderChildren is false and defaultValue is not provided (neither as an explicit attribute nor via a defaultValue prop 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