Custom Fields
Introduction
You may extend Inertia Forms with your own field types. A custom field consists of a PHP class, a frontend component for your stack, and registration in that stack's field component registry. Add a TypeScript interface when you want your app to type the serialized field config.
To restyle an existing field instead of creating a new PHP field, clone that field's presenter component and keep using its package composable or hook. See Customizing Presenters for the Vue and React pattern.
Custom presenters that render icon names from PHP should use DynamicIcon so they share the same resolver behavior as the built-in fields. See Using DynamicIcon In Custom Presenters.
PHP Class
Create a new class in your application that extends Field. Start with the built-in field closest to your control and copy only the concerns your field needs.
<?php
declare(strict_types=1);
namespace App\Fields;
use InertiaUI\Forms\Fields\Field;
use InertiaUI\Forms\Fields\Concerns\CanBeDisabled;
use InertiaUI\Forms\Fields\Concerns\HasCssClasses;
use InertiaUI\Forms\Fields\Concerns\HasHelpText;
use InertiaUI\Forms\Fields\Concerns\HasLabel;
use InertiaUI\Forms\Fields\Concerns\HasPlaceholder;
class StarRating extends Field
{
use CanBeDisabled;
use HasCssClasses;
use HasHelpText;
use HasLabel;
use HasPlaceholder;
protected int $maxStars = 5;
public function getComponent(): string
{
return 'StarRating';
}
public function maxStars(int $max): static
{
$this->maxStars = $max;
return $this;
}
public function toArray(): array
{
return array_merge(parent::toArray(), [
'label' => $this->getLabel() ?? $this->generateLabel(),
'labelClass' => $this->getLabelClass(),
'class' => $this->getClass(),
'disabled' => $this->isDisabled(),
'help' => $this->getHelp(),
'helpClass' => $this->getHelpClass(),
'placeholder' => $this->getPlaceholder(),
'maxStars' => $this->maxStars,
]);
}
}Key Points
getComponent()returns the string used to look up the frontend component. It must match the name you pass toregisterFieldComponent().toArray()serializes the field to JSON. Always callparent::toArray()to includecomponent,name,required, andprecognitive.- Use
->default()(inherited fromField) to set the initial value. - Use
->rule()and->rules()(fromHasValidationRules) to add validation rules.
Shared Concerns
Shared concerns keep custom fields aligned with the built-in fields. Text-like controls may follow TextInput or Textarea, choice fields may follow Combobox, and structural fields may follow Repeater or Blocks.
label(), help(), and placeholder() each take a plain string. The built-in frontend wrappers escape labels and help text, and placeholders render as input attributes.
Frontend Component
Vue apps use useFormField with FieldWrapper for consistent label, help text, and error rendering. React apps use the same field contract with the React exports. Custom components registered for auto-rendering receive the serialized field config as flat props, the same way built-in fields do.
<script setup lang="ts">
import { computed } from 'vue'
import { FieldWrapper, useFormField } from '@inertiaui/form-vue'
import type { StarRatingField } from '../types'
const props = defineProps<StarRatingField>()
const { value, setValue, error, inputId, ariaDescribedBy, invalid } = useFormField<number>(props)
const stars = computed(() => {
return Array.from({ length: props.maxStars }, (_, i) => i + 1)
})
</script>
<template>
<FieldWrapper :field="$props" :error="error" :input-id="inputId">
<div class="flex gap-1">
<button
v-for="star in stars"
:key="star"
type="button"
@click="setValue(star)"
:class="[
'text-2xl transition-colors',
star <= (value ?? 0)
? 'text-iui-form-warning'
: 'text-iui-form-icon',
]"
:disabled="disabled"
:aria-describedby="ariaDescribedBy"
:aria-invalid="invalid ? 'true' : undefined"
:aria-label="`Rate ${star} out of ${maxStars}`"
:aria-pressed="star === value"
>
★
</button>
</div>
</FieldWrapper>
</template>import { FieldWrapper, useFormField } from '@inertiaui/form-react'
import type { StarRatingField } from '../types'
export default function StarRating(props: StarRatingField) {
const { value, setValue, error, inputId, ariaDescribedBy, invalid } = useFormField<number>(props)
const stars = Array.from({ length: props.maxStars }, (_, index) => index + 1)
return (
<FieldWrapper field={props} error={error} inputId={inputId}>
<div className="flex gap-1">
{stars.map((star) => (
<button
key={star}
type="button"
onClick={() => setValue(star)}
className={[
'text-2xl transition-colors',
star <= (value ?? 0) ? 'text-iui-form-warning' : 'text-iui-form-icon',
].join(' ')}
disabled={props.disabled}
aria-describedby={ariaDescribedBy}
aria-invalid={invalid ? 'true' : undefined}
aria-label={`Rate ${star} out of ${props.maxStars}`}
aria-pressed={star === value}
>
★
</button>
))}
</div>
</FieldWrapper>
)
}TypeScript Interface
Add a type for your field in your application's types. You may augment the package's CustomFormFields interface so FormConfig and FormField include your custom field:
import type { BaseField } from '@inertiaui/form-vue'
export interface StarRatingField extends BaseField {
component: 'StarRating'
placeholder?: string
maxStars: number
}
declare module '@inertiaui/form-vue' {
interface CustomFormFields {
StarRating: StarRatingField
}
}import type { BaseField } from '@inertiaui/form-react'
export interface StarRatingField extends BaseField {
component: 'StarRating'
placeholder?: string
maxStars: number
}
declare module '@inertiaui/form-react' {
interface CustomFormFields {
StarRating: StarRatingField
}
}Registering the Component
The Form, Wizard, nested Fieldset, Repeater, and Blocks renderers use the exported field component registry in Vue and React. Register your component once during app startup:
// In your app's main.ts
import { registerFieldComponent } from '@inertiaui/form-vue'
import StarRating from './components/StarRating.vue'
registerFieldComponent('StarRating', StarRating)// In your app's main.tsx
import { registerFieldComponent } from '@inertiaui/form-react'
import StarRating from './components/StarRating'
registerFieldComponent('StarRating', StarRating)You may also register several components at once:
import { registerFieldComponents } from '@inertiaui/form-vue'
import StarRating from './components/StarRating.vue'
import AddressLookup from './components/AddressLookup.vue'
registerFieldComponents({
StarRating,
AddressLookup,
})import { registerFieldComponents } from '@inertiaui/form-react'
import StarRating from './components/StarRating'
import AddressLookup from './components/AddressLookup'
registerFieldComponents({
StarRating,
AddressLookup,
})Global Vue component registration with app.component() and ordinary React imports in a page are not used by the auto-renderer. Use the registry helpers when the field should render from PHP config.
Built-in presenter components such as TextInput, Combobox, and RichText are registered by the package. Do not register them again in your app boot code unless you intentionally want to replace that built-in presenter for the whole bundle.
To override a built-in field presenter, register the built-in component key with your cloned presenter component:
import { registerFieldComponent } from '@inertiaui/form-vue'
import AppTextInput from './components/form/AppTextInput.vue'
registerFieldComponent('TextInput', AppTextInput)import { registerFieldComponent } from '@inertiaui/form-react'
import AppTextInput from './components/form/AppTextInput'
registerFieldComponent('TextInput', AppTextInput)That override is app-level. Every auto-rendered TextInput in that JavaScript bundle will use your component, so keep the same field contract and use the package composable or hook for behavior that should stay in sync with future fixes.
Custom components may reuse the same class helpers as the built-in fields. Import cn and partClass from your stack's root package (@inertiaui/form-vue or @inertiaui/form-react) to merge Tailwind classes and read a named entry from field.partClasses. Prefer the package's semantic utilities such as text-iui-form-muted-foreground, ring-iui-form-border, and focus:ring-iui-form-ring over raw palette classes, so your custom field follows the same theme tokens as the built-in components.
Manual Rendering
You may render the field manually with your component and pass the field config instead of using the registry:
<script setup lang="ts">
import { provide } from 'vue'
import { FormContextKey, useForm } from '@inertiaui/form-vue'
import type { FormConfig } from '@inertiaui/form-vue'
import StarRating from './StarRating.vue'
const props = defineProps<{ form: FormConfig }>()
const form = useForm(props.form)
provide(FormContextKey, {
formId: form.formId,
data: form.data,
rootData: form.data,
errors: form.errors,
processing: form.processing,
setData: form.setData,
validate: form.validate,
})
</script>
<template>
<form @submit.prevent="form.submit()">
<StarRating v-bind="form.getField('rating')" />
</form>
</template>import { FormContext, useForm } from '@inertiaui/form-react'
import type { FormConfig } from '@inertiaui/form-react'
import StarRating from './StarRating'
export default function ManualRating({ form: config }: { form: FormConfig }) {
const form = useForm(config)
const handleSubmit = (event) => {
event.preventDefault()
form.submit({ formElement: event.currentTarget })
}
return (
<FormContext.Provider value={{
formId: form.formId,
data: form.data,
rootData: form.data,
errors: form.errors,
rootErrors: form.errors,
processing: form.processing,
setData: form.setData,
validate: form.validate,
}}>
<form onSubmit={handleSubmit}>
<StarRating {...form.getField('rating')} />
</form>
</FormContext.Provider>
)
}Using the Field
Once registered, use your custom field like any built-in field:
use App\Fields\StarRating;
public function fields(): array
{
return [
TextInput::make('title')->required(),
StarRating::make('rating')
->maxStars(5)
->required()
->help('How would you rate this?'),
Submit::make('Save'),
];
}