VueConf US 2025 — Epicmax is speaking!Register now →
Epicmax
March 20, 2025· by Epicmax Team

Vueform + Vuestic UI: Full Integration for Scalable Vue Forms

A step-by-step guide to wrapping Vuestic UI components as custom Vueform elements — v-model, validation, props, events, and schema-driven slots.

Vue.jsVuestic UIForms
Vueform + Vuestic UI: Full Integration for Scalable Vue Forms

Vueform is a powerful form framework for Vue, and Vuestic UI is a full-featured component library. This guide walks through building a custom wrapper around Vuestic UI components for use inside Vueform, covering everything you need for full compatibility.

Integration methods

Vueform offers several integration strategies:

  • Generic Elements — maximum flexibility, full control over props, slots, and events. In this article, we will consider this approach.
  • Plugins — more suitable for extending existing functionality, adding new props and features.
  • Alternative Views — easier if you don't want to develop a new element.

Step 1: Create a custom wrapper for a Vuestic UI component

Let's create our first wrapper. For this, we will use VaInput from Vuestic UI:

<template>
  <ElementLayout>
    <template #element>
      <VaInput />
    </template>
    <template v-for="(component, slot) in elementSlots" #[slot]>
      <slot :name="slot" :el$="el$">
        <component :is="component" :el$="el$" />
      </slot>
    </template>
  </ElementLayout>
</template>

<script>
import { defineElement } from '@vueform/vueform'
import { VaInput } from 'vuestic-ui'

export default defineElement({
  name: 'VaInputElement',
  components: [VaInput],
  setup(props, { element }) {
    // Initial setup
  },
})
</script>

defineElement is imported directly from @vueform/vueform. This is our main helper function for creating a custom component.

The v-for block passes generic slots from Vueform (label, info, description, before, between, after). There aren't many of them, but they can conflict with slots from Vuestic UI, so they can be removed.

The name 'VaInputElement' is special — every name should end with 'Element'. This allows Vueform to render the form through the schema (we'll touch on this later).

Step 2: Register your component

Create vueform.config.ts in the root of the project and import your wrapper:

import en from '@vueform/vueform/locales/en'
import vueform from '@vueform/vueform/dist/vueform'
import { defineConfig } from '@vueform/vueform'
import '@vueform/vueform/dist/vueform.css';
import VaInputElement from './src/VaInputElement.vue';

export default defineConfig({
  theme: vueform,
  locales: { en },
  locale: 'en',
  elements: [VaInputElement]
})

Step 3: Use the custom element

<template>
  <Vueform>
    <VaInputElement name="MyCustomInput"/>
  </Vueform>
</template>

<script setup lang="ts">
import VaInputElement from './VaInputElement.vue'
</script>

The name attribute is mandatory for each element. So far, we can't pass props, slots, or event listeners. Also, Vueform doesn't know anything about our component's model, so validation won't work. Let's fix that next.

Step 4: Add v-model and an input handler

We need to bind the component to Vueform's model using model-value and update it with @update:model-value. This gives us reactivity and enables validation:

<template>
  <ElementLayout>
    <template #element>
      <VaInput :model-value="value" @update:model-value="handleInput" />
    </template>
    <template v-for="(component, slot) in elementSlots" #[slot]>
      <slot :name="slot" :el$="el$">
        <component :is="component" :el$="el$" />
      </slot>
    </template>
  </ElementLayout>
</template>
<script>
import { defineElement } from '@vueform/vueform'
import { VaInput } from 'vuestic-ui'

export default defineElement({
  name: 'VaInputElement',
  components: [VaInput],
  setup(props, { element }) {
    const { value, update } = element
    const handleInput = (val) => update(val)
    return { value, handleInput }
  },
})
</script>

The element variable gives access to Vueform's GenericElement API. From it, we destructure value and update, and bind them to our Vuestic component.

Step 5: Pass props from Vueform to Vuestic UI

The next step is to pass props dynamically and filter out the ones that might conflict with Vueform internals:

<script>
import { defineElement } from '@vueform/vueform'
import { VaInput } from 'vuestic-ui'
import { omit } from './omit'
import { computed } from 'vue'

const propsToOmit = ['rules']

export default defineElement({
  name: 'VaInputElement',
  components: [VaInput],
  props: {
    ...omit(VaInput.props, propsToOmit),
  },
  setup(props, { element }) {
    const { value, update } = element
    const handleInput = (val) => update(val)
    const omittedProps = computed(() => omit(props, propsToOmit))
    return { value, props: omittedProps, handleInput }
  },
})
</script>

Wrapping with computed() ensures reactivity. We omit props like rules to avoid conflicts.

Step 6: Add validation with the rules prop

Now we can pass the rules prop and see how it works:

<template>
  <Vueform>
    <VaInputElement name="MyCustomInput" rules="required|email|min:5" />
  </Vueform>
</template>

Vueform will now validate the field using the provided rules, giving you full form validation support just like with native Vueform inputs.

Step 7: Add event listeners using fire()

To handle events, we first explicitly specify them in defineElement and use the fire function from element. We use fire() instead of native context.emit() because this way we can listen for events when our element is defined in the schema. To avoid binding each listener manually, we use reduce() to build a listeners object dynamically:

const listeners = this.emits.reduce((acc, curr) => {
  acc[curr] = (...args) => {
    fire(curr, ...args)
  }
  return acc
}, {})

Then we bind all emitted events via v-on="listeners":

<VaInput v-bind="props" :model-value="value" @update:model-value="handleInput" v-on="listeners" />

Step 8: Handle Vuestic and schema slots

Although the generic slot block can be removed, here's how to resolve slot conflicts between Vuestic-specific slots and schema-defined slots:

<template>
  <ElementLayout>
    <template #element>
      <VaInput v-bind="props" :model-value="value" @update:model-value="handleInput" v-on="listeners">

        <template v-for="slotKey in vuesticSlotKeys" #[slotKey]="slotProps">
          <slot :name="slotKey" v-bind="slotProps" />
        </template>

        <template v-for="(component, slot) in schemaSlots" #[slot]="slotProps">
          <slot :name="slot" :el$="el$" v-bind="slotProps">
            <component :is="component" :el$="el$" />
          </slot>
        </template>

      </VaInput>
    </template>

    <template v-for="(component, slot) in elementSlots" #[slot]>
      <slot :name="slot" :el$="el$">
        <component :is="component" :el$="el$" />
      </slot>
    </template>
  </ElementLayout>
</template>

Step 9: Use slots in the schema

<template>
  <Vueform>
    <VaInputElement name="MyCustomInput" rules="required|email|min:5" @update:modelValue="console.log($event)">
      <template #appendInner>
        appendInner
      </template>
    </VaInputElement>
  </Vueform>
</template>

Step 10: Extract defineElement logic for reusability

If you're going to integrate a large number of components, it makes sense to put the defineElement logic in a separate function so you don't repeat yourself:

import { defineElement } from '@vueform/vueform'
import { computed } from 'vue'
import { omit } from './omit'

export function defineVuesticElement({ name, components, props, emits, propsToOmit }) {
  return defineElement({
    name,
    components,
    props,
    emits,
    setup(props, { element, slots }) {
      const { value, update, fire, elementSlots } = element
      const handleInput = (val) => update(val)
      const omittedProps = computed(() => omit(props, propsToOmit))

      const listeners = this.emits.reduce((acc, curr) => {
        acc[curr] = (...args) => { fire(curr, ...args) }
        return acc
      }, {})

      const vueFormSlotNames = Object.keys(elementSlots.value)
      const allSlotNames = Object.keys(slots)
      const vuesticSlotKeys = allSlotNames.filter(
        name => !vueFormSlotNames.includes(name)
      )

      const schemaSlots = computed(() => {
        const result = {}
        for (const key in props.slots) {
          if (!vueFormSlotNames.includes(key)) {
            result[key] = props.slots[key]
          }
        }
        return result
      })

      return {
        value,
        props: omittedProps,
        listeners,
        vuesticSlotKeys,
        schemaSlots,
        handleInput,
      }
    },
  })
}

Step 11: Use the extracted wrapper function

<script>
import { defineVuesticElement } from './defineVuesticElement';
import { VaInput } from 'vuestic-ui';
import { omit } from './omit';

const propsToOmit = ['rules', 'label']
const props = {
  ...omit(VaInput.props, propsToOmit)
}

export default defineVuesticElement({
  name: 'VaInputElement',
  components: [VaInput],
  props,
  emits: VaInput.emits,
  propsToOmit
})
</script>

Note the ref="input" template ref you can add to VaInput — you can reference it like this:

<script setup>
import { onMounted, ref } from 'vue'
import VaInputElement from './VaInputElement.vue'

const form$ = ref(null)

onMounted(() => {
  console.log(form$.value.el$('custom').input)
})
</script>

Step 12: Render a form through the schema

<template>
  <Vueform :schema="schema" />
</template>

<script setup>
const schema = {
  MyCustomInput: {
    type: 'VaInput',
    rules: "required|email|min:5",
    ['onUpdate:modelValue']: (v) => console.log(v),
    slots: {
      appendInner: () => 'appendInner',
    }
  }
}
</script>

Bind the schema object via props to Vueform to get the result. The key (MyCustomInput) matches the mandatory name prop. type: 'VaInput' is the argument from defineElement (it must always end with Element, which we skip here). type can be written as PascalCase (VaInput) or kebab-case (va-input). For events, replace the @ character with on.

Step 13: Define slots with different methods

Method 1 — arrow function:

slots: {
  appendInner: () => 'appendInner',
}

Method 2 — render() function:

import { h } from 'vue';

const schema = {
  MyCustomInput: {
    type: 'va-input',
    slots: {
      appendInner: {
        props: ['el$'],
        render() {
          console.log(this.el$)
          return h('div', 'Schema Slot')
        }
      }
    }
  }
}

Method 3 — return h():

import { h } from 'vue';
import TestComponent from './TestComponent.vue';

const schema = {
  MyCustomInput: {
    type: 'va-input',
    slots: {
      appendInner: () => h(TestComponent, {
        label: 'hello',
        onSomeEvent: () => console.log('onSomeEvent')
      }, {
        icon: () => '👋'
      })
    }
  }
}

h() takes a component as the first argument, props and events as the second, and slots as the third.

Final thoughts

In this guide, we walked through building a custom wrapper around Vuestic UI components for use inside Vueform. We covered everything you need for full compatibility:

  • ✅ Handling v-model and validation
  • ✅ Passing props with automatic omission
  • ✅ Listening to events using fire() for schema support
  • ✅ Managing generic and custom slots without conflicts
  • ✅ Using render functions in schema configuration

This approach gives you the flexibility of Vueform with the design power of Vuestic. If you're working with many components, abstract your wrapper logic for easier maintenance. Happy building! 🚀

Vue.js is our passion. Open Source is our culture 😍