I18n
Vueform translates on three layers. The framework's own strings are Tags looked up in the active Locale, the texts you write accept Locale Maps the same Locale resolves at render time, and a translatable input translates what the user writes, holding every language's copy in the model. The first two re-translate every form on the spot when the Locale switches; the third stores content in the data, not the app's Locale.
Locales
A Locale is the dictionary everything else reads — it supplies the translations for the built-in tags, and its code is what Locale Maps are keyed by. Vueform ships with the following Locales, exported from @vueform/ui/locales:
| Code | Name |
|---|---|
de_DE | 🇩🇪 Deutsch |
en_US | 🇺🇸 English |
hu_HU | 🇭🇺 Magyar |
The Full Bundle (@vueform/ui/bundle) registers en_US, which starts active as the first (and only) registered Locale. The locales config option replaces that map — it never adds to it — so to offer another language next to English, list both:
import { createVueform } from '@vueform/ui/bundle'
import { en_US, de_DE } from '@vueform/ui/locales'
createVueform({
locales: { en_US, de_DE },
locale: 'de_DE',
})locale names the active code, and an empty locale falls back to the first registered key.
Fallback Locale
A Locale doesn't have to be complete. fallbackLocale (default '', meaning no fallback) is consulted for any tag the active Locale lacks, which makes partial Locales practical:
import { createVueform } from '@vueform/ui/bundle'
import { en_US } from '@vueform/ui/locales'
const fr_FR = {
steps_back: 'Retour',
steps_next: 'Suivant',
steps_finish: 'Terminer',
file_button_text: 'Choisir un fichier',
}
createVueform({
locales: { en_US, fr_FR },
locale: 'fr_FR',
fallbackLocale: 'en_US',
})A tag resolves active Locale → fallback Locale → the raw tag string, and an empty-string translation counts as missing and falls through too.
Changing the Active Locale
useI18n() returns the i18n module. Its locale is a writable computed ref of the active code, and setLocale(code) switches it (set is an alias):
import { useI18n } from '@vueform/ui'
const { locale, setLocale } = useI18n()
locale.value // 'en_US'
setLocale('de_DE')A switch re-resolves every tag and Locale Map in every form immediately. Validation errors already on screen re-render in the new language without re-running a single rule — see Locale Tags.
The locale config option also accepts a ref, which keeps the source of truth outside Vueform — a Pinia store, a URL param. The binding is two-way, so writing the ref switches the Locale and setLocale() writes back into your ref:
import { ref } from 'vue'
import { createVueform } from '@vueform/ui/bundle'
import { en_US, de_DE } from '@vueform/ui/locales'
export const appLocale = ref('en_US')
createVueform({
locales: { en_US, de_DE },
locale: appLocale,
})Outside a setup context — a store, a route guard — the same module is available as vueform.useI18n() on the Vueform Instance. Inside Event Handlers and Dynamic Prop Functions, the received context carries it as $i18n. During SSR the active Locale lives on the instance, so a module-scope instance would share it across requests — see Cross-Request State.
Locale Tags
Every built-in string is a tag — a key resolved through the active Locale at render time.
Writing a Locale
A Locale is a flat object of tag → string. All shipped Locales share the same key set, so en_US is the reference when writing a new language.
The same object is how you override built-in wording app-wide — spread a shipped Locale and replace the keys you want. Your own tags can live right next to the built-in ones:
import { createVueform } from '@vueform/ui/bundle'
import { en_US } from '@vueform/ui/locales'
createVueform({
locales: {
en_US: {
...en_US,
steps_next: 'Continue', // override a built-in tag
welcome: 'Welcome, {name}!', // add your own
},
},
})Overriding a tag changes the wording everywhere. Some wording for a single form or element goes through props instead — nextLabel for a wizard button, or ruleMessages for validation messages. How rule names map onto the rule_* tags, the typed _string / _numeric / _array / _file variants included, is Validation's territory.
Translating in Your Code
The module's t(tag, params?) resolves a tag through the same active → fallback → raw-tag chain. Its tag argument is typed as LocaleTag, so built-in tag names autocomplete while any string is still accepted:
import { useI18n } from '@vueform/ui'
const { t, tags } = useI18n()
t('steps_next') // 'Next'
t('welcome', { name: 'John' }) // 'Welcome, John!'
tags.steps_next // 'Next'tags is the merged record of the fallback and active Locales — a plain read without t()'s parameter filling — and reads from it are reactive. The full member list is at useI18n.
Parameters
{name} tokens in a tag's value are filled from t()'s second argument, as welcome above shows. A token with no matching param stays in the text as-is.
The token syntax itself is the localeParamPattern config option (default /\{(\w+)\}/g). Its first capture group must yield the parameter name, and it always runs globally — other flags are dropped:
createVueform({
localeParamPattern: /:(\w+)/g, // tokens look like :name
})The pattern applies to every string, the shipped Locales included — their {attribute}, {min}, and friends stop resolving under a different syntax unless you rewrite those Locales to match.
Locale Maps
Tags translate Vueform's built-in strings — the texts you write yourself translate in place via Locale Maps. Labels, placeholders, help texts, button captions, and success messages are TProps, and every TProp accepts a Locale Map instead of a string. A Locale Map is a plain object keyed by Locale codes:
{
email: {
type: 'text',
label: { en_US: 'Email address', de_DE: 'E-Mail-Adresse' },
placeholder: { en_US: 'you@company.com', de_DE: 'sie@firma.de' },
},
}The active Locale picks the matching entry at render time and every map re-resolves the moment the Locale switches, with no message files and no keys to maintain — each translation lives next to the prop it belongs to.
The keys are the codes you register under locales. They aren't type-checked against the registered set, so a mistyped code doesn't error — its entry is simply never matched and the lookup falls through the resolution chain.
The Resolution Chain
A map doesn't need an entry for every registered Locale. A prop resolves to the first non-empty entry of:
- the active Locale's entry
- the
fallbackLocale's entry - the map's first entry
// locale: 'fr_FR', fallbackLocale: 'en_US'
{
name: {
type: 'text',
label: { fr_FR: 'Nom', en_US: 'Name' }, // → 'Nom' (active locale)
help: { en_US: 'Legal name, please' }, // → 'Legal name, please' (fallback)
placeholder: { de_DE: 'Name eingeben' }, // → 'Name eingeben' (first entry)
},
}An empty-string entry counts as missing and falls through to the next step, and without a configured fallbackLocale step 2 is skipped. When no step yields a non-empty string, the prop falls back to its own default — exactly as an empty plain string would.
Maps and Expressions
Each entry of a map is an ordinary prop string, so it can hold Expression blocks. Localization runs before expression resolution — the active Locale picks the entry first, then its {...} blocks resolve against the live form data:
{
name: { type: 'text', label: 'Name' },
bio: {
type: 'textarea',
label: { en_US: 'Bio of {name}', de_DE: 'Biografie von {name}' },
},
}{name} here references the name element, not a Locale-tag parameter — localeParamPattern plays no part inside maps.
The inverse doesn't hold. An expression resolves to a string and never to a map, so an expression can't pick between translations — the map picks first.
Tags Instead of Maps
A plain-string prop is never looked up as a Locale Tag. label: 'steps_next' renders the literal text steps_next even though that tag exists in every shipped Locale — among props, only validation messages resolve tags on their own (see Locale Tags). When translations should live in Locale files instead of the schema, use the prop's function form and the context's $i18n module:
{
email: {
type: 'text',
label: (el$) => el$.$i18n.t('email_label'),
},
}t() reads the active Locale inside the prop's computed, so the label re-evaluates on every Locale switch. Registering the email_label tag is covered in writing a Locale.
Choosing between the two is mostly a storage question. Inline maps keep translations next to the schema and survive JSON serialization, while tags centralize them in Locale files or a custom i18n backend.
Localizing Your Own Content
The resolution behind text props is yours to call. useI18n exposes localize(map), which returns the entry picked by the chain above, and localizeObject(record), which runs localize on every value of a { key: map } record.
That closes the item-label gap — items accepts a ref, and localizeObject returns exactly the Record<string, string> shape items take:
<script setup lang="ts">
import { computed } from 'vue'
import { useForm, useI18n } from '@vueform/ui'
const { localizeObject } = useI18n()
const countries = computed(() =>
localizeObject({
de: { en_US: 'Germany', fr_FR: 'Allemagne' },
fr: { en_US: 'France', fr_FR: 'France' },
})
)
const [MyForm] = useForm({
schema: {
country: { type: 'select', label: 'Country', items: countries },
},
})
</script>
<template>
<MyForm />
</template>The computed re-runs on Locale switches, so the option labels follow the active Locale like any translated prop. The same calls work in your own components rendered inside a form — useContext hands you the nearest component's context, which carries the module as $i18n.
Custom I18n Backend
To route Vueform's strings through an i18n system your app already runs, replace the built-in translator wholesale with $i18n.adapter. An adapter is an I18nAdapter — a function receiving the merged I18nModuleOptions and returning the full I18nModule surface:
import { computed, ref, unref } from 'vue'
import { createVueform } from '@vueform/ui/bundle'
import type { I18nAdapter } from '@vueform/ui'
const myAdapter: I18nAdapter = (options) => {
const current = ref(unref(options.locale) || '')
const setLocale = (code: string) => { current.value = code }
return {
locale: computed({ get: () => current.value, set: setLocale }),
tags: {}, // the merged fallback + active tag record
locales: options.locales ?? {},
fallbackLocale: options.fallbackLocale ?? '',
t: (tag, params) => tag, // translate a tag, filling {param} tokens
replaceParams: (msg, params) => msg, // substitute tokens in a plain string
setLocale,
set: setLocale,
localize: (msg) => '', // resolve a locale map to a string
localizeObject: (obj) => ({}), // resolve every value of a { key: localeMap } record
}
}
createVueform({
$i18n: { adapter: myAdapter },
})Once installed, everything that reads strings goes through it — components, validation messages, the expression engine's error text and useI18n() alike. The rest of the $i18n keys mirror the top-level options for module-scoped overrides — see Configuration.
Multilingual Inputs
Tags and maps translate the UI — a Translatable Input translates the data. One field's model value holds every language's copy side by side, the shape content-management forms need. Its languages are content slots in the data, not the UI language, so they're configured separately from Locales.
Translatable Elements
Set translate: true on an element and give it a languages map, and its model value becomes an object keyed by language code instead of a single string:
<VfForm>
<VfText
name="bio"
label="Bio"
translate
:languages="{ en_US: 'English', fr_FR: 'French' }"
/>
</VfForm><template>
<VfForm>
<VfText
name="bio"
label="Bio"
translate
:languages="{ en_US: 'English', fr_FR: 'French' }"
/>
</VfForm>
</template><script setup lang="ts">
import { defineSchema, useForm } from '@vueform/ui'
const schema = defineSchema({
bio: {
type: 'text',
label: 'Bio',
translate: true,
languages: { en_US: 'English', fr_FR: 'French' },
},
})
const [Form] = useForm({ schema })
</script>
<template>
<Form />
</template>translate on its own is inert — the element only becomes translatable once at least one language resolves from the element, the form, or the global config, and the config ships none by default. Available Languages covers where the map can live.
The input shows one language at a time, and typing writes the active language's entry. The element's context mirrors the split — value is the active language's entry, model the full language-keyed object.
Among the built-in elements, VfText and VfTextarea are translatable. Custom elements built on VfField get the same capability and behave exactly as this section describes — see Wrapping a Widget with VfField for how to build one.
Values and Defaults
default takes a language-keyed object, and languages missing from it start at the element's empty value. A plain-string default is applied to every language at once.
Values written from outside follow the same shape. Hand the Model API a language-keyed object and it replaces every language at once — form$.set('bio', { en_US: 'Hi', fr_FR: 'Salut' }) — or reach a single language by its path, form$.set('bio.fr_FR', 'Salut'). An element v-model or seeded value for a translatable element must be an object; a scalar throws Multilingual model must be an object.
With defineSchema, translate: true plus a literal languages map types the element's model as an object keyed by exactly those codes; without a languages prop the keys stay string — see Model for how schema typing flows into the model.
Available Languages
A language map pairs codes with display labels — the codes become the model's keys, the labels are what the switchers show. The map can live at three levels, and the most specific one wins:
| Level | Where |
|---|---|
| Global | languages in configuration |
| Form | languages on VfForm or useForm |
| Element | the element's languages prop |
The global map covers every form in the app, a form's map covers its translatable elements, and an element's map applies to that element alone:
createVueform({
languages: { en_US: 'English', fr_FR: 'French' },
})defineSchema({
bio: {
type: 'text',
translate: true,
languages: { en_US: 'English', fr_FR: 'French' },
},
})Labels can themselves be Locale Maps, resolving with the active Locale through the same chain as any translated prop:
createVueform({
languages: {
en_US: { en_US: 'English', fr_FR: 'Anglais' },
fr_FR: { en_US: 'French', fr_FR: 'Français' },
},
})Headless Models
A useModel instance expands translatable values with its own languages option, falling back to the global config — element languages props still win. Pass it the same map the attaching form gets, so both agree on the model's shape:
useForm's internal model takes the form's languages automatically.
Language Switchers
Every translatable element ships its own switcher — a compact language select rendered beside the input. Each element's choice is independent, so a French body can sit beside an English title — the side-by-side translation workflow.
Setting multilingual on the form adds a VfLanguages selector at the top that drives them all — picking a language there realigns every translatable element to it, discarding any per-element choice:
<VfForm
multilingual
:languages="{ en_US: 'English', fr_FR: 'French' }"
>
<VfText name="title" label="Title" translate />
<VfTextarea name="body" label="Body" translate />
</VfForm><template>
<VfForm
multilingual
:languages="{ en_US: 'English', fr_FR: 'French' }"
>
<VfText name="title" label="Title" translate />
<VfTextarea name="body" label="Body" translate />
</VfForm>
</template><script setup lang="ts">
import { defineSchema, useForm } from '@vueform/ui'
const schema = defineSchema({
title: { type: 'text', label: 'Title', translate: true },
body: { type: 'textarea', label: 'Body', translate: true },
})
const [Form] = useForm({
multilingual: true,
languages: { en_US: 'English', fr_FR: 'French' },
schema,
})
</script>
<template>
<Form />
</template>The same control is available in code — form$.language reads the active language and form$.selectLanguage(code) switches, on the Form Context. Languages holding validation errors are marked on the form's switcher, so the user sees which translation needs attention without visiting each one.
Validating Languages
A translatable element's rules run once per language — each language validates independently, with its errors tagged by the language's label, and the element is invalid while any language is:
<VfForm :languages="{ en_US: 'English', fr_FR: 'French' }">
<VfText
name="bio"
label="Bio"
translate
rules="required|min:10"
/>
</VfForm><template>
<VfForm :languages="{ en_US: 'English', fr_FR: 'French' }">
<VfText
name="bio"
label="Bio"
translate
rules="required|min:10"
/>
</VfForm>
</template><script setup lang="ts">
import { defineSchema, useForm } from '@vueform/ui'
const schema = defineSchema({
bio: {
type: 'text',
label: 'Bio',
translate: true,
rules: 'required|min:10',
},
})
const [Form] = useForm({
languages: { en_US: 'English', fr_FR: 'French' },
schema,
})
</script>
<template>
<Form />
</template>The details, including validating languages differently with language-keyed rules, live in Multilingual Rules.