Koala logo Design

Forms

Working validation demo showing every field type. In the Portal, forms use Alpine-AJAX with koala-inline-validation-for for per-field validation on blur.

£
Reset
<form method="post" x-target.push="main" novalidate>
    <!-- Text inputs (2-column) -->
    <div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
        <div koala-inline-validation-for="Input.FirstName">
            <label asp-for="Input.FirstName"
                   class="block mb-2.5 font-medium text-gray-900 dark:text-white">First name</label>
            <input asp-for="Input.FirstName" placeholder=""/>
            <span asp-validation-for="Input.FirstName" class="mt-2 block"></span>
        </div>
        <div koala-inline-validation-for="Input.LastName">
            <label asp-for="Input.LastName"
                   class="block mb-2.5 font-medium text-gray-900 dark:text-white">Last name</label>
            <input asp-for="Input.LastName" placeholder=""/>
            <span asp-validation-for="Input.LastName" class="mt-2 block"></span>
        </div>
    </div>

    <!-- Email with icon prefix -->
    <div koala-inline-validation-for="Input.Email">
        <label asp-for="Input.Email"
               class="block mb-2.5 font-medium text-gray-900 dark:text-white">Email</label>
        <input asp-for="Input.Email" koala-input-prefix="Email" placeholder=""/>
        <span asp-validation-for="Input.Email" class="mt-2 block"></span>
    </div>

    <!-- Currency input with £ prefix -->
    <div koala-inline-validation-for="Input.Amount">
        <label asp-for="Input.Amount"
               class="block mb-2.5 font-medium text-gray-900 dark:text-white">Amount</label>
        <div class="relative">
            <div class="absolute inset-y-0 start-0 flex items-center ps-2.5
                        pointer-events-none dark:text-white">
                &pound;
            </div>
            <input asp-for="Input.Amount"
                   inputmode="numeric"
                   data-type="currency"
                   class="block w-full ps-7 pe-3 py-2.5 bg-surface
                          border border-gray-200 dark:border-gray-600 text-gray-900
                          dark:text-white rounded-lg placeholder:gray-100"
                   placeholder=""/>
        </div>
        <span asp-validation-for="Input.Amount" class="mt-2 block"></span>
    </div>

    <!-- Secret input with show/hide toggle -->
    <div koala-inline-validation-for="Input.ApiKey">
        <label asp-for="Input.ApiKey"
               class="block mb-2.5 font-medium text-gray-900 dark:text-white">API key</label>
        <div class="relative" x-data="{ visible: false }">
            <input asp-for="Input.ApiKey"
                   type="password"
                   :type="visible ? 'text' : 'password'"
                   autocomplete="new-password"
                   spellcheck="false"
                   placeholder="Enter API key"
                   class="bg-surface border border-gray-200
                          dark:border-gray-600 text-gray-900 dark:text-white
                          rounded-lg block w-full py-2.5 ps-3 pe-10
                          placeholder:text-gray-400"/>
            <button type="button"
                    x-on:click="visible = !visible"
                    :aria-label="visible ? 'Hide API key' : 'Show API key'"
                    :title="visible ? 'Hide API key' : 'Show API key'"
                    tabindex="-1"
                    class="absolute inset-y-0 end-0 px-3 flex items-center
                           text-gray-500 hover:text-gray-700
                           dark:text-gray-400 dark:hover:text-gray-200">
                <koala-icon name="Eye" x-show="!visible" />
                <koala-icon name="EyeOff" x-show="visible" x-cloak />
            </button>
        </div>
        <span asp-validation-for="Input.ApiKey" class="mt-2 block"></span>
    </div>

    <!-- Alpine.js custom dropdown (no koala-inline-validation-for) -->
    <div>
        <label class="block mb-2.5 font-medium text-gray-900 dark:text-white">Category</label>
        <div x-data="{ open: false, selected: '' }" class="relative"
             x-on:click.outside="open = false">
            <button type="button" x-on:click="open = !open"
                    class="flex items-center justify-between w-full px-3 py-2.5
                           bg-surface border border-gray-200
                           dark:border-gray-600 text-gray-900 dark:text-white rounded-lg">
                <span x-text="selected || 'Select a category'"
                      :class="selected ? '' : 'text-gray-400'"></span>
                <koala-icon name="ChevronDown" size="Small" class="text-gray-400" />
            </button>
            <input type="hidden" name="Input.Category" :value="selected" />
            <div x-show="open" x-transition x-cloak
                 class="absolute z-10 mt-1 w-full bg-surface
                        border border-border-default rounded-lg
                        shadow-lg py-1 overflow-hidden">
                <button type="button" x-on:click="selected = 'Sale'; open = false"
                        class="block w-full px-3 py-2 text-left text-gray-900
                               dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700"
                        :class="selected === 'Sale' ? 'bg-gray-50 dark:bg-gray-700' : ''">Sale</button>
                <button type="button" x-on:click="selected = 'Purchase'; open = false"
                        class="block w-full px-3 py-2 text-left text-gray-900
                               dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700"
                        :class="selected === 'Purchase' ? 'bg-gray-50 dark:bg-gray-700' : ''">Purchase</button>
                <button type="button" x-on:click="selected = 'Remortgage'; open = false"
                        class="block w-full px-3 py-2 text-left text-gray-900
                               dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700"
                        :class="selected === 'Remortgage' ? 'bg-gray-50 dark:bg-gray-700' : ''">Remortgage</button>
            </div>
        </div>
        <span asp-validation-for="Input.Category" class="mt-2 block"></span>
    </div>

    <!-- Textarea -->
    <div koala-inline-validation-for="Input.Notes">
        <label asp-for="Input.Notes"
               class="block mb-2.5 font-medium text-gray-900 dark:text-white">Notes</label>
        <textarea asp-for="Input.Notes" rows="4" placeholder=""></textarea>
        <span asp-validation-for="Input.Notes" class="mt-2 block"></span>
    </div>

    <button type="submit" koala-loading koala-btn="Primary">Submit</button>
</form>

Invalid-state border on custom pickers

Native <input>/<select>/<textarea> elements with asp-for get the red input-validation-error class automatically when ModelState has an error. Custom Alpine dropdowns, radio groups, and any other non-native form control need the koala-invalid-for tag helper to match.

<button type="button"
        koala-invalid-for="Input.DefaultUserId"
        class="flex w-full items-center justify-between px-3 py-2.5 bg-white
               border border-gray-200 rounded-xl ...">
    ...
</button>

The tag helper adds input-validation-error to the element whenever ModelState["Input.DefaultUserId"] has errors, which triggers the same rose-400 border styling the inputs get. Works on any element — buttons, divs, whatever carries the picker's visible chrome.

Secret inputs

API keys, tokens, and other secrets use type="password" with an Eye/EyeOff toggle so users can verify what they're typing. Wrap the input in a relative div with x-data="{ visible: false }", bind :type="visible ? 'text' : 'password'" on the input, and place an absolutely-positioned toggle button at the right edge.

  • Keep a static type="password" so the value stays masked before Alpine hydrates
  • Add tabindex="-1" on the toggle so Tab moves to the next form field, not the eye
  • Set autocomplete="new-password" and spellcheck="false" to stop browsers saving or spell-checking secrets
  • Use pe-10 on the input so the text never sits under the toggle; duplicate the base input classes since the input tag helper bails when class is set
  • Never echo a saved secret back to the browser — render a masked placeholder (e.g. ••••••••1c2a) and require a fresh entry to change it

Inline field validation

Every form field that needs per-field validation on blur must be wrapped in a div with koala-inline-validation-for.

<div koala-inline-validation-for="Input.FieldName">
    <label asp-for="Input.FieldName" class="block mb-2.5 font-medium text-gray-900 dark:text-white">Label</label>
    <input asp-for="Input.FieldName" placeholder=""/>
    <span asp-validation-for="Input.FieldName" class="mt-2 block"></span>
</div>

Requires a matching OnPostValidateField handler in the page model. Custom dropdowns and radio buttons must NOT use this attribute.

Key rules

  • novalidate on every form — all validation is server-side via FluentValidation
  • koala-inline-validation-for on the wrapping div of every field (requires Alpine-AJAX)
  • Custom dropdowns and radio buttons must not use koala-inline-validation-for
  • Custom pickers (Alpine dropdowns, radio groups, etc.) use koala-invalid-for to get the red invalid border
  • koala-loading on submit buttons for spinner and click guard
  • Input model properties use init accessors, not set
  • Use nullable types (string?) to avoid ASP.NET's implicit required validation
  • Never save data on blur — only the submit button triggers OnPost()
  • Every page model using koala-inline-validation-for must have an OnPostValidateField handler
  • Secret inputs (API keys, tokens) use type="password" with an Eye/EyeOff toggle — never render a saved secret back to the browser

Form actions

Bottom-of-form button row — flex-col on mobile, flex-row on desktop with gap-3. Replaces the boilerplate <div class="flex flex-col sm:flex-row gap-3"> wrapper around submit + cancel buttons.

Cancel
<koala-form-actions>
    <button type="submit" koala-loading koala-btn="Primary" class="w-full sm:w-auto">Save</button>
    <a href="#" koala-btn="Outline" class="w-full sm:w-auto">Cancel</a>
</koala-form-actions>

Form field

Replaces the four-line <div koala-inline-validation-for> + <label asp-for> + <input asp-for> + <span asp-validation-for> quadruple. Saves ~5 lines per field across hundreds of form pages. For richer fields (autocomplete, dropdowns, custom layouts) keep using the explicit markup — this helper covers the simple text/email/tel/number case that dominates form pages.

<koala-field for="Input.FirstName" label="First name" />
<koala-field for="Input.EmailAddress" label="Email" type="email" input-prefix="Email" />
<koala-field for="Input.WebsiteUrl" label="Website" type="url" optional="true" />
<koala-field for="Input.Phone" label="Phone" inputmode="numeric" class="mb-5" />

Attributes: for (required), label, type, placeholder, autocomplete, required, optional, input-prefix (Email/Phone), inputmode, class.

Auto-required labels

<label asp-for> automatically appends a muted (required) suffix if the bound property has a FluentValidation NotEmpty() or NotNull() rule — so authors don't have to mark required state manually. For custom dropdowns and radio groups (which don't bind via asp-for), use koala-required="true" explicitly. (required) reads more clearly than an asterisk for non-form-savvy users — the word is unambiguous; the muted gray keeps it secondary to the label itself.

<!-- Validator has NotEmpty() — (required) suffix auto-rendered -->
<label asp-for="Input.FirstName">First name</label>

<!-- Custom dropdown with no asp-for binding — explicit -->
<label koala-required="true">Role</label>

Form-level errors

Renders the form-level model-state errors (the string.Empty ModelState key) in a red alert box at the top of the form. Used for cross-field validation errors and other errors that aren't bound to a specific field.

<form method="post" novalidate>
    <koala-form-errors />
    <koala-field for="Input.Name" label="Name" />
    ...
</form>

Yes/No radio pills

Pill-style Yes/No radio group. Replaces ~25 lines of repeated peer-checked Tailwind markup per question on the quote-create flow.

<koala-radio-yes-no for="IsNewBuild" label="Is the property a new build?" />
<koala-radio-yes-no for="IsUsingMortgage" label="Mortgage?" class="mt-8" />

Radio groups must not use koala-inline-validation-for — the change-fired AJAX re-render resets scroll position. This helper validates on submit only.

Step heading (_StepHeading)

Heading partial for each step in a multi-step form (create-quote, onboarding, settings sub-flows). Renders an <h2> with a thin edge-to-edge divider underneath, so the section the user is on reads as its own bordered block inside the surrounding card. Pair with the footer action row (_FormButtons / _ClientFormButtons) which carries the same edge-to-edge divider above its buttons — heading + footer frame the step content top and bottom.

Tell us about the property you're buying

Step content goes here — questions, inputs, etc.

<partial name="_StepHeading" model="Tell us about the property you're buying" />

Preserve input across multi-step flows

Walks an input model and renders <input type="hidden"> tags for every scalar property. Used in multi-step flows (quote-create, partner-create) where the next-step form needs to round-trip the previous-step data without losing it.

<form method="post" asp-page-handler="Step2">
    <koala-preserve-input prefix="Input" model="@Model.Input" />
    <!-- step 2 fields -->
</form>

CRM picker

<koala-crm-select for="..."/> renders the canonical CRM dropdown used on every branch create/edit form (Reapit / MRI Software / Jupix / Alto / Expert Agent / Rex Software / Dezrez / Street.co.uk / Loop / AgentOS, plus a "Select CRM" placeholder). Centralising the option list means adding a new CRM is a one-file change. Portal-only.

<koala-crm-select for="Input.Crm" />

<!-- with a custom wrapper class (default is mb-5) -->
<koala-crm-select for="Input.Crm" class="mb-8" />

Yes / No radio group

<koala-radio-yes-no for="..." label="..."/> renders a two-pill radio group for boolean questions. Replaces ~25 lines of peer-checked Tailwind markup per question (used for every "is this a new build / is there a mortgage / is this shared ownership" prompt in the quote-create flow). The helper reads the parent partial's HtmlFieldPrefix automatically so name/id match what asp-for would generate.

Note: per CLAUDE.md, radio groups must NOT use koala-inline-validation-for — change-fired AJAX would reset Alpine state and tray scroll. This helper validates on submit only.

<koala-radio-yes-no for="IsNewBuild" label="Is the property a new build?" />
<koala-radio-yes-no for="IsUsingMortgage"
                    label="Is a mortgage being used to buy the property?"
                    class="mt-8" />

"I don't know yet" toggle

Used on purchase quote creation to let buyers proceed without an address — they tick the box, the address fields hide, validation skips the address sub-rules, and the quote saves with a null Address. The address is filled in later (in Hoowla, post-instruction) and syncs back via HoowlaTransactionSyncService.SyncAddressAsync.

Pattern: a nullable bool? on the input model with an x-data-scoped Alpine state, a paired hidden input so the unchecked value round-trips correctly, and an x-show wrapping the optional fields. The validator branches on the flag and skips its address sub-validator when set.

Read-side pair: views that render the optional value use _AddressTbcCallout.cshtml (an inline amber callout matching the design system warning style) when the field is null, plus _QuotePurchaseAddressLine.cshtml / _TransactionAddressLine.cshtml for compact list-card / table-row rendering — same muted-italic "Still house hunting" fallback, one partial per aggregate.

Property attribute defaults: when the address is unknown, QuotePurchase persists assumed defaults (House / Freehold / not new build / not shared ownership — see QuotePurchase.Default* constants). The create-form hint and the read-side callout both spell out the assumption so the customer can see what the price is based on, and Hoowla receives real values rather than nulls.

Tick this if the buyer is still searching. We'll fill the address in once it's confirmed.

👌 No rush — we'll add the address once you've found the one!

In the meantime we'll calculate your quote based on:

  • Freehold
  • House
  • Not a new build
  • Not part of a shared ownership scheme
<div x-data="{ addressUnknown: @(Model.AddressUnknown == true ? "true" : "false") }">
    <div class="mb-4">
        <label class="inline-flex items-center gap-2 cursor-pointer">
            <input type="hidden" name="@Html.NameFor(model => model.AddressUnknown)" value="false" />
            <input asp-for="AddressUnknown" type="checkbox" value="true"
                   x-model="addressUnknown"
                   class="h-5 w-5 rounded ..." />
            <span>I don't know the address yet</span>
        </label>
    </div>

    <div x-show="!addressUnknown">
        <partial name="_Address" for="PropertyDetails.Address" />
    </div>
</div>

Switch (<koala-switch>)

iOS-style track + sliding thumb backed by a real checkbox. Use for any binary on/off form input — settings toggles, "I don't know yet"-style escape hatches, multi-select rows where each row contributes a value to a posted list. Never roll the inline 14-line Tailwind salad; that's exactly what this tag helper exists to replace.

Reject new build purchases

Off in this demo. Click the switch to flip.

Notifications enabled

On in this demo.

Inline-label variant — the text and the track are inside one <label> so the text is also a click target.

Pill variant (variant="Pill")

Two-button segmented pill — same shape as the £/% discount amount-type toggle. Use when the binary needs explicit labels for both states ("Yes / No", "On / Off") rather than a single on/off track. Compact: text-sm buttons, gray-500 supporting label that's also clickable to flip the pill. Active text defaults to full gray-900 / white; pass subtle="true" when the pill is a quiet secondary toggle next to a primary input (e.g. the address widget's "Still house hunting?") and the active text should match the muted label colour.

Notifications
Still house hunting?
subtle
No label — pill alone
@* Property-bound (most common) *@
<koala-switch for="OrganisationInput.RejectNewBuildPurchases" />

@* Inline label *@
<koala-switch for="AddressUnknown" label="Still house hunting? 🏠" />

@* Pill variant (Yes/No segmented) *@
<koala-switch for="AddressUnknown" label="Still house hunting?" variant="Pill" />

@* Subtle pill — active text matches the muted label colour *@
<koala-switch for="AddressUnknown" label="Still house hunting?" variant="Pill" subtle="true" />

@* Pill with custom labels *@
<koala-switch for="IsPercentage" variant="Pill" on-label="%" off-label="£" />

@* Dynamic name (loops, multi-select-as-switch) *@
@foreach (var (status, fieldName, isChecked) in toggles)
{
    <koala-switch name="@fieldName" checked="@isChecked" />
}

@* Auto-submit on change *@
<koala-switch name="IsHoowlaEnabled" checked="@Model.IsHoowlaEnabled"
              x-on:change="$el.form.requestSubmit()" />

@* Alpine state binding (parent x-data scope provides the variable) *@
<koala-switch for="AddressUnknown" x-model="addressUnknown" />