Vue componentFree

Contact Form

Accessible Vue 3 contact form with inline validation, a live character counter and loading, success and error states. Tailwind CSS, dark mode.

by Vueplayv1.0.0Updated 2 hours ago

Install
Full screen ↗

About this component

A contact section for marketing sites, support pages and portfolios: contact details on the left, and a form with name, email, topic and message on the right. It stacks into a single column on phones.

Every field has a visible label. Errors appear inline once a field has been touched (or on submit), are tied to the input with aria-describedby, and focus jumps to the first problem. The message box has a live counter that turns amber near the limit and red past it, with a screen-reader friendly announcement.

Pass submitHandler, an async function that sends the data wherever you like. While it runs the form shows a spinner and disables inputs; resolve it to show the success panel or throw to show a retryable error. The submit, success and error events fire along the way if you prefer events.

  • Labelled fields with inline validation
  • Live character counter
  • Loading, success and error states
  • Async submitHandler or plain events
  • Contact details column with icons

Code

11 files in the repo
<script lang="ts">
export interface ContactFormData {
  name: string
  email: string
  topic: string
  message: string
}

export interface ContactDetail {
  icon: 'mail' | 'phone' | 'pin' | 'clock'
  label: string
  value: string
  href?: string
}

const ICONS: Record<ContactDetail['icon'], string> = {
  mail: 'M3 6.75A2.75 2.75 0 0 1 5.75 4h12.5A2.75 2.75 0 0 1 21 6.75v10.5A2.75 2.75 0 0 1 18.25 20H5.75A2.75 2.75 0 0 1 3 17.25V6.75Zm2.1-.5 6.9 5.18 6.9-5.18M3.5 7l8.5 6.25L20.5 7',
  phone: 'M2.25 6.75c0 8.28 6.72 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.37c0-.52-.35-.97-.85-1.09l-4.42-1.1c-.44-.11-.9.06-1.17.42l-.97 1.29a1.13 1.13 0 0 1-1.21.38 12.04 12.04 0 0 1-7.14-7.14 1.13 1.13 0 0 1 .38-1.21l1.3-.97c.36-.27.52-.73.41-1.17l-1.1-4.42a1.13 1.13 0 0 0-1.1-.85H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z',
  pin: 'M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM19.5 10.5c0 7.14-7.5 11.25-7.5 11.25S4.5 17.64 4.5 10.5a7.5 7.5 0 1 1 15 0Z',
  clock: 'M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z',
}
</script>

<script setup lang="ts">
import { computed, nextTick, reactive, ref, useId } from 'vue'

type Status = 'idle' | 'loading' | 'success' | 'error'

const props = withDefaults(defineProps<{
  heading?: string
  subheading?: string
  topics?: string[]
  /** Maximum message length; a live counter is shown. */
  maxLength?: number
  minLength?: number
  details?: ContactDetail[]
  /** Called on a valid submit. Return a promise: pending shows loading, resolve shows success, reject shows the error state. */
  submitHandler?: (data: ContactFormData) => Promise<unknown>
  successMessage?: string
  errorMessage?: string
}>(), {
  heading: 'Get in touch',
  subheading: 'Questions about pricing, a partnership idea or a bug that’s bugging you? Send us a note and a real person will reply.',
  topics: () => ['General question', 'Sales and pricing', 'Technical support', 'Partnerships', 'Press'],
  maxLength: 1000,
  minLength: 20,
  details: () => [
    { icon: 'mail', label: 'Email', value: 'hello@larkmoor.test', href: 'mailto:hello@larkmoor.test' },
    { icon: 'phone', label: 'Phone', value: '+1 (555) 014-2290', href: 'tel:+15550142290' },
    { icon: 'pin', label: 'Studio', value: '48 Harbor Street, Portland' },
    { icon: 'clock', label: 'Response time', value: 'Within one business day' },
  ],
  submitHandler: undefined,
  successMessage: 'Thanks! Your message is on its way. We usually reply within one business day.',
  errorMessage: 'We couldn’t send your message. Check your connection and try again.',
})

const emit = defineEmits<{
  submit: [data: ContactFormData]
  success: [data: ContactFormData]
  error: [error: unknown]
}>()

defineSlots<{
  /** Replace the contact details column. */
  aside?: () => unknown
  /** Extra fields or consent text above the submit button. */
  extra?: () => unknown
}>()

const uid = useId()
const id = (name: string) => `${uid}-${name}`
const form = reactive<ContactFormData>({ name: '', email: '', topic: '', message: '' })
const touched = reactive(new Set<keyof ContactFormData>())
const status = ref<Status>('idle')
const formEl = ref<HTMLFormElement | null>(null)
const statusEl = ref<HTMLElement | null>(null)

const validation = computed<Partial<Record<keyof ContactFormData, string>>>(() => {
  const e: Partial<Record<keyof ContactFormData, string>> = {}
  if (!form.name.trim()) e.name = 'Please tell us your name.'
  if (!form.email.trim()) e.email = 'We need an email address to reply to.'
  else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) e.email = 'That email address doesn’t look complete.'
  if (!form.topic) e.topic = 'Pick the topic that fits best.'
  const len = form.message.trim().length
  if (!len) e.message = 'Write a short message.'
  else if (len < props.minLength) e.message = `A little more detail helps: at least ${props.minLength} characters.`
  else if (form.message.length > props.maxLength) e.message = `Please keep it under ${props.maxLength} characters.`
  return e
})
const errors = computed(() => {
  const out: Partial<Record<keyof ContactFormData, string>> = {}
  for (const k of touched) if (validation.value[k]) out[k] = validation.value[k]
  return out
})

const remaining = computed(() => props.maxLength - form.message.length)
const counterClass = computed(() => remaining.value < 0
  ? 'text-rose-600 dark:text-rose-400'
  : remaining.value < props.maxLength * 0.1 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500 dark:text-gray-400')

async function onSubmit() {
  (['name', 'email', 'topic', 'message'] as const).forEach(k => touched.add(k))
  if (Object.keys(validation.value).length) {
    await nextTick()
    formEl.value?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
    return
  }
  const data: ContactFormData = { name: form.name.trim(), email: form.email.trim(), topic: form.topic, message: form.message.trim() }
  emit('submit', data)
  status.value = 'loading'
  try {
    await props.submitHandler?.(data)
    status.value = 'success'
    emit('success', data)
  }
  catch (err) {
    status.value = 'error'
    emit('error', err)
  }
  await nextTick()
  statusEl.value?.focus()
}

function reset() {
  Object.assign(form, { name: '', email: '', topic: '', message: '' })
  touched.clear()
  status.value = 'idle'
}

const inputClass = (key: keyof ContactFormData) => [
  'mt-1.5 block w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm shadow-xs placeholder:text-gray-400 focus:ring-2 focus:outline-none disabled:opacity-60 dark:bg-gray-950 dark:placeholder:text-gray-500',
  key === 'topic' && !form.topic ? 'text-gray-400 dark:text-gray-500' : 'text-gray-900 dark:text-white',
  errors.value[key]
    ? 'border-rose-400 focus:border-rose-500 focus:ring-rose-500/25 dark:border-rose-500/70'
    : 'border-gray-300 focus:border-rose-500 focus:ring-rose-500/20 dark:border-gray-700 dark:focus:border-rose-400',
]
const describedBy = (key: keyof ContactFormData, extra?: string) => [errors.value[key] && id(`${key}-error`), extra].filter(Boolean).join(' ') || undefined
</script>

<template>
  <section class="mx-auto grid max-w-6xl gap-10 px-4 py-16 sm:px-6 sm:py-24 lg:grid-cols-5 lg:gap-16 lg:px-8 dark:scheme-dark">
    <div class="lg:col-span-2">
      <slot name="aside">
        <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl dark:text-white">{{ heading }}</h2>
        <p class="mt-4 text-base leading-relaxed text-gray-600 dark:text-gray-400">{{ subheading }}</p>
        <dl class="mt-10 space-y-6">
          <div v-for="d in details" :key="d.label" class="flex gap-4">
            <dt class="grid size-10 shrink-0 place-items-center rounded-xl bg-rose-50 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400">
              <span class="sr-only">{{ d.label }}</span>
              <svg viewBox="0 0 24 24" class="size-5 fill-none stroke-current" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path :d="ICONS[d.icon]" /></svg>
            </dt>
            <dd class="text-sm">
              <span class="block text-gray-500 dark:text-gray-400" aria-hidden="true">{{ d.label }}</span>
              <a v-if="d.href" :href="d.href" class="font-medium text-gray-900 hover:text-rose-600 dark:text-white dark:hover:text-rose-400">{{ d.value }}</a>
              <span v-else class="font-medium text-gray-900 dark:text-white">{{ d.value }}</span>
            </dd>
          </div>
        </dl>
      </slot>
    </div>

    <div class="lg:col-span-3">
      <div class="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8 dark:border-gray-800 dark:bg-gray-900">
        <div v-if="status === 'success'" ref="statusEl" tabindex="-1" role="status" class="py-10 text-center focus:outline-none">
          <span class="mx-auto grid size-14 place-items-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
            <svg viewBox="0 0 24 24" class="size-7 fill-none stroke-current" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5" /></svg>
          </span>
          <h3 class="mt-5 text-lg font-semibold text-gray-900 dark:text-white">Message sent</h3>
          <p class="mx-auto mt-2 max-w-sm text-sm text-gray-600 dark:text-gray-400">{{ successMessage }}</p>
          <button type="button" class="mt-6 rounded-lg px-4 py-2 text-sm font-semibold text-rose-700 hover:bg-rose-50 focus-visible:outline-2 focus-visible:outline-rose-500 dark:text-rose-400 dark:hover:bg-rose-500/10" @click="reset">Send another message</button>
        </div>

        <form v-else ref="formEl" novalidate class="grid gap-5 sm:grid-cols-2" :aria-busy="status === 'loading'" @submit.prevent="onSubmit">
          <div v-if="status === 'error'" ref="statusEl" tabindex="-1" role="alert" class="flex gap-3 rounded-xl border border-rose-200 bg-rose-50 p-3.5 text-sm text-rose-800 focus:outline-none sm:col-span-2 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200">
            <svg viewBox="0 0 20 20" class="mt-0.5 size-4 shrink-0 fill-current" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg>
            <p>{{ errorMessage }}</p>
          </div>

          <div>
            <label :for="id('name')" class="block text-sm font-medium text-gray-900 dark:text-white">Name</label>
            <input :id="id('name')" v-model="form.name" type="text" autocomplete="name" placeholder="Jordan Ellis" :disabled="status === 'loading'" :class="inputClass('name')" :aria-invalid="!!errors.name || undefined" :aria-describedby="describedBy('name')" required @blur="touched.add('name')">
            <p v-if="errors.name" :id="id('name-error')" class="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">{{ errors.name }}</p>
          </div>

          <div>
            <label :for="id('email')" class="block text-sm font-medium text-gray-900 dark:text-white">Email</label>
            <input :id="id('email')" v-model="form.email" type="email" autocomplete="email" placeholder="jordan@example.com" :disabled="status === 'loading'" :class="inputClass('email')" :aria-invalid="!!errors.email || undefined" :aria-describedby="describedBy('email')" required @blur="touched.add('email')">
            <p v-if="errors.email" :id="id('email-error')" class="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">{{ errors.email }}</p>
          </div>

          <div class="sm:col-span-2">
            <label :for="id('topic')" class="block text-sm font-medium text-gray-900 dark:text-white">What’s this about?</label>
            <select :id="id('topic')" v-model="form.topic" :disabled="status === 'loading'" :class="inputClass('topic')" :aria-invalid="!!errors.topic || undefined" :aria-describedby="describedBy('topic')" required @blur="touched.add('topic')" @change="touched.add('topic')">
              <option value="" disabled>Choose a topic</option>
              <option v-for="t in topics" :key="t" :value="t">{{ t }}</option>
            </select>
            <p v-if="errors.topic" :id="id('topic-error')" class="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">{{ errors.topic }}</p>
          </div>

          <div class="sm:col-span-2">
            <div class="flex items-baseline justify-between">
              <label :for="id('message')" class="block text-sm font-medium text-gray-900 dark:text-white">Message</label>
              <span :id="id('counter')" class="text-xs tabular-nums" :class="counterClass" aria-live="polite">
                <span aria-hidden="true">{{ form.message.length }} / {{ maxLength }}</span>
                <span v-if="remaining < maxLength * 0.1" class="sr-only">{{ remaining >= 0 ? `${remaining} characters left` : `${-remaining} characters over the limit` }}</span>
              </span>
            </div>
            <textarea :id="id('message')" v-model="form.message" rows="6" placeholder="How can we help? Links and steps to reproduce are welcome." :disabled="status === 'loading'" :class="inputClass('message')" class="resize-y" :aria-invalid="!!errors.message || undefined" :aria-describedby="describedBy('message', id('counter'))" required @blur="touched.add('message')" />
            <p v-if="errors.message" :id="id('message-error')" class="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">{{ errors.message }}</p>
          </div>

          <div v-if="$slots.extra" class="sm:col-span-2"><slot name="extra" /></div>

          <div class="flex flex-col-reverse gap-3 sm:col-span-2 sm:flex-row sm:items-center sm:justify-between">
            <p class="text-xs text-gray-500 dark:text-gray-400">We only use your details to reply to this message.</p>
            <button
              type="submit"
              :disabled="status === 'loading'"
              class="inline-flex items-center justify-center gap-2 rounded-lg bg-rose-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-rose-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-600 disabled:cursor-wait disabled:opacity-70"
            >
              <svg v-if="status === 'loading'" viewBox="0 0 24 24" class="size-4 animate-spin" aria-hidden="true"><circle cx="12" cy="12" r="9" class="fill-none stroke-white/30" stroke-width="3" /><path d="M21 12a9 9 0 0 0-9-9" class="fill-none stroke-white" stroke-width="3" stroke-linecap="round" /></svg>
              {{ status === 'loading' ? 'Sending…' : status === 'error' ? 'Try again' : 'Send message' }}
            </button>
          </div>
        </form>
      </div>
    </div>
  </section>
</template>

Props

PropTypeDefaultDescription
headingstring'Get in touch'Heading in the details column.
subheadingstring'Questions about pricing, a partnership idea…'Supporting text under the heading.
topicsstring[]General question, Sales and pricing, Technical support, Partnerships, PressOptions for the topic select.
maxLengthnumber1000Maximum message length, shown in the counter.
minLengthnumber20Minimum message length.
detailsContactDetail[]Email, Phone, Studio, Response timeContact details list. Each has icon ('mail' | 'phone' | 'pin' | 'clock'), label, value and optional href.
submitHandler(data: ContactFormData) => Promise<unknown>undefinedAsync send function. Pending shows loading, resolve shows success, reject shows the error state. Without it the form goes straight to success after emitting submit.
successMessagestring'Thanks! Your message is on its way…'Text in the success panel.
errorMessagestring'We couldn’t send your message…'Text in the error alert.

Events

EventPayloadDescription
submit(data: ContactFormData)A valid form was submitted. Data has name, email, topic and message, trimmed.
success(data: ContactFormData)submitHandler resolved (or there was none).
error(error: unknown)submitHandler rejected.

Slots

SlotDescription
asideReplace the heading and contact details column.
extraExtra content above the submit row, e.g. a consent checkbox.
All forms →
Preview of Multi-Step FormFree

Component · Forms

Multi-Step Form

Config-driven Vue 3 multi-step form wizard with a step indicator, per-step validation, back/next, a review step and a submit event. Tailwind CSS.

by Vueplay
Preview of File DropzoneFree

Component · Forms

File Dropzone

Drag-and-drop file upload zone for Vue 3 with click to browse, type and size validation, image previews and progress bars. Tailwind CSS, dark mode.

by Vueplay
Preview of Newsletter FooterFree

Component · Footers

Newsletter Footer

Vue 3 footer with a newsletter signup form: email validation, loading and success states, and a subscribe event. Tailwind CSS, dark mode.

by Vueplay
Preview of Hero SplitFree

Component · Hero sections

Hero Split

Split Vue 3 hero section with copy and two CTAs on the left and a Tailwind-drawn app window mockup on the right. Dark mode, typed props, no images.

by Vueplay1 installs

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings