Contact Form

Ready-to-use contact form patterns: basic, with extras, service routing, file attachment, honeypot spam protection, and success/error feedback.

Basic

The minimal contact form: name, email, message, and submit. Alpine handles client-side validation before a real fetch() POST would fire. Submit triggers a success state inline — no page reload.

Message sent!

We'll get back to you within 24 hours.

* Required

<form x-data="contactForm()" @submit.prevent="submit()">

    <div class="flex flex-col gap-4">

        <div>
            <label class="label pb-1" for="name">
                <span class="label-text font-medium">Name <span class="text-error">*</span></span>
            </label>
            <input id="name" type="text" class="input input-bordered input-sm w-full"
                   :class="errors.name ? 'input-error' : ''"
                   x-model="fields.name" @blur="validate()" />
            <p class="text-xs text-error mt-1" x-show="errors.name" x-text="errors.name" x-cloak></p>
        </div>

        <!-- email + message fields follow same pattern -->

        <button type="submit" class="btn btn-primary btn-sm" :disabled="status === 'loading'">
            <span class="loading loading-spinner loading-xs" x-show="status === 'loading'" x-cloak></span>
            <span x-text="status === 'loading' ? 'Sending…' : 'Send message'"></span>
        </button>

    </div>

</form>

With phone & department routing

Extends the basic form with an optional phone field and a department selector so messages are routed to the right team. The phone field validates an international format loosely — strict validation should happen server-side.

Your message has been sent. We'll be in touch soon.

* Required

<!-- Two-column grid layout — collapses to 1 col on mobile -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">

    <!-- Phone — optional field pattern -->
    <div>
        <label class="label pb-1" for="phone">
            <span class="label-text font-medium">Phone</span>
            <span class="label-text-alt text-base-content/40">Optional</span>
        </label>
        <input id="phone" type="tel" class="input input-bordered input-sm w-full"
               :class="errors.phone ? 'input-error' : ''"
               placeholder="+62 812 3456 7890"
               x-model="fields.phone" @blur="validate()" />
        <p class="text-xs text-error mt-1" x-show="errors.phone" x-text="errors.phone" x-cloak></p>
    </div>

    <!-- Department select -->
    <div>
        <label class="label pb-1" for="dept">
            <span class="label-text font-medium">Department</span>
        </label>
        <select id="dept" class="select select-bordered select-sm w-full"
                x-model="fields.department">
            <option value="">Select a department</option>
            <option>General Inquiry</option>
            <option>Sales</option>
            <option>Technical Support</option>
        </select>
    </div>

    <!-- Message spans both columns -->
    <div class="sm:col-span-2"> … </div>

</div>

With file attachment

Accepts one or more attachments. Alpine reads the FileList to preview filenames and sizes before upload. Max file size is validated client-side (5 MB per file); type restrictions are enforced via accept. A real implementation would send files as multipart/form-data.

Message and attachments sent successfully.

<!-- Drag-and-drop upload zone -->
<label class="flex flex-col items-center gap-2 border-2 border-dashed
              border-base-300 rounded-[--radius-sm] p-5 cursor-pointer
              hover:border-primary/50 hover:bg-base-200/40 transition-colors">
    <!-- icon -->
    <span class="text-sm text-base-content/50">Click to upload or drag &amp; drop</span>
    <input type="file" class="hidden" multiple
           accept=".pdf,.png,.jpg,.jpeg,.docx"
           @change="onFiles($event)" />
</label>

<!-- File preview list -->
<ul class="mt-2 flex flex-col gap-1.5">
    <template x-for="(file, idx) in files" :key="idx">
        <li class="flex items-center gap-2 text-xs bg-base-200 rounded-[--radius-sm] px-3 py-1.5">
            <span class="truncate flex-1" x-text="file.name"></span>
            <span class="text-base-content/40" x-text="formatSize(file.size)"></span>
            <button @click="removeFile(idx)"> <!-- × icon --> </button>
        </li>
    </template>
</ul>

Honeypot spam protection

A visually hidden field that real users never see or fill in. Bots that blindly populate all fields will trigger it. The field is hidden with CSS (not display:none or visibility:hidden, which some bots detect) and excluded from tab order with tabindex="-1". Server-side: reject the submission if the honeypot field is non-empty.

Message sent.
<!-- Honeypot field — hidden with CSS, NOT display:none (bots detect that) -->
<style>
.hp-trap {
    position: absolute;
    left: -9999px;
    width: 1px;
    height: 1px;
    overflow: hidden;
    opacity: 0;
    pointer-events: none;
}
</style>

<div class="hp-trap" aria-hidden="true">
    <label for="website">Website</label>
    <input id="website" type="text" name="website"
           tabindex="-1" autocomplete="off"
           x-model="fields.website" />
</div>

<!-- Server-side (PHP): -->
<?php
if (!empty($_POST['website'])) {
    // Bot detected — discard silently or log
    http_response_code(200);
    exit;
}
?>

Server error feedback

When the server returns an error (5xx, network failure, rate limit), show an inline error banner above the submit button. The user's input is preserved so they can retry without re-filling the form.

<!-- Error banner shown after failed submission -->
<div class="alert alert-error text-sm" x-show="status === 'error'" x-cloak>
    <!-- error icon -->
    <span x-text="errorMsg"></span>
</div>

<!-- Submit label changes to "Retry" on error -->
<button class="btn btn-primary btn-sm"
        :disabled="status === 'loading'"
        @click="submit()">
    <span class="loading loading-spinner loading-xs"
          x-show="status === 'loading'" x-cloak></span>
    <span x-text="status === 'loading' ? 'Sending…'
                : status === 'error'   ? 'Retry'
                : 'Send message'"></span>
</button>