vue3-survey-builder

A powerful, AI-assisted, drag-and-drop form builder Vue 3 component library — built on top of SurveyJS, with full **Persian (Farsi) / English** bilingual support and **AI-powered form generation**.
1,592
Install
npm install vue3-survey-builder
Latest Version:3.6.1
Vue:3
Last Updated:Oct 18, 2026
Links: GitHub  ·  npm
Maintainer: saeed_p11

🧱 Form Builder

A powerful, AI-assisted, drag-and-drop form builder Vue 3 component library — built on top of SurveyJS, with full Persian (Farsi) / English bilingual support and AI-powered form generation.

npm version Vue 3 TypeScript


✨ Features

  • 🎨 Visual drag-and-drop form designer powered by SurveyJS Creator
  • 🤖 AI-driven form generation — describe your form in natural language; your app calls the model and returns the JSON schema
  • 🧾 Highlighted JSON editor — the designer's JSON Editor tab colours the JSON and formats it (toolbar button or Shift+Alt+F)
  • 📈 Chart question — a built-in, display-only bar, line or donut chart of the answers, live as the form is filled
  • 📊 Result templates — a score card for completed surveys, configured and previewed in the designer
  • 🔌 Custom question types and logic — async dropdowns, designer-only pickers, custom Conditional Logic triggers
  • 🌗 Dark & Light themes, following the dark class on <html>
  • 🌐 Bilingual support — Persian (Farsi) & English
  • 🖼️ Icon Picker — a Lucide icon for the survey logo, picked from the survey header
  • 📦 Dual build output — ESM (.mjs) + UMD (.umd.js) + TypeScript declarations

📦 Installation

The SurveyJS packages, Vue, vue-i18n and Tailwind are peer dependencies, shared with your app rather than bundled. pnpm and npm install missing peers automatically; with yarn, or with peer auto-install turned off, add them yourself:

pnpm add vue3-survey-builder

# only if your package manager does not install peers:
pnpm add survey-core survey-creator-core survey-creator-vue survey-vue3-ui vue vue-i18n tailwindcss

survey-core and survey-creator-core keep global registries (custom properties, question types, locale strings), so the app and this library must share one copy. Import SurveyJS APIs from either package: this library re-exports survey-core.


🚀 Usage

The components translate their own text with vue-i18n, so the app must install it (Composition API mode). Its current locale (en or fa) also drives the SurveyJS locale.

// main.ts
import { createI18n } from "vue-i18n";

app.use(createI18n({ legacy: false, locale: "fa", messages: {} }));

Designer

<script setup lang="ts">
import { ref } from "vue";
import {
  FormBuilder,
  SurveySchemaPrompt,
  provideAiAssistantConfig,
} from "vue3-survey-builder";
import "vue3-survey-builder/style.css";

const json = ref<object>({});

// Optional: the toolbar's AI button sends the prompt here. Return the survey
// JSON (or a Promise of it); return undefined to leave the form unchanged.
provideAiAssistantConfig({
  onSubmit: async (prompt) => {
    const messages = [...SurveySchemaPrompt, { role: "user", content: prompt }];
    const reply = await callYourModel(messages); // your LLM call
    return JSON.parse(reply.match(/\[JSON\](https://github.com/SaeedP11/vue-survey-builder/blob/HEAD/[\s\S]*?)\[\/JSON\]/)![1]);
  },
});
</script>

<template>
  <FormBuilder v-model:json="json" />
</template>

v-model:json receives the survey JSON on every autosave, and setting it loads a new schema into the designer. SurveySchemaPrompt is a few-shot prompt that makes a model answer in the [JSON]…[/JSON] format above.

Text direction

The designer follows the locale's direction: right to left in fa (and other RTL languages), left to right in en, switching with the locale. Pass dir="ltr" or dir="rtl" to fix it instead. SurveyJS mounts its popups on <body> and reads their direction from it, so keep <html dir> in step with the locale (a Nuxt i18n app does this) for menus and dropdowns to match.

Filling a survey

<script setup lang="ts">
import { SurveyComponent, useSurveyModel } from "vue3-survey-builder";
import "vue3-survey-builder/style.css";

const survey = useSurveyModel(surveyJson);
survey.onComplete.add((sender) => save(sender.data));
</script>

<template>
  <SurveyComponent :model="survey" />
</template>

useSurveyModel builds the Model, keeps its locale and theme in step with the app, and scrolls to the first invalid question on validation. It must be called from a component's setup(). A Persian survey renders right to left on its own; an English one takes the page's direction.

Without the designer: vue3-survey-builder/runtime

A page that only shows surveys can import from the runtime entry instead. It has everything above, survey-core's exports, the chart question and the survey's own properties (icon, repetition, resultTemplate), but not the designer, so survey-creator-core and its stylesheet stay out of the page (about 450 KB of script and 120 KB of CSS, brotli).

import { SurveyComponent, useSurveyModel } from "vue3-survey-builder/runtime";
import "vue3-survey-builder/runtime.css";

Both entries share one copy of their common code, so an app that uses the designer on some pages and the runtime on others registers everything once. The runtime entry is ES only; the UMD build is the full library.


🎨 Styling

Import the library stylesheet where you use the components:

import "vue3-survey-builder/style.css";

Some components (the result template, its editor and the toasts) are styled with Tailwind v4 utilities, which your app's Tailwind build generates. Add the library's Tailwind entry to your app's CSS, after Tailwind itself:

@import "tailwindcss";
@import "vue3-survey-builder/tailwind.css";

It names the library's sources for Tailwind to scan (Tailwind skips node_modules otherwise) and registers the fb-* colours those components use.

To match the designer and surveys to your app's palette, set these CSS variables (for example on :root, and again under .dark); style.css maps them onto SurveyJS's own variables:

Variable Used for
--color-formbilder-background Page and input backgrounds
--color-formbilder-surface-background Panels and the designer surface
--color-formbilder-primary Brand and accent colour
--color-formbilder-error, --color-formbilder-error-light Validation errors
--font-formbilder-family Font of the whole builder and of surveys: SurveyJS and the library's own components (font-fb). Set it to your app's font, e.g. var(--font-sans); code views stay monospace
--font-formbilder-size, --font-formbilder-question-size, --font-formbilder-weight Typography
--radius-formbilder Corner radius of forms, inputs and buttons
--color-formbilder-json-key, -string, -number, -boolean, -null, -punctuation, -comment Token colours in the JSON Editor tab (light and dark defaults otherwise)

🔌 Async Dropdown (custom question)

Register a server-driven dropdown question that the form designer and the rendered survey can both use. Search, pagination, and item state are owned by your app; the component just renders them and calls your handlers.

1. Register the question

registerAsyncDropdown() uses getCurrentInstance() internally, so you must call it from inside a component's setup() (or <script setup>) — typically the same parent that mounts FormBuilder.

<script setup lang="ts">
import { ref } from "vue";
import {
  FormBuilder,
  registerAsyncDropdown,
  type ItemType,
} from "vue3-survey-builder";

const items = ref<ItemType[]>([]);
const currentPage = ref(1);
const totalPages = ref(1);
let lastQuery = "";

async function load(query: string, page: number) {
  const res = await fetch(
    `/api/items?q=${encodeURIComponent(query)}&page=${page}`,
  );
  const data = await res.json();
  items.value = data.items;          // [{ value, text }, ...]
  currentPage.value = data.page;
  totalPages.value = data.totalPages;
  lastQuery = query;
}

registerAsyncDropdown("asyncDropdown", {
  items,
  currentPage,
  totalPages,
  handleSearch: (q) => load(q, 1),
  handleNextPage: () => load(lastQuery, currentPage.value + 1),
  handlePrevPage: () => load(lastQuery, currentPage.value - 1),
});

// optional: prime the list
load("", 1);
</script>

<template>
  <FormBuilder />
</template>

2. Use it in a survey JSON

The first argument to registerAsyncDropdown ("asyncDropdown" above) is the question type. Reference it in any survey JSON:

{
  "elements": [
    {
      "type": "asyncDropdown",
      "name": "country",
      "title": "Country"
    }
  ]
}

Handler contract

The same AsyncDropdownHandlers interface is used by both registerAsyncDropdown and registerAdvancedTextInput.

Field Type Notes
items Ref<ItemType[]> Current page of options — { value, text }
currentPage Ref<number> 1-based current page
totalPages Ref<number> Total page count; set 0 to hide the pager
handleSearch (query: string) => void Called on every keystroke — debounce if needed
handleNextPage () => void Called when the next button is pressed
handlePrevPage () => void Called when the previous button is pressed
onSelect? (question: Question, item: ItemType) => void Only invoked by registerAdvancedTextInput — ignored by the runtime dropdown

⚠️ items, currentPage, and totalPages must be Vue Refs — the component reads .value and re-renders reactively when you mutate them.


📝 Advanced Text Input (designer-only async dropdown)

registerAdvancedTextInput registers a question that renders as a regular text input at runtime, but whose convertInputType button in the form designer opens your async dropdown instead of the default subtype list. When the designer picks an item, your onSelect callback runs and can mutate the question — e.g. set name and title from the chosen item.

Use this when you want the form designer to choose from a server-backed catalogue (FHIR codes, country lists, glossary terms…) but want end-users to type freely into a normal text field.

1. Register the type

Same AsyncDropdownHandlers shape as registerAsyncDropdown, plus the optional onSelect. Must be called from a component's setup().

<script setup lang="ts">
import { computed, ref } from "vue";
import {
  FormBuilder,
  registerAdvancedTextInput,
  type AsyncDropdownHandlers,
  type ItemType,
} from "vue3-survey-builder";

const COUNTRIES: ItemType[] = [
  { value: "IR", text: "Iran" },
  { value: "US", text: "United States" },
  { value: "GB", text: "United Kingdom" },
  // …
];

const PAGE_SIZE = 5;
const query = ref("");
const currentPage = ref(1);

const filtered = computed(() => {
  const q = query.value.toLowerCase().trim();
  return q
    ? COUNTRIES.filter((c) => c.text.toLowerCase().includes(q))
    : COUNTRIES;
});
const totalPages = computed(() =>
  Math.max(1, Math.ceil(filtered.value.length / PAGE_SIZE)),
);
const items = computed(() =>
  filtered.value.slice(
    (currentPage.value - 1) * PAGE_SIZE,
    currentPage.value * PAGE_SIZE,
  ),
);

const handlers: AsyncDropdownHandlers = {
  items,
  currentPage,
  totalPages,
  handleSearch: (q) => {
    query.value = q;
    currentPage.value = 1;
  },
  handleNextPage: () => {
    if (currentPage.value < totalPages.value) currentPage.value++;
  },
  handlePrevPage: () => {
    if (currentPage.value > 1) currentPage.value--;
  },
  // designer-only — runs when an item is picked from the convertInputType popup
  onSelect: (question, item) => {
    question.name = item.value;
    question.title = item.text;
  },
};

registerAdvancedTextInput("countryAsync", handlers);
</script>

<template>
  <FormBuilder />
</template>

2. Use the type in survey JSON

{
  "elements": [
    { "type": "countryAsync", "name": "country" }
  ]
}

In the designer, click the question's convertInputType button (the chevron next to the type label) — your async list pops up. Pick an item, and:

  • the question's properties update via your onSelect,
  • the popup closes,
  • the selection is highlighted on next open, and
  • the action button's title becomes the selected item's text.

At runtime the question is a plain text input — the popup never appears.

How it differs from registerAsyncDropdown

Aspect registerAsyncDropdown registerAdvancedTextInput
Runtime render Custom dropdown Vue component Native text input (survey-text)
Where the user picks At survey-fill time In the designer's convertInputType popup
What selection does Sets question.value = item.value Calls handlers.onSelect(question, item) — you decide
onSelect handler Ignored Required for anything to happen

Internals (for the curious)

  • The popup component (survey-text-input-popup) is registered globally on the active Vue app on first call.
  • QuestionAdornerViewModel.prototype.createConvertInputType is monkey-patched once to swap in the async popup for questions registered through this helper. Other question types keep their default behaviour.
  • The registered class extends QuestionTextModel and overrides getType() (so the JSON round-trips with your custom type), getTemplate() / getCssType() → "text" (so SurveyJS renders + styles it as a regular text input).

🧠 Custom Logic (Conditional Logic tab)

registerCustomLogic adds a new entry to the form designer's Conditional Logic tab — alongside the built-in Skip to question, Complete survey, Set answer, etc. The user picks it from the action dropdown, types an expression, and the survey JSON gains a matching entry in its triggers array:

"triggers": [
  { "type": "reminder", "expression": "{question1} = 10" }
]

An optional onFire callback runs at survey-fill time the moment the expression flips to true, so you can react in the host app (toast, log, emit an event, schedule a notification, …).

1. Register the logic type

Unlike registerAsyncDropdown / registerAdvancedTextInput, this helper does not need a Vue component context — call it at module load time, before the creator is instantiated:

import { registerCustomLogic } from "vue3-survey-builder";

registerCustomLogic({
  type: "reminder",
  locales: {
    en: {
      displayName: "Reminder",
      description: "Send a reminder when the condition is met",
    },
    fa: {
      displayName: "یادآور",
      description: "ارسال یادآور هنگام برقراری شرط",
    },
  },
  onFire: ({ survey, expression, properties }) => {
    console.log("[reminder] fired:", expression, survey.data, properties);
  },
});

The label shown in the Logic-tab dropdown (displayName) and the help text below it (description) automatically switch with the creator's current locale.

2. Use it in the designer

Open the Logic tab, click Add New, set a condition, then pick Reminder (or whatever you named it) from the action dropdown. Save — the produced survey JSON contains a trigger of your custom type.

3. React at runtime (optional)

When the survey is filled and the trigger's expression evaluates to true, your onFire callback runs with:

Field Type Notes
survey SurveyModel The live survey instance — read survey.data
expression string The expression text the user authored
properties Record<string, unknown> SurveyJS-supplied context (keys, navigation, …)

If onFire is omitted the trigger is JSON-only — your app can still inspect survey.triggers itself.

API

interface RegisterCustomLogicOptions {
  type: string;                       // value placed in triggers[].type
  locales: Record<                    // per-locale label + description
    string,
    { displayName: string; description?: string }
  >;
  onFire?: (ctx: {                    // optional runtime hook
    survey: SurveyModel;
    expression: string;
    properties: Record<string, unknown>;
  }) => void;
}

The call is idempotent — re-invoking with the same type updates only the onFire handler; the serializer class, Logic-tab entry, and locale strings are added once.

Internals (for the curious)

  • A SurveyTrigger subclass is registered with survey-core's Serializer (parent "surveytrigger", single expression property) so JSON round-trips with { "type": "<your type>", "expression": "…" }.
  • A trigger_<type> entry is pushed into SurveyLogic.types (publicly-exposed static accessor), which is what the Logic-tab dropdown reads.
  • Display strings are merged into editorLocalization under lg.trigger_<type>Name / lg.trigger_<type>Description — the same keys the built-in triggers use, so locale switching just works.
  • onFire is wired to the trigger's onSuccess hook (fires only when the expression result transitions from false → true, per SurveyJS semantics).

📈 Chart question (built in)

A display-only question that charts numbers the form already holds. It is built in: importing the library registers it, so the designer's toolbox lists Chart and every survey rendered with SurveyComponent draws it, with nothing to call.

Each data row is a label and a SurveyJS expression evaluated against the answers as the respondent fills the form: a question ({weight}), a calculation ({a} + {b}, min(100, {steps} * 10)), a calculated value or a constant. A row whose expression names an unanswered question stays empty instead of drawing a zero. The chart adds nothing to the survey's data and never blocks completion.

{
  "type": "chart",
  "name": "today",
  "title": "Today at a glance",
  "chartType": "bar",
  "unit": "h",
  "maxValue": 12,
  "items": [
    { "value": "sleep", "text": "Sleep", "expression": "{sleep}" },
    { "value": "goal", "text": "Goal", "expression": "8", "color": "#1baf7a" }
  ]
}
Property Values Default
chartType bar, horizontalBar, line, donut bar
items { value, text, expression, color? }[] []
unit text after each value (kg, %, ...)
maxValue top of the value axis; empty fits the data
  • Bars and lines use one colour (the first palette slot, or a row's color); a donut gives each slice the next slot of a fixed eight-colour, colour-blind-checked palette, with light and dark steps that follow the dark class on <html>. A ninth slice and later fold into "Other".
  • Hovering or focusing a bar, point or slice shows its value; each chart carries a screen-reader summary and a hidden table of its numbers.
  • Right-to-left forms put the value axis on the right; numbers are formatted for vue-i18n's locale (Persian digits in fa).
  • In the designer the chart shows sample numbers, labelled as such.
  • QuestionChartModel and ChartItemValue are exported for code that builds or inspects chart questions.

📊 Result template

The designer toolbar's desktop button opens the result-template editor: a live preview on the left, settings on the right. The settings are stored in the survey JSON under resultTemplate (a hidden survey property):

Setting Notes
scoreDisplay default, linear, radial, temperature, donut, bullet, thermometer, pills or table
score Name of the question or calculated value that holds the score
min, max Score range
statusLabels Tier labels shared by every display; the range is split into this many tiers
tableRows Rows for the table display: metric, value (a variable name or a number), min, max, unit
Brand and copy logoSrc, brandName, brandTagline, categoryChip, title, subtitle, description, noteText
images { id, src, alt?, caption?, placement?, fit?, tier? }[]: see below
actions { id, label, type, variant?, icon?, url?, newTab? }[]: the page's buttons, see below
layout { id, type, placement?, html?, hidden?, tier? }[]: the page's blocks, top to bottom, see below

Images. placement is banner (full width under the header), afterScore (the default) or beforeActions; fit is cover (fill and crop) or contain. With tier set (an index into statusLabels), the image shows only when the score lands in that tier, so each tier can have its own illustration. The editor uploads a file through the creator's onUploadFile event (with elementType: "resultImage" and propertyName: "resultTemplate"), so a host that handles it stores images on its own server; without a handler the image is saved in the survey JSON as a data URL, up to 1 MB. The logo field uploads the same way. Only http(s), relative, blob: and raster data:image addresses are shown.

Layout. The page is a list of blocks: hero (check mark, title, subtitle and description), score, note, images (with a placement: the images placed there show in it), actions (the buttons), and the author's own html and divider blocks. Without layout the page keeps its standard order (banner images, hero, score, images, note, images, buttons), so templates saved before it look as they did. Any block can be hidden, or limited to one status tier like an image.

In the editor, blocks are arranged by dragging them, on the preview or in the Layout panel. The panel also has move up/down buttons, and a focused preview block moves with Alt+↑/↓. New blocks are dragged in from the panel's palette, or clicked to go after the selected block; a removed built-in block goes back to the palette.

HTML blocks. An html block shows the author's markup, styled inline. DOMPurify sanitizes it before it is shown: scripts, event handlers, <style>, form controls, frames and <template> go; links must be http(s), mailto:, tel: or relative; target may only be _blank (and gets rel="noopener noreferrer"); and id/name are prefixed so they cannot clobber the page's globals. The block is CSS-contained, so even position: fixed stays inside it. {name} puts in an answer or calculated value as text (HTML-escaped, so an answer never adds markup); {$score}, {$status}, {$min} and {$max} put in the score, its status label and the range. The editor's preview shows the placeholders as written.

Buttons. variant is primary, secondary or text; up to two filled buttons share a row, with more the primary ones get their own row, and text buttons go last. icon is one of none, arrow, external, download, print, calendar, phone, message, share, restart. type says what a click does:

type Click
link Opens url (http(s), mailto:, tel: or relative; other schemes are refused). {name} in it is replaced with that answer or calculated value, URL-encoded: https://clinic.example/book?score={riskScore}
restart Emits restart; the Preview tab starts the survey again
close Emits close; the Preview tab hides the result
print Opens the browser's print dialog (which also saves as PDF)
custom Nothing but the action event: the host tells buttons apart by id

Every click emits action with the button. A template saved before actions existed keeps primaryLabel, secondaryLabel and closeLabel (now deprecated): it shows those three buttons, which still emit primary, secondary and close, and the editor turns them into actions when it is next saved. The template editor's preview never follows links or prints, and it shows every image and block, marking those that belong to one tier or are hidden.

When a survey is completed in the designer's Preview tab, the template is rendered over the preview with the score read from the answers. Saving a survey without a template shows a one-time reminder.

The template is only rendered inside FormBuilder for now: the default template component reads the creator's theme, so it cannot yet be shown to respondents outside the designer.

To use your own template component and editor, provide them from an ancestor of FormBuilder:

import { provideSurveyResultTemplate } from "vue3-survey-builder";
import MyResult from "./MyResult.vue"; // receives the settings as props
import MyResultEditor from "./MyResultEditor.vue"; // v-model:props

provideSurveyResultTemplate({
  id: "my-result",
  component: MyResult,
  editor: MyResultEditor, // optional; defaults to the built-in editor
});

Version History

Version Released Vue License
3.6.1 ^3.5.30
3.6.0 ^3.5.30
3.5.0 ^3.5.30
3.4.2 ^3.5.30
3.4.1 ^3.5.30