JSONForms renderers for Vue 3, built on Nuxt UI 4 and Tailwind CSS 4 — plus a visual builder to compose { schema, uischema } without writing JSON by hand.
v2 — stack change. v1 was based on Quasar. v2 renders with Nuxt UI
U*components and therefore inherits the host app theme automatically. Thev1-quasarbranch keeps the old implementation;@tacxou/jsonforms_builder@1.xremains installable.
yarn add @tacxou/jsonforms_builder @jsonforms/core @jsonforms/vue @nuxt/ui@nuxt/ui, @jsonforms/core, @jsonforms/vue, and vue are peerDependencies: the library does not ship any Nuxt UI components; it imports them from the host app installation.
<template lang="pug">
json-forms(
:data="data"
:schema="schema"
:uischema="uischema"
:renderers="renderers"
validation-mode="ValidateAndShow"
@change="onChange"
)
</template>
<script setup lang="ts">
import { JsonForms } from '@jsonforms/vue'
import { nuxtUiRenderers } from '@tacxou/jsonforms_builder'
const renderers = Object.freeze(nuxtUiRenderers)
</script>nuxtUiRenderers— controls, layouts, and additional elements.allRenderers— the same, plus the rich-text editor (UEditor).
<template lang="pug">
form-builder(v-model="definition")
</template>
<script setup lang="ts">
import { FormBuilder, type FormDefinition } from '@tacxou/jsonforms_builder'
const definition = ref<Partial<FormDefinition>>({})
</script>Palette, drag-and-drop reorderable tree, property inspector, live preview, and JSON export. Raw JSON editing is left to the host app (Monaco, CodeMirror…).
| Schema / option | Nuxt UI component |
|---|---|
string |
UInput |
string + options.multi |
UTextarea |
string + format: password |
UInput + visibility toggle |
object / string + options.wysiwyg |
UEditor (JSON or HTML via contentType) |
| WYSIWYG images | upload dropzone, drag-resize, bubble replace/delete |
WYSIWYG options (options.wysiwyg: true):
| Option | Role |
|---|---|
contentType |
'json' | 'html' (else inferred from schema) |
toolbar |
Nuxt UI toolbar groups, or false to hide |
density |
'compact' | 'comfortable' | 'prose' |
minHeight / padding / blockSpacing / editorClass |
spacing overrides |
debounce |
onChange debounce in ms (default 300) |
image |
false to disable, or { upload, accept, maxSize, resize, bubble, … } |
extensions / handlers |
extra TipTap extensions / Nuxt UI handlers |
options: {
wysiwyg: true,
contentType: 'html',
density: 'comfortable',
image: {
upload: async (file) => uploadedUrl,
maxSize: 5 * 1024 * 1024,
resize: { minWidth: 80, alwaysPreserveAspectRatio: true },
},
}| string + options.format: pin | UPinInput |
| string + format: color | UColorPicker in a popover + text input |
| string + format: data-url | UFileUpload |
| number / integer | UInputNumber |
| number + options.slider | USlider |
| number + options.format: rating | UInputRating |
| boolean | UCheckbox (USwitch via options.toggle) |
| enum | USelectMenu |
| enum + options.format: select | USelect (no search) |
| enum + options.format: radio | URadioGroup |
| string + options.api | UInputMenu (remote search) |
| format: date / date-time / time | UInputDate / UInputTime |
| format: date + options.format: calendar | expanded UCalendar |
| array of string + options.format: tags | UInputTags |
| array | repeatable cards (add, reorder, remove) |
| oneOf | variant selector + sub-form |
| Group | titled UCard |
| Categorization | UTabs (UStepper via options.variant: "stepper") |
| Label | heading + USeparator |
Two levels, from broadest to most specific:
// 1. Global theme, injected once for the whole tree.
provide('styles', { control: { input: 'font-mono' } })// 2. Per element, via uischema options — `<slot>` is the target component.
{
"type": "Control",
"scope": "#/properties/name",
"options": { "input": { "size": "lg", "ui": { "base": "tracking-wide" } } }
}Tailwind 4 generates utilities by scanning project sources and ignores everything
outside its root — including node_modules. Without the line below, classes used by
the renderers appear in the DOM but map to no CSS rule: selection borders and backgrounds
disappear, and spacing shifts.
@import "tailwindcss";
@import "@nuxt/ui";
@source "../node_modules/@tacxou/jsonforms_builder/dist";If your brand theme is declared in an
@themeblock, use@theme static. Tailwind prunes variables that no source references directly, and a color consumed only by Nuxt UI's generated CSS (--ui-primary: var(--color-my-color-500)) silently falls away — the theme then reverts to default colors.
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
vite: {
optimizeDeps: {
// The library keeps imports into `@nuxt/ui` SFCs: esbuild's pre-bundler
// cannot compile them, so exclude it.
exclude: ['@tacxou/jsonforms_builder'],
// `ajv` is CommonJS. Without pre-bundling, its default export is not exposed
// and `@jsonforms/core` fails on import.
include: ['ajv', 'ajv-formats', '@jsonforms/core', '@jsonforms/vue'],
},
},
})When updating the library — if the browser throws
does not provide an export named '…'on@jsonforms/vueor@jsonforms/core, Vite's pre-bundle is stale: a new version may import from an already optimized dependency an export it did not import before, and Vite does not always invalidate its cache. A restart with a cleared cache is enough:rm -rf node_modules/.vite && vite --force
import ui from '@nuxt/ui/vite'
export default defineConfig({
plugins: [vue(), ui({ colorMode: true })],
})The WYSIWYG renderer also requires deduplicating ProseMirror — its plugins are identified by object identity, and two copies in the dependency tree throw Adding different instances of a keyed plugin:
resolve: {
dedupe: ['@tiptap/core', '@tiptap/pm', '@tiptap/vue-3', 'prosemirror-state', 'prosemirror-view', 'prosemirror-model'],
}See playground/vite.config.ts for a full commented configuration (including the #imports stubs required by @nuxt/icon outside Nuxt).
@vueuse/core 14.4.0 (latest as of writing, pulled by Nuxt UI) contains in
onClickOutside:
function hasMultipleRoots(target) {
const vm = toValue(target)
return vm && vm.$.subTree.shapeFlag === 16 // `vm` is guarded, `vm.$` is not
}After a component unmounts, vm.$ is null: any later click that reaches a surviving
listener throws. The triggering case is an open menu whose selection click unmounts
the subtree — typically a oneOf variant change, or removing an array row.
The workaround applied in the affected renderers is to defer the unmounting mutation by
one nextTick, so the menu can finish closing first. Reproduce this in the host app if
it unmounts subtrees itself from an @update:model-value handler.
This project runs on Node.js ≥ 22 with Yarn (Classic 1.x, see packageManager)
as the sole package manager: yarn.lock is authoritative — do not install with
npm or pnpm.
yarn install
yarn start:dev # playground alone + mock API (http://localhost:5174)
yarn docs:dev # VitePress + playground (http://localhost:5173/playground)
yarn docs:build # site docs unique pour GitHub Pages
yarn docs:preview # prévisualiser le build docs
yarn build # library build (es + cjs + declarations)
yarn test # Vitest suite
yarn test:watch # same, watch mode
yarn test:coverage # v8 coverage → ./coverage/lcov.info
yarn lint # Biome: lint + format check
yarn lint:fix # apply safe fixes and reformatmake help lists the equivalent Make targets (make dev, make build, make test…).
Releases are driven by GitHub Actions. CHANGELOG.md is the source of the release notes,
and git tags are bare (2.0.0, no v prefix).
make release-ci INCREMENT=none WATCH=1 # publish the version in package.json
make release-ci INCREMENT=minor WATCH=1 # let the CI bump the version first
make release-ci INCREMENT=none LATEST=false # prerelease → npm dist-tag `next`
make release-ci INCREMENT=none NPM=false # GitHub Release only, no npm publishThe release.yml workflow runs the full CI, bumps and tags the version, publishes the GitHub
Release (body taken from the matching CHANGELOG.md section) and pushes the package to npm —
dist-tag latest for a stable release, next for a prerelease. It requires the NPM_TOKEN
repository secret.
make release VERSION=X.Y.Z [PRERELEASE=1] is the local alternative: it commits the bump and
CHANGELOG.md, pushes, and creates the Release from RELEASE_NOTES.md with gh. The
publish.yml workflow then publishes to npm.
The VitePress site in docs/ is published to
GitHub Pages. The interactive
playground is compiled into the same site (mounted on /playground, hash
routes #/?example=… / #/builder) — no separate iframe or /play/ SPA.
In the playground sidebar:
- Documentation — Nuxt UI control showcases (
nuxt-*, control options, …) - Examples — JSONForms demos, layouts, compositions, and edge cases
Enable Pages in the repository settings (Settings → Pages → Source: GitHub Actions).
The workflow .github/workflows/deploy-docs.yml builds and deploys on every push to main.
Linting and formatting are handled by Biome (biome.jsonc),
replacing ESLint and Prettier. Two limits come from the Vue + Pug stack:
- Biome only analyzes an SFC's
<script>block, never the<template>. ThenoUnusedVariablesandnoUnusedImportsrules are therefore disabled on.vuefiles, where any binding consumed by the template would look unused. - Formatting does not touch Pug templates, which are left to
.editorconfig.
