Skip to content
Overview

Live Demo ​

Every form below is a real PHP form class from this page, serialized with json_encode() and rendered by the Vue <Form> component. Pick a form, change the accent color, fill it in, and press submit.

Form class

Accent
AllFieldsForm.php

What happens on submit

The demo passes an onBeforeSubmit callback to <Form>. It checks required fields in the browser and returns false, so nothing is sent to a server. In your app, the same form posts to Laravel and the #[Validate] attribute validates it on the server.

The form classes ​

Fifteen form classes: one that shows every field type, one built from custom fields, three that show the newer features, and ten real-world forms with conditional fields.

  • Onboarding wizard is a three-step wizard: each fieldset is a step with an icon, and Continue checks the step on the server. It also uses a Slug, an OTP Input and a Callout.
  • Landing page combines a Slug, a structured Link, Blocks, a Repeater, display helpers, and two Submit buttons with different intents.
  • Support chat is a single Composer with attachments and quick replies. It uses resetOnSuccess, so the box clears after each send.
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\Callout;
use Erag\InertiaForms\Fields\Checkbox;
use Erag\InertiaForms\Fields\CheckboxGroup;
use Erag\InertiaForms\Fields\ColorPicker;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\FileUpload;
use Erag\InertiaForms\Fields\Hidden;
use Erag\InertiaForms\Fields\KeyValue;
use Erag\InertiaForms\Fields\Link;
use Erag\InertiaForms\Fields\OtpInput;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Repeater;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Slug;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\TimePicker;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;

/**
 * Every field type and its main options on one page.
 */
class AllFieldsForm extends Form
{
    protected ?string $actionUrl = '/all-fields';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Text inputs')->description('TextInput types, prefix / suffix and clear button.')->columns(2)->fields([
                TextInput::make('full_name')->required()->placeholder('Jane Doe')->clearable(),
                TextInput::make('email')->email()->placeholder('jane@example.com'),
                TextInput::make('password')->password()->minLength(8),
                TextInput::make('phone')->tel()->placeholder('+91 98765 43210'),
                TextInput::make('website')->url()->prefix('https://'),
                TextInput::make('price')->number()->min(0)->step('0.01')->prefix('$')->suffix('USD'),
                Textarea::make('about')->rows(3)->autoResize()->maxLength(200)->showCharacterCount()->columnSpan(2),
            ]),
            Fieldset::make('Selects and lists')->description('Custom dropdowns with descriptions, search and chips, key-value rows and tags.')->columns(2)->fields([
                Combobox::make('plan')->clearable()->options([
                    ['value' => 'free', 'label' => 'Free', 'description' => 'For side projects'],
                    ['value' => 'pro', 'label' => 'Pro', 'description' => 'For growing teams'],
                    ['value' => 'enterprise', 'label' => 'Enterprise', 'description' => 'SSO, audit log and support'],
                ]),
                Combobox::make('country')->searchable()->clearable()->options([
                    'IN' => 'India',
                    'US' => 'United States',
                    'GB' => 'United Kingdom',
                    'DE' => 'Germany',
                    'JP' => 'Japan',
                ]),
                Combobox::make('frameworks')->multiple()->searchable()->clearable()
                    ->options(['Laravel', 'Inertia', 'Vue', 'React', 'Svelte', 'Tailwind CSS'])->columnSpan(2),
                KeyValue::make('headers')->label('Request headers')->keyLabel('Header')->keyPlaceholder('Accept')
                    ->valuePlaceholder('application/json')->addActionLabel('Add header')->columnSpan(2),
                TagsInput::make('keywords')->suggestions(['laravel', 'inertia', 'forms', 'tailwind'])
                    ->maxTags(6)->reorderable()->columnSpan(2),
            ]),
            Fieldset::make('Choices')->description('Radio cards, segmented buttons, checkbox pills and toggles.')->columns(2)->fields([
                Radio::make('shipping')->options([
                    ['value' => 'standard', 'label' => 'Standard', 'description' => '3 to 5 business days'],
                    ['value' => 'express', 'label' => 'Express', 'description' => 'Next business day'],
                ])->default('standard')->columns(2)->columnSpan(2),
                Radio::make('size')->options(['S', 'M', 'L', 'XL'])->buttons()->default('M'),
                Radio::make('contact_by')->options(['email' => 'Email', 'phone' => 'Phone', 'none' => 'None'])->inline(),
                CheckboxGroup::make('channels')->options(['email' => 'Email', 'sms' => 'SMS', 'push' => 'Push'])->buttons(),
                CheckboxGroup::make('days')->options(['mon' => 'Mon', 'tue' => 'Tue', 'wed' => 'Wed', 'thu' => 'Thu', 'fri' => 'Fri'])->inline(),
                Toggle::make('notifications')->onLabel('On')->offLabel('Off')->default(true),
                Checkbox::make('newsletter')->label('Subscribe to the newsletter'),
            ]),
            Fieldset::make('Dates and times')->description('Calendar, range, date with time, and time columns.')->columns(2)->fields([
                DatePicker::make('start_date')->minDate(now()->toDateString())->clearable(),
                DatePicker::make('meeting_at')->label('Meeting')->withTime()->clearable(),
                DatePicker::make('stay')->range()->firstDayOfWeek(1)->columnSpan(2),
                TimePicker::make('opens_at')->minuteStep(15)->clearable(),
                TimePicker::make('duration')->withSeconds()->clearable(),
            ]),
            Fieldset::make('Colors, sliders and files')->columns(2)->fields([
                ColorPicker::make('brand_color')->swatches(['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b', '#e11d48', '#9333ea'])
                    ->default('#7c3aed')->clearable(),
                Slider::make('volume')->min(0)->max(100)->step(5)->suffix('%')->default(40),
                FileUpload::make('photo')->image()->maxSize(2048),
                FileUpload::make('attachments')->multiple()->maxFiles(3)->accept(['pdf', 'docx'])->maxSize(4096),
            ]),
            Fieldset::make('Links, slugs and display')->description('Slugs, codes, links and repeating rows.')->columns(2)->fields([
                TextInput::make('page_title')->placeholder('Hello world'),
                Slug::make('page_slug')->from('page_title')->prefix('example.test/'),
                Link::make('homepage')->placeholder('example.test'),
                OtpInput::make('pin')->label('PIN')->length(4)->password(),
                Repeater::make('contacts')->itemLabel('Contact')->titleFrom('name')->columns(2)->columnSpan(2)->fields([
                    TextInput::make('name')->required(),
                    TextInput::make('email')->email(),
                ]),
                Callout::make('Heads up', 'Display helpers like this one have no value and are not submitted.')->info(),
            ]),
            Checkbox::make('terms')->label('I agree to the terms')->rule('accepted'),
            Hidden::make('source')->default('all-fields-demo'),
            Submit::make('Submit all fields')->processingLabel('Submitting…'),
        ];
    }
}
php
<?php

namespace App\Forms;

use App\Forms\Fields\CodeInput;
use App\Forms\Fields\QuantityStepper;
use App\Forms\Fields\Rating;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Form;

/**
 * Three custom fields (Rating, CodeInput, QuantityStepper) mixed with built-in ones.
 */
class CustomFieldsForm extends Form
{
    protected ?string $actionUrl = '/custom-fields';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Order')->description('QuantityStepper is a custom field with min, max and a unit.')->columns(2)->fields([
                Radio::make('ticket')->options([
                    ['value' => 'standard', 'label' => 'Standard', 'description' => '$49 per ticket'],
                    ['value' => 'vip', 'label' => 'VIP', 'description' => '$129, front rows and lounge'],
                ])->default('standard')->columns(2)->columnSpan(2),
                QuantityStepper::make('tickets')->min(1)->max(10)->unit('tickets')->default(2)->required(),
                QuantityStepper::make('parking')->label('Parking spots')->min(0)->max(4)->unit('spots'),
            ]),
            Fieldset::make('Verify')->description('CodeInput is a custom field: one box per digit, with paste support.')->fields([
                TextInput::make('email')->email()->required()->placeholder('jane@example.com'),
                CodeInput::make('code')->label('Verification code')->length(6)->required()
                    ->help('Use 123456 in this demo.'),
            ]),
            Fieldset::make('Feedback')->description('Rating is a custom field with keyboard support.')->fields([
                Rating::make('rating')->label('How was the booking experience?')->stars(5)->required(),
                Textarea::make('comment')->rows(3)->maxLength(200)->showCharacterCount()
                    ->visibleWhen('rating', '<=', 3)->help('Shown when the rating is 3 stars or less.'),
            ]),
            Submit::make('Book tickets')->processingLabel('Booking…'),
        ];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\Callout;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\OtpInput;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slug;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Form;

/**
 * A three-step wizard: each fieldset is a step, checked on the server before moving on.
 */
class OnboardingWizardForm extends Form
{
    protected ?string $actionUrl = '/onboarding-wizard';

    protected bool $wizard = true;

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Account')->description('Create your login')->icon('user')->fields([
                TextInput::make('name')->required()->placeholder('Jane Example'),
                TextInput::make('email')->email()->required()->placeholder('jane@example.test'),
                TextInput::make('password')->password()->required()->minLength(8),
            ]),
            Fieldset::make('Workspace')->description('Tell us about your team')->icon('briefcase')->columns(2)->fields([
                TextInput::make('workspace')->label('Workspace name')->required()->placeholder('Acme Studio'),
                Slug::make('workspace_url')->label('Workspace URL')->from('workspace')->prefix('app.example.test/')->required(),
                Radio::make('team_size')->options(['1' => 'Just me', '2-10' => '2–10', '11-50' => '11–50', '50+' => '50+'])
                    ->buttons()->default('2-10')->columnSpan(2),
            ]),
            Fieldset::make('Verify')->description('Confirm your email')->icon('shield')->fields([
                Callout::make('Check your inbox', 'We sent a 6-digit code to your email. Use 123456 in this demo.')->info(),
                OtpInput::make('code')->label('Verification code')->length(6)->groupSize(3)->required()
                    ->rule('in:123456'),
            ]),
            Submit::make('Create account')->icon('check')->processingLabel('Creating…'),
        ];
    }

    public function messages(): array
    {
        return ['code.in' => 'That code is not right. Use 123456 in this demo.'];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\Block;
use Erag\InertiaForms\Fields\Blocks;
use Erag\InertiaForms\Fields\Callout;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Heading;
use Erag\InertiaForms\Fields\Link;
use Erag\InertiaForms\Fields\Repeater;
use Erag\InertiaForms\Fields\Separator;
use Erag\InertiaForms\Fields\Slug;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\Text;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Form;

/**
 * A landing page editor: blocks, a repeater, links, display helpers
 * and two submit actions (save draft / publish).
 */
class LandingPageForm extends Form
{
    protected ?string $actionUrl = '/landing-page';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Page')->columns(2)->fields([
                Heading::make('Page details')->level(3),
                Text::make('The URL follows the title until you change it.'),
                TextInput::make('title')->required()->placeholder('Launch faster with Inertia Forms'),
                Slug::make('slug')->from('title')->prefix('example.test/')->required(),
                Link::make('cta')->label('Call to action')->withLabel(placeholder: 'Get started')->withTarget()
                    ->requireScheme()->allowedSchemes('https')->columnSpan(2),
                Textarea::make('intro')->rows(4)->autoResize()->maxLength(600)->showCharacterCount()->columnSpan(2),
            ]),
            Fieldset::make('Content')->fields([
                Blocks::make('sections')->label('Sections')->addActionLabel('Add section')->blocks([
                    Block::make('hero')->description('Big headline with a summary')->titleFrom('headline')->fields([
                        TextInput::make('headline')->required(),
                        Textarea::make('summary')->rows(3),
                    ]),
                    Block::make('quote')->description('Customer quote')->icon('Q')->columns(2)->fields([
                        Textarea::make('quote')->required()->columnSpan(2),
                        TextInput::make('author')->required(),
                        TextInput::make('company'),
                    ]),
                ])->default([['type' => 'hero', 'data' => ['headline' => 'Launch faster', 'summary' => 'Build forms in PHP and render them in your frontend.']]]),
                Separator::make()->spacing('sm'),
                Repeater::make('faq')->label('FAQ')->itemLabel('Question')->titleFrom('question')->maxItems(8)->fields([
                    TextInput::make('question')->required(),
                    Textarea::make('answer')->rows(2)->required(),
                ])->default([['question' => 'Do I need a frontend library?', 'answer' => 'No. Every field is built in with Tailwind CSS.']]),
                Callout::make('Publishing is final', 'Published pages are visible to everyone right away.')->warning(),
            ]),
            Submit::make('Save draft')->secondary()->intent('draft'),
            Submit::make('Publish')->icon('send', 'right')->intent('publish')->processingLabel('Publishing…'),
        ];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\Composer;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Text;
use Erag\InertiaForms\Form;

/**
 * A chat-style reply box: Enter sends, Shift+Enter adds a line, files can be attached.
 */
class SupportChatForm extends Form
{
    protected ?string $actionUrl = '/support-chat';

    protected bool $resetOnSuccess = true;

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Text::make('Reply to the customer. Attach screenshots or invoices if they help.'),
            Composer::make('reply')->label('First reply')->required()->placeholder('Write a reply…')
                ->accept(['png', 'jpg', 'pdf'])->maxFiles(3)->maxSize(4096)->maxLength(2000)
                ->quickReplies(['Thanks for reaching out!', 'Could you share a screenshot?', 'This is fixed now.']),
        ];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\CheckboxGroup;
use Erag\InertiaForms\Fields\ColorPicker;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\FileUpload;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;

/**
 * Launch a product: pricing tier, channels and a press kit when press is picked.
 */
class ProductLaunchForm extends Form
{
    protected ?string $actionUrl = '/product-launch';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Product')->columns(2)->fields([
                TextInput::make('product_name')->required()->placeholder('Aurora headphones')->clearable(),
                TextInput::make('price')->number()->min(0)->step('0.01')->prefix('$')->required(),
                Radio::make('tier')->options(['free' => 'Free', 'pro' => 'Pro', 'enterprise' => 'Enterprise'])->buttons()->default('pro'),
                DatePicker::make('launch_date')->required()->minDate(now()->toDateString())->clearable(),
                Textarea::make('tagline')->rows(2)->maxLength(120)->showCharacterCount()->columnSpan(2),
            ]),
            Fieldset::make('Go to market')->columns(2)->fields([
                CheckboxGroup::make('channels')->options([
                    'email' => 'Email',
                    'blog' => 'Blog',
                    'social' => 'Social',
                    'press' => 'Press',
                ])->buttons()->required()->columnSpan(2),
                FileUpload::make('press_kit')->accept(['pdf', 'zip'])->maxSize(5120)
                    ->visibleWhen('channels', 'contains', 'press')->columnSpan(2),
                ColorPicker::make('brand_color')->swatches(['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b', '#e11d48'])->default('#4f46e5'),
                Toggle::make('announce')->label('Announce on launch day')->onLabel('Yes')->offLabel('No')->default(true),
            ]),
            Submit::make('Schedule launch')->processingLabel('Scheduling…'),
        ];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\CheckboxGroup;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\FileUpload;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Form;

/**
 * Kick off a client project: business details only show up for business clients.
 */
class ProjectKickoffForm extends Form
{
    protected ?string $actionUrl = '/project-kickoff';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Project')->columns(2)->fields([
                TextInput::make('project_name')->required()->maxLength(80)->placeholder('Acme redesign'),
                TextInput::make('contact_email')->email()->required(),
                Combobox::make('client_type')->options(['personal' => 'Personal', 'business' => 'Business'])->default('personal'),
                TextInput::make('company')->required()->visibleWhen('client_type', 'business')->clearWhenHidden(),
                TextInput::make('budget')->number()->min(100)->prefix('$'),
                DatePicker::make('deadline')->minDate(now()->toDateString())->clearable(),
            ]),
            Fieldset::make('Scope')->fields([
                Radio::make('priority')->options([
                    ['value' => 'low', 'label' => 'Low', 'description' => 'Whenever there is time.'],
                    ['value' => 'high', 'label' => 'High', 'description' => 'Needs attention this week.'],
                ])->default('low')->columns(2),
                Combobox::make('stack')->label('Tech stack')->multiple()->searchable()->clearable()
                    ->options(['Laravel', 'Inertia', 'React', 'Vue', 'Svelte', 'Tailwind CSS']),
                CheckboxGroup::make('services')->options(['design' => 'Design', 'build' => 'Build', 'hosting' => 'Hosting'])->buttons(),
                FileUpload::make('brief')->accept(['pdf', 'docx'])->maxSize(2048)->help('Optional project brief.'),
            ]),
            Submit::make('Start project')->processingLabel('Starting…'),
        ];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\FileUpload;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;

/**
 * Triage a support ticket: critical issues ask for an on-call phone number.
 */
class SupportTriageForm extends Form
{
    protected ?string $actionUrl = '/support-triage';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Ticket')->columns(2)->fields([
                TextInput::make('requester_email')->email()->required()->placeholder('customer@example.com'),
                Combobox::make('area')->label('Product area')->required()->clearable()->options([
                    ['value' => 'billing', 'label' => 'Billing', 'description' => 'Invoices, cards and refunds'],
                    ['value' => 'auth', 'label' => 'Login & access', 'description' => 'Passwords, SSO and 2FA'],
                    ['value' => 'api', 'label' => 'API', 'description' => 'Tokens, limits and webhooks'],
                    ['value' => 'other', 'label' => 'Something else', 'description' => 'Anything not listed'],
                ]),
                Radio::make('severity')->options(['low' => 'Low', 'medium' => 'Medium', 'high' => 'High', 'critical' => 'Critical'])
                    ->buttons()->default('medium')->columnSpan(2),
                TextInput::make('on_call_phone')->tel()->required()->placeholder('+91 98765 43210')
                    ->visibleWhen('severity', 'critical')->clearWhenHidden()->columnSpan(2),
                Slider::make('affected_users')->min(1)->max(500)->step(1)->default(10)->columnSpan(2),
            ]),
            Fieldset::make('Details')->fields([
                Textarea::make('steps')->label('Steps to reproduce')->required()->rows(4)->autoResize(),
                TagsInput::make('labels')->suggestions(['bug', 'regression', 'billing', 'login', 'performance'])->maxTags(5),
                FileUpload::make('screenshots')->image()->multiple()->maxFiles(3)->maxSize(2048),
                Toggle::make('escalate')->label('Escalate to engineering')->onLabel('Yes')->offLabel('No'),
            ]),
            Submit::make('Create ticket')->processingLabel('Creating…'),
        ];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\TimePicker;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;

/**
 * Plan an event session: room and meeting link depend on the format.
 */
class EventSessionForm extends Form
{
    protected ?string $actionUrl = '/event-session';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Session')->columns(2)->fields([
                TextInput::make('title')->required()->placeholder('Building forms with Inertia')->columnSpan(2),
                DatePicker::make('dates')->label('Session dates')->range()->required()->columnSpan(2),
                TimePicker::make('starts_at')->label('Start time')->minuteStep(15)->required()->clearable(),
                TimePicker::make('ends_at')->label('End time')->minuteStep(15)->clearable(),
            ]),
            Fieldset::make('Format')->columns(2)->fields([
                Radio::make('format')->options([
                    ['value' => 'in_person', 'label' => 'In person', 'description' => 'At the venue'],
                    ['value' => 'online', 'label' => 'Online', 'description' => 'Live stream only'],
                    ['value' => 'hybrid', 'label' => 'Hybrid', 'description' => 'Venue and stream'],
                ])->default('in_person')->columns(3)->columnSpan(2),
                Combobox::make('room')->options(['Main hall', 'Workshop A', 'Workshop B', 'Studio'])->required()
                    ->visibleWhen('format', 'in', ['in_person', 'hybrid'])->clearWhenHidden(),
                TextInput::make('meeting_link')->url()->required()->placeholder('https://meet.example.com/…')
                    ->visibleWhen('format', 'in', ['online', 'hybrid'])->clearWhenHidden(),
                Slider::make('capacity')->min(10)->max(300)->step(10)->default(50)->columnSpan(2),
                TagsInput::make('speakers')->placeholder('Add a speaker and press Enter')->reorderable()->columnSpan(2),
                Toggle::make('recorded')->label('Record this session')->onLabel('Yes')->offLabel('No'),
            ]),
            Submit::make('Save session')->processingLabel('Saving…'),
        ];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\CheckboxGroup;
use Erag\InertiaForms\Fields\ColorPicker;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Form;

/**
 * Plan a marketing campaign: audiences, flight dates, channels and UTM tags.
 */
class CampaignPlanForm extends Form
{
    protected ?string $actionUrl = '/campaign-plan';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Campaign')->columns(2)->fields([
                TextInput::make('campaign_name')->required()->placeholder('Autumn sale')->clearable(),
                Radio::make('goal')->options(['awareness' => 'Awareness', 'leads' => 'Leads', 'sales' => 'Sales'])->buttons()->default('leads'),
                Combobox::make('audiences')->multiple()->searchable()->clearable()->required()
                    ->options(['New visitors', 'Returning customers', 'Newsletter subscribers', 'Trial users', 'Churned users'])->columnSpan(2),
                DatePicker::make('flight')->label('Flight dates')->range()->required()->columnSpan(2),
            ]),
            Fieldset::make('Budget')->columns(2)->fields([
                TextInput::make('budget')->number()->min(0)->prefix('$')->suffix('USD')->required(),
                Slider::make('daily_cap')->label('Daily cap')->min(0)->max(100)->step(5)->suffix('%')->default(25),
                CheckboxGroup::make('channels')->options([
                    'search' => 'Search',
                    'social' => 'Social',
                    'display' => 'Display',
                    'email' => 'Email',
                    'video' => 'Video',
                ])->buttons()->columnSpan(2),
                TagsInput::make('utm_tags')->label('UTM tags')->suggestions(['autumn', 'sale', 'retargeting', 'brand'])
                    ->maxTags(6)->columnSpan(2),
                ColorPicker::make('creative_color')->swatches(['#e11d48', '#f59e0b', '#10b981', '#0ea5e9', '#7c3aed'])->default('#e11d48'),
            ]),
            Submit::make('Save plan')->processingLabel('Saving…'),
        ];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Form;

/**
 * Open a role: office location only matters when the role is not remote.
 */
class HiringPipelineForm extends Form
{
    protected ?string $actionUrl = '/hiring-pipeline';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Role')->columns(2)->fields([
                TextInput::make('role_title')->required()->placeholder('Senior Laravel developer'),
                Combobox::make('department')->required()->options(['Engineering', 'Design', 'Product', 'Marketing', 'Support']),
                Radio::make('employment')->options(['full_time' => 'Full-time', 'part_time' => 'Part-time', 'contract' => 'Contract'])
                    ->buttons()->default('full_time')->columnSpan(2),
                TextInput::make('salary_min')->number()->min(0)->prefix('$')->suffix('/yr'),
                TextInput::make('salary_max')->number()->min(0)->prefix('$')->suffix('/yr'),
            ]),
            Fieldset::make('Location')->columns(2)->fields([
                Radio::make('workplace')->options([
                    ['value' => 'remote', 'label' => 'Remote', 'description' => 'Work from anywhere'],
                    ['value' => 'hybrid', 'label' => 'Hybrid', 'description' => 'Two days in the office'],
                    ['value' => 'onsite', 'label' => 'On-site', 'description' => 'Every day in the office'],
                ])->default('remote')->columns(3)->columnSpan(2),
                Combobox::make('office')->required()->searchable()->options(['Bengaluru', 'Berlin', 'London', 'New York', 'Singapore'])
                    ->visibleWhen('workplace', '!=', 'remote')->clearWhenHidden(),
                DatePicker::make('start_date')->minDate(now()->toDateString())->clearable(),
            ]),
            Fieldset::make('Pipeline')->fields([
                Combobox::make('skills')->multiple()->searchable()->clearable()
                    ->options(['PHP', 'Laravel', 'Inertia', 'React', 'Vue', 'SQL', 'Testing', 'AWS']),
                TagsInput::make('stages')->reorderable()->default(['Screening', 'Technical interview', 'Team interview', 'Offer'])
                    ->help('Drag the stages into order.'),
            ]),
            Submit::make('Open role')->processingLabel('Opening…'),
        ];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\Checkbox;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\KeyValue;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;

/**
 * Choose a plan: EU countries ask for a VAT number.
 */
class SubscriptionBillingForm extends Form
{
    protected ?string $actionUrl = '/subscription-billing';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Plan')->columns(2)->fields([
                Radio::make('plan')->options([
                    ['value' => 'starter', 'label' => 'Starter', 'description' => '$9 per seat, up to 5 seats'],
                    ['value' => 'growth', 'label' => 'Growth', 'description' => '$19 per seat, priority support'],
                    ['value' => 'scale', 'label' => 'Scale', 'description' => '$39 per seat, SSO and audit log'],
                ])->default('growth')->columns(3)->columnSpan(2),
                Radio::make('cycle')->label('Billing cycle')->options(['monthly' => 'Monthly', 'yearly' => 'Yearly'])
                    ->buttons()->default('yearly')->help('Yearly billing gets two months free.'),
                Slider::make('seats')->min(1)->max(100)->default(5),
                TextInput::make('coupon')->placeholder('LAUNCH20')->clearable(),
            ]),
            Fieldset::make('Billing details')->columns(2)->fields([
                TextInput::make('billing_email')->email()->required(),
                Combobox::make('country')->searchable()->required()->options([
                    'IN' => 'India',
                    'US' => 'United States',
                    'GB' => 'United Kingdom',
                    'DE' => 'Germany',
                    'FR' => 'France',
                    'NL' => 'Netherlands',
                ]),
                TextInput::make('vat_number')->label('VAT number')->required()->placeholder('DE123456789')
                    ->visibleWhen('country', 'in', ['DE', 'FR', 'NL'])->clearWhenHidden()->columnSpan(2),
                KeyValue::make('invoice_metadata')->keyPlaceholder('region')->valuePlaceholder('EMEA')
                    ->addActionLabel('Add metadata')->maxItems(8)->default(['region' => 'EMEA', 'cost_center' => 'OPS-204'])
                    ->help('Shown on every invoice.')->columnSpan(2),
                Toggle::make('auto_renew')->onLabel('On')->offLabel('Off')->default(true),
                Checkbox::make('terms')->label('I agree to the subscription terms')->rule('accepted'),
            ]),
            Submit::make('Subscribe')->processingLabel('Subscribing…'),
        ];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\Checkbox;
use Erag\InertiaForms\Fields\CheckboxGroup;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;

/**
 * Patient intake: insurance details appear only when the patient is insured.
 */
class ClinicIntakeForm extends Form
{
    protected ?string $actionUrl = '/clinic-intake';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Patient')->columns(2)->fields([
                TextInput::make('patient_name')->required()->placeholder('Jane Doe'),
                DatePicker::make('birthday')->label('Date of birth')->required()->maxDate(today()->toDateString()),
                TextInput::make('phone')->tel()->required(),
                Combobox::make('reason')->label('Reason for visit')->required()->options([
                    ['value' => 'checkup', 'label' => 'General check-up', 'description' => 'Routine visit'],
                    ['value' => 'follow_up', 'label' => 'Follow-up', 'description' => 'After a previous visit'],
                    ['value' => 'new_issue', 'label' => 'New problem', 'description' => 'Something new to look at'],
                ]),
            ]),
            Fieldset::make('Symptoms')->columns(2)->fields([
                CheckboxGroup::make('symptoms')->options([
                    'fever' => 'Fever',
                    'cough' => 'Cough',
                    'headache' => 'Headache',
                    'fatigue' => 'Fatigue',
                    'nausea' => 'Nausea',
                ])->inline()->columnSpan(2),
                Slider::make('pain_level')->min(0)->max(10)->default(0)->columnSpan(2),
                TagsInput::make('allergies')->suggestions(['penicillin', 'peanuts', 'latex', 'pollen'])->columnSpan(2),
                DatePicker::make('appointment_at')->label('Preferred appointment')->withTime()->minDate(now()->toDateString())->clearable(),
                Toggle::make('insured')->label('I have insurance')->onLabel('Yes')->offLabel('No'),
                TextInput::make('insurance_number')->required()->visibleWhen('insured', 'truthy')->clearWhenHidden()->columnSpan(2),
            ]),
            Checkbox::make('consent')->label('I consent to the clinic storing this information')->rule('accepted'),
            Submit::make('Send intake form')->processingLabel('Sending…'),
        ];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\CheckboxGroup;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\TimePicker;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;

/**
 * Book a stay: pet details show up when pets are coming along.
 */
class PropertyBookingForm extends Form
{
    protected ?string $actionUrl = '/property-booking';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Stay')->columns(2)->fields([
                Combobox::make('property')->required()->options([
                    ['value' => 'lake_house', 'label' => 'Lake house', 'description' => '3 bedrooms, private dock'],
                    ['value' => 'city_loft', 'label' => 'City loft', 'description' => '1 bedroom, walk to everything'],
                    ['value' => 'forest_cabin', 'label' => 'Forest cabin', 'description' => '2 bedrooms, wood stove'],
                ])->columnSpan(2),
                DatePicker::make('stay')->label('Check-in and check-out')->range()->required()->minDate(now()->toDateString())->columnSpan(2),
                TimePicker::make('arrival')->label('Arrival time')->minuteStep(30)->minTime('14:00')->maxTime('22:00')->clearable(),
                Slider::make('guests')->min(1)->max(8)->default(2),
            ]),
            Fieldset::make('Extras')->columns(2)->fields([
                Radio::make('bed_setup')->options(['double' => 'Double bed', 'twin' => 'Twin beds'])->buttons()->default('double'),
                CheckboxGroup::make('extras')->options([
                    'breakfast' => 'Breakfast',
                    'pickup' => 'Airport pickup',
                    'late_checkout' => 'Late checkout',
                ])->buttons(),
                Toggle::make('pets')->label('Travelling with pets')->onLabel('Yes')->offLabel('No'),
                TextInput::make('pet_details')->placeholder('One small dog')->required()
                    ->visibleWhen('pets', 'truthy')->clearWhenHidden(),
                Textarea::make('requests')->label('Special requests')->rows(3)->maxLength(300)->showCharacterCount()->columnSpan(2),
            ]),
            Submit::make('Request booking')->processingLabel('Requesting…'),
        ];
    }
}
php
<?php

namespace App\Forms;

use Erag\InertiaForms\Fields\Block;
use Erag\InertiaForms\Fields\Blocks;
use Erag\InertiaForms\Fields\ColorPicker;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\FileUpload;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;

/**
 * Schedule an article: the publish time is only needed once it is scheduled.
 */
class EditorialCalendarForm extends Form
{
    protected ?string $actionUrl = '/editorial-calendar';

    /**
     * @return array<int, Field|Fieldset>
     */
    public function fields(): array
    {
        return [
            Fieldset::make('Article')->columns(2)->fields([
                TextInput::make('headline')->required()->maxLength(90)->placeholder('Ten tips for faster forms')->columnSpan(2),
                Combobox::make('author')->searchable()->required()->clearable()->placeholder('Search authors…')->options([
                    ['value' => 1, 'label' => 'Avery Stone', 'description' => 'avery@example.test'],
                    ['value' => 2, 'label' => 'Rowan Example', 'description' => 'rowan@example.test'],
                    ['value' => 3, 'label' => 'Morgan Vale', 'description' => 'morgan@example.test'],
                    ['value' => 4, 'label' => 'Casey Example', 'description' => 'casey@example.test'],
                    ['value' => 5, 'label' => 'Skyler Example', 'description' => 'skyler@example.test'],
                    ['value' => 6, 'label' => 'Aisha Khan', 'description' => 'aisha@example.test'],
                    ['value' => 7, 'label' => 'Ben Carter', 'description' => 'ben@example.test'],
                    ['value' => 8, 'label' => 'Chen Wei', 'description' => 'chen@example.test'],
                    ['value' => 9, 'label' => 'Diego Ruiz', 'description' => 'diego@example.test'],
                    ['value' => 10, 'label' => 'Emma Novak', 'description' => 'emma@example.test'],
                ]),
                Radio::make('section')->options(['news' => 'News', 'guides' => 'Guides', 'opinion' => 'Opinion'])->buttons()->default('guides'),
                Textarea::make('summary')->rows(3)->autoResize()->maxLength(200)->showCharacterCount()->columnSpan(2),
            ]),
            Fieldset::make('Body')->fields([
                Blocks::make('body')->label('Body outline')->addActionLabel('Add content block')->maxItems(12)
                    ->blocks([
                        Block::make('section')->description('Heading with notes')->titleFrom('heading')->fields([
                            TextInput::make('heading')->required()->maxLength(90),
                            Textarea::make('summary')->rows(2)->autoResize(),
                        ]),
                        Block::make('quote')->description('Pull quote')->icon('Q')->columns(2)->fields([
                            Textarea::make('text')->label('Quote')->required()->rows(2)->columnSpan(2),
                            TextInput::make('author')->required(),
                            TextInput::make('source')->placeholder('Book, talk or link'),
                        ]),
                        Block::make('image')->description('Picture with a caption')->icon('I')->fields([
                            FileUpload::make('file')->image()->maxSize(3072),
                            TextInput::make('caption'),
                        ]),
                    ])
                    ->default([
                        ['type' => 'section', 'data' => ['heading' => 'Why forms belong in PHP']],
                        ['type' => 'quote', 'data' => ['text' => 'One class drives the page and the validation.', 'author' => 'Aisha Khan']],
                    ]),
            ]),
            Fieldset::make('Publishing')->columns(2)->fields([
                Combobox::make('status')->options([
                    ['value' => 'draft', 'label' => 'Draft', 'description' => 'Still being written'],
                    ['value' => 'review', 'label' => 'In review', 'description' => 'Waiting for an editor'],
                    ['value' => 'scheduled', 'label' => 'Scheduled', 'description' => 'Goes live at a set time'],
                ])->default('draft'),
                DatePicker::make('publish_at')->withTime()->required()->minDate(now()->toDateString())
                    ->visibleWhen('status', 'scheduled')->clearWhenHidden(),
                TagsInput::make('tags')->suggestions(['laravel', 'inertia', 'tutorial', 'release'])->reorderable()->maxTags(5)->columnSpan(2),
                FileUpload::make('cover_image')->image()->maxSize(3072),
                ColorPicker::make('accent')->label('Accent color')->swatches(['#4f46e5', '#0f766e', '#b45309', '#be123c'])->default('#0f766e'),
                Slider::make('word_target')->label('Word count target')->min(300)->max(3000)->step(100)->default(1200)->columnSpan(2),
                Toggle::make('featured')->label('Feature on the home page')->onLabel('Yes')->offLabel('No'),
            ]),
            Submit::make('Save article')->processingLabel('Saving…'),
        ];
    }
}

Custom fields in this demo ​

The Custom fields form uses three field types that are not part of the package: Rating, CodeInput and QuantityStepper. Each one is a small PHP class plus a frontend component registered through the components prop. See Custom Fields for the full guide.

php
<?php

namespace App\Forms\Fields;

use Erag\InertiaForms\Fields\Field;

/**
 * Star rating rendered by the `Rating` frontend component.
 */
class Rating extends Field
{
    protected int $stars = 5;

    public function component(): string
    {
        return 'Rating';
    }

    /**
     * Number of stars to show.
     */
    public function stars(int $stars): static
    {
        $this->stars = max(1, $stars);

        return $this;
    }

    public function emptyValue(): mixed
    {
        return null;
    }

    /**
     * @return array<int, string>
     */
    protected function typeRules(): array
    {
        return ['integer', 'between:1,'.$this->stars];
    }

    /**
     * @return array{stars: int}
     */
    protected function props(): array
    {
        return ['stars' => $this->stars];
    }
}
php
<?php

namespace App\Forms\Fields;

use Erag\InertiaForms\Fields\Field;

/**
 * One box per digit, like a verification code, rendered by the `CodeInput` component.
 */
class CodeInput extends Field
{
    protected int $length = 6;

    public function component(): string
    {
        return 'CodeInput';
    }

    /**
     * Number of digits in the code.
     */
    public function length(int $length): static
    {
        $this->length = min(max($length, 2), 10);

        return $this;
    }

    /**
     * @return array<int, string>
     */
    protected function typeRules(): array
    {
        return ['digits:'.$this->length];
    }

    /**
     * @return array{length: int}
     */
    protected function props(): array
    {
        return ['length' => $this->length];
    }
}
php
<?php

namespace App\Forms\Fields;

use Erag\InertiaForms\Fields\Field;

/**
 * A number with minus and plus buttons, rendered by the `QuantityStepper` component.
 */
class QuantityStepper extends Field
{
    protected int $min = 0;

    protected int $max = 99;

    protected int $step = 1;

    protected ?string $unit = null;

    public function component(): string
    {
        return 'QuantityStepper';
    }

    public function min(int $min): static
    {
        $this->min = $min;

        return $this;
    }

    public function max(int $max): static
    {
        $this->max = $max;

        return $this;
    }

    public function step(int $step): static
    {
        $this->step = max(1, $step);

        return $this;
    }

    /**
     * Text shown after the number, like "tickets".
     */
    public function unit(?string $unit): static
    {
        $this->unit = $unit;

        return $this;
    }

    public function emptyValue(): mixed
    {
        return $this->min;
    }

    /**
     * @return array<int, string>
     */
    protected function typeRules(): array
    {
        return ['integer', 'between:'.$this->min.','.$this->max];
    }

    /**
     * @return array{min: int, max: int, step: int, unit: string|null}
     */
    protected function props(): array
    {
        return ['min' => $this->min, 'max' => $this->max, 'step' => $this->step, 'unit' => $this->unit];
    }
}
vue
<script setup lang="ts">
import { computed, ref } from 'vue';

/**
 * Star rating for the custom `Rating` field (demo/Fields/Rating.php).
 */
const props = defineProps<{
    field: Record<string, any>;
    id: string;
    modelValue: unknown;
    error?: string;
    disabled: boolean;
    describedBy?: string;
}>();
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>();

const starPath = 'M12 2.5l2.9 6.1 6.6.8-4.9 4.6 1.3 6.6L12 17.3l-5.9 3.3 1.3-6.6-4.9-4.6 6.6-.8L12 2.5z';
const stars = computed(() => Number(props.field.stars ?? 5));
const current = computed(() => Number(props.modelValue) || 0);
const hover = ref(0);
const buttons = ref<HTMLButtonElement[]>([]);
const locked = computed(() => props.disabled || Boolean(props.field.readonly));
const shown = computed(() => hover.value || current.value);

function pick(star: number): void {
    emit('update:modelValue', star);
    buttons.value[star - 1]?.focus();
}

function keydown(event: KeyboardEvent): void {
    const moves: Record<string, number> = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 };

    if (event.key in moves) {
        event.preventDefault();
        pick(Math.min(stars.value, Math.max(1, current.value + moves[event.key]!)));
    } else if (event.key === 'Home' || event.key === 'End') {
        event.preventDefault();
        pick(event.key === 'Home' ? 1 : stars.value);
    }
}
</script>

<template>
    <div class="flex items-center gap-3">
        <div
            :id="id"
            role="radiogroup"
            :aria-describedby="describedBy"
            :aria-invalid="error ? true : undefined"
            :aria-required="field.required || undefined"
            class="flex gap-0.5 rounded-lg aria-invalid:ring-2 aria-invalid:ring-red-500/30"
            @pointerleave="hover = 0"
        >
            <button
                v-for="star in stars"
                :key="star"
                ref="buttons"
                type="button"
                role="radio"
                :aria-checked="current === star"
                :aria-label="`${star} of ${stars} stars`"
                :tabindex="star === (current || 1) ? 0 : -1"
                :disabled="locked"
                class="rounded-md p-0.5 transition hover:scale-110 focus-visible:ring-2 focus-visible:ring-[var(--erag-form-accent,#4f46e5)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
                @pointerenter="!locked && (hover = star)"
                @click="pick(star)"
                @keydown="keydown"
            >
                <svg
                    viewBox="0 0 24 24"
                    aria-hidden="true"
                    stroke-width="1.5"
                    stroke-linejoin="round"
                    :class="
                        shown >= star
                            ? 'size-7 fill-amber-400 stroke-amber-500'
                            : 'size-7 fill-transparent stroke-zinc-300 dark:stroke-zinc-600'
                    "
                >
                    <path :d="starPath" />
                </svg>
            </button>
        </div>
        <span class="text-sm text-zinc-500 tabular-nums dark:text-zinc-400">
            {{ shown ? `${shown} / ${stars}` : 'Not rated yet' }}
        </span>
    </div>
</template>
vue
<script setup lang="ts">
import { computed, ref, watch } from 'vue';

/**
 * One box per digit for the custom `CodeInput` field (demo/Fields/CodeInput.php).
 */
const props = defineProps<{
    field: Record<string, any>;
    id: string;
    modelValue: unknown;
    error?: string;
    disabled: boolean;
    describedBy?: string;
}>();
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>();

const length = computed(() => Number(props.field.length ?? 6));
const code = computed(() => (typeof props.modelValue === 'string' ? props.modelValue.replace(/\D/g, '') : ''));
const boxes = ref<HTMLInputElement[]>([]);
// The code just typed, before the new value comes back as a prop.
let latest = code.value;
watch(code, (next) => {
    latest = next;
});

function emitCode(next: string): void {
    latest = next;
    emit('update:modelValue', next);
}
const locked = computed(() => props.disabled || Boolean(props.field.readonly));

function onFocus(index: number): void {
    if (index > latest.length) focusBox(latest.length);
}

function focusBox(index: number): void {
    const box = boxes.value[Math.min(Math.max(index, 0), length.value - 1)];
    box?.focus();
    box?.select();
}

function type(index: number, event: Event): void {
    const input = event.target as HTMLInputElement;
    const digit = input.value.replace(/\D/g, '').slice(-1);
    input.value = code.value[index] ?? '';

    if (!digit) {
        return;
    }

    const position = Math.min(index, code.value.length);
    emitCode((code.value.slice(0, position) + digit + code.value.slice(position + 1)).slice(0, length.value));
    focusBox(position + 1);
}

function keydown(index: number, event: KeyboardEvent): void {
    if (event.key === 'Backspace') {
        event.preventDefault();
        const position = code.value[index] ? index : index - 1;

        if (position >= 0) {
            emitCode(code.value.slice(0, position) + code.value.slice(position + 1));
            focusBox(position);
        }
    } else if (event.key === 'ArrowLeft') {
        event.preventDefault();
        focusBox(index - 1);
    } else if (event.key === 'ArrowRight') {
        event.preventDefault();
        focusBox(Math.min(index + 1, code.value.length));
    }
}

function paste(event: ClipboardEvent): void {
    event.preventDefault();
    const pasted = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '').slice(0, length.value);

    if (pasted) {
        emitCode(pasted);
        focusBox(pasted.length);
    }
}
</script>

<template>
    <div role="group" :aria-describedby="describedBy" class="flex flex-wrap gap-2">
        <input
            v-for="(_, index) in length"
            :id="index === 0 ? id : `${id}-${index}`"
            :key="index"
            ref="boxes"
            type="text"
            inputmode="numeric"
            :autocomplete="index === 0 ? 'one-time-code' : 'off'"
            :aria-label="`Digit ${index + 1} of ${length}`"
            :aria-invalid="error ? true : undefined"
            :value="code[index] ?? ''"
            :disabled="locked"
            class="size-11 rounded-lg border border-zinc-300 bg-white text-center text-lg font-semibold text-zinc-900 shadow-xs tabular-nums transition focus:border-[var(--erag-form-accent,#4f46e5)] focus:ring-2 focus:ring-[color-mix(in_oklab,var(--erag-form-accent,#4f46e5)_22%,transparent)] focus:outline-none disabled:cursor-not-allowed disabled:bg-zinc-50 aria-invalid:border-red-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
            @focus="onFocus(index)"
            @input="type(index, $event)"
            @keydown="keydown(index, $event)"
            @paste="paste"
        />
    </div>
</template>
vue
<script setup lang="ts">
import { computed } from 'vue';

/**
 * Minus / plus counter for the custom `QuantityStepper` field (demo/Fields/QuantityStepper.php).
 */
const props = defineProps<{
    field: Record<string, any>;
    id: string;
    modelValue: unknown;
    error?: string;
    disabled: boolean;
    describedBy?: string;
}>();
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>();

const min = computed(() => Number(props.field.min ?? 0));
const max = computed(() => Number(props.field.max ?? 99));
const step = computed(() => Number(props.field.step ?? 1));
const unit = computed(() => (typeof props.field.unit === 'string' ? props.field.unit : null));
const locked = computed(() => props.disabled || Boolean(props.field.readonly));
const number = computed(() =>
    props.modelValue === '' || props.modelValue === null || props.modelValue === undefined ? null : Number(props.modelValue),
);
const buttonClass =
    'flex w-10 items-center justify-center text-lg font-medium text-zinc-600 transition hover:bg-zinc-50 hover:text-zinc-900 focus-visible:bg-zinc-100 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-300 dark:hover:bg-zinc-800';

function clamp(next: number): number {
    return Math.min(max.value, Math.max(min.value, next));
}

function input(event: Event): void {
    const value = (event.target as HTMLInputElement).value;
    emit('update:modelValue', value === '' ? null : Number(value));
}
</script>

<template>
    <div class="flex items-center gap-3">
        <div
            :aria-invalid="error ? true : undefined"
            class="inline-flex h-10 items-stretch overflow-hidden rounded-lg border border-zinc-300 bg-white shadow-xs focus-within:border-[var(--erag-form-accent,#4f46e5)] focus-within:ring-2 focus-within:ring-[color-mix(in_oklab,var(--erag-form-accent,#4f46e5)_22%,transparent)] aria-invalid:border-red-500 dark:border-zinc-700 dark:bg-zinc-900"
        >
            <button
                type="button"
                :aria-label="`Decrease ${field.label}`"
                :disabled="locked || (number ?? min) <= min"
                :class="buttonClass"
                @click="emit('update:modelValue', clamp((number ?? min) - step))"
            >
                −
            </button>
            <input
                :id="id"
                type="number"
                inputmode="numeric"
                :min="min"
                :max="max"
                :step="step"
                :value="number ?? ''"
                :disabled="locked"
                :aria-describedby="describedBy"
                :aria-invalid="error ? true : undefined"
                class="w-14 [appearance:textfield] border-x border-zinc-200 bg-transparent text-center text-sm font-semibold text-zinc-900 tabular-nums focus:outline-none dark:border-zinc-700 dark:text-zinc-100 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
                @input="input"
                @blur="number !== null && emit('update:modelValue', clamp(number))"
            />
            <button
                type="button"
                :aria-label="`Increase ${field.label}`"
                :disabled="locked || (number ?? min) >= max"
                :class="buttonClass"
                @click="emit('update:modelValue', clamp((number ?? min) + step))"
            >
                +
            </button>
        </div>
        <span v-if="unit" class="text-sm text-zinc-500 dark:text-zinc-400">{{ unit }}</span>
    </div>
</template>
vue
<Form :form="form" :components="{ Rating, CodeInput, QuantityStepper }" />

Render it in your app ​

Pass the form from a controller, then render it with one component. The accent prop sets the theme color, and you can also set it in PHP with ->accent('#059669').

php
use App\Forms\EventSessionForm;
use Inertia\Inertia;

public function create()
{
    return Inertia::render('EventSession', [
        'form' => EventSessionForm::make(),
    ]);
}
vue
<script setup lang="ts">
import { Form, type FormSchema } from '@erag/inertia-forms-vue';

defineProps<{ form: FormSchema }>();
</script>

<template>
    <Form :form="form" accent="#059669" />
</template>
tsx
import { Form, type FormSchema } from '@erag/inertia-forms-react';

export default function EventSession({ form }: { form: FormSchema }) {
    return <Form form={form} accent="#059669" />;
}
svelte
<script lang="ts">
    import { Form, type FormSchema } from '@erag/inertia-forms-svelte';

    let { form }: { form: FormSchema } = $props();
</script>

<Form {form} accent="#059669" />

Next, follow the Quick Start to build your first form, or read about styling.