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.
* 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.
<!-- 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 & 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.
<!-- 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>