Vueform and Vuestic UI Integration: Custom Elements, Slots, Props
For a full-featured example and out-of-the-box support, check out our ready-made integration package.
Vueform is a comprehensive form framework for Vue that makes form development a breeze. It offers quite extensive capabilities for integrating any UI libraries. Here are some of them:

It provides advanced features for integrating with third-party UI libraries like Vuestic UI. In this tutorial, we’ll walk through how to build custom Vueform elements using Vuestic UI components — including support for props, slots, events, and schema-based forms.


Author: Vitali Pilipovi, frontend developer @Epicmax
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: Creating a Custom Wrapper for Vuestic UI Component

Let's create our first wrapper. For this, we will use the va-input 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 block:
<template v-for="(component, slot) in elementSlots" #[slot]>
  <slot :name="slot" :el$="el$">
    <component :is="component" :el$="el$" />
  </slot>
</template>
is designed to pass generic slots from Vueform: label, info, description, before, between, after. There are not so many of them, but they can create conflicts with slots from Vuestic UI, so they can be removed.

The name argument 'VaInputElement' is also 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
To do this, 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: Using 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 input handler
We need to bind the component to Vueform’s model by using model-value and updating 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 and use 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>

<script setup lang="ts">
import VaInputElement from './VaInputElement.vue';
</script>
Vueform will now validate the field using the provided rules. This gives you full form validation support, just like with native Vueform inputs.
Step 7: Add event listeners using fire()
Now it's time to deal with events. To do this, we first need to 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 having to bind each event listener manually, we'll use the reduce() method to dynamically create an object of listeners:
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" />
This way, the component can emit and handle events inside schema definitions.
Step 8: Handle Vuestic and schema slots
Although it was written above that the block with slots can be removed, let's look at how to solve slot conflicts:
<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>
This setup allows handling both Vuestic-specific slots and schema-defined slots, avoiding collisions with Vueform’s internal slot system.
Step 9: Use slots in schema
Now we can see how it works:
<template>
  <Vueform>
    <VaInputElement name="MyCustomInput" rules="required|email|min:5" @update:modelValue="console.log($event)">
      <template #appendInner>
        appendInner
      </template>
    </VaInputElement>
  </Vueform>
</template>

<script setup lang="ts">
import VaInputElement from './VaInputElement.vue';
</script>
Step 10: Extract defineElement logic for reusability
If you are going to integrate a large number of components, it makes sense to put the defineElement function separately so that 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 extracted wrapper function
So our wrapper gonna look like this:
<template>
  <ElementLayout>
    <template #element>
      <VaInput v-bind="props" :model-value="value" @update:model-value="handleInput" v-on="listeners" ref="input">

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

<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>
Notice that we've added a ref="input" attribute to our VaInput; this is a regular template ref, and we can refer to it like this:
<template>
  <Vueform ref="form$">
    <VaInputElement name="custom" />
  </Vueform>
</template>

<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 form through schema
We have finished creating our wrapper. Now let's see how we can render a form with custom elements 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>
As you can see, it is enough to bind the schema object via props to Vueform to get the result. The key (in this case, MyCustomInput) is the same as the mandatory prop name. type: 'VaInput' is an argument from the defineElement function; remember that it must always end with the string Element, in this case, we skip it. Also, type can be written as PascalCase (VaInput) and kebab-case (va-input).

Next come the props and events. It is important to replace the @ character with on for the events.
Step 13: Define slots with different methods
Slots can be defined in several ways:
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')
        }
      }
    }
  }
}
You must specify el$ in the props. And this.el$ is a reference to our wrapper.
Method 3: Return h() function
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: () => '👋'
      })
    }
  }
}
In this case, h() takes a component as the first argument, props and events as the second, and slots as the third. You can read more about h() in the Vue documentation.
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.


For a full-featured example and out-of-the-box support, check out our ready-made integration package.


Happy building! 🚀