Multi-step survey form
The long-form-that-does-not-feel-long pattern: one question at a time, a progress bar, and Enter moves you on. Vue is loaded from a CDN as a single script tag, no build step, no bundler, nothing to install. Every answer is a named input inside one form, so it posts as a single submission at the end.
Vue · HTML · CSS
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Quick survey</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
padding: 32px 16px;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #f4f4f5;
color: #18181b;
}
.card {
max-width: 460px;
margin: 0 auto;
padding: 28px;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 12px;
}
h1 { margin: 0 0 6px; font-size: 20px; }
.sub { margin: 0 0 22px; font-size: 14px; color: #71717a; }
.field { margin-bottom: 16px; }
label { display: block; margin-bottom: 6px; font-size: 13px; font-weight: 500; }
input, textarea, select {
width: 100%;
padding: 10px 12px;
font: inherit;
font-size: 14px;
color: #18181b;
background: #fff;
border: 1px solid #d4d4d8;
border-radius: 8px;
}
input:focus, textarea:focus, select:focus {
outline: 2px solid #18181b;
outline-offset: 1px;
border-color: transparent;
}
textarea { min-height: 110px; resize: vertical; }
button {
width: 100%;
padding: 11px 16px;
font: inherit;
font-size: 14px;
font-weight: 500;
color: #fff;
background: #18181b;
border: 0;
border-radius: 8px;
cursor: pointer;
}
button:hover { background: #3f3f46; }
button:active { transform: scale(0.99); }
button[disabled] { opacity: 0.6; cursor: default; }
/* Hidden from people, filled in by bots. Never remove it. */
.hp { display: none; }
.bar { height: 3px; margin: -28px -28px 24px; border-radius: 12px 12px 0 0; background: #e4e4e7; overflow: hidden; }
.bar span { display: block; height: 100%; background: #18181b; transition: width 240ms cubic-bezier(0.23, 1, 0.32, 1); }
.count { margin: 12px 0 0; font-size: 12px; color: #a1a1aa; text-align: center; font-variant-numeric: tabular-nums; }
[v-cloak] { display: none; }
</style>
</head>
<body>
<div id="app" class="card">
<div class="bar"><span :style="{ width: ((index + 1) / questions.length * 100) + '%' }"></span></div>
<form action="https://app.portform.co/api/submit" method="POST" @submit="onSubmit">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="checkbox" name="botcheck" class="hp" tabindex="-1" autocomplete="off" />
<!-- Every field stays in the DOM; only one is visible at a time, so the
whole survey posts as one submission. -->
<div v-for="(q, i) in questions" :key="q.name" v-show="i === index" class="field">
<label :for="q.name">{{ q.label }}</label>
<textarea v-if="q.long" :id="q.name" :name="q.name" v-model="answers[q.name]"></textarea>
<input
v-else
:id="q.name"
:type="q.type || 'text'"
:name="q.name"
:autocomplete="q.autocomplete || 'off'"
v-model="answers[q.name]"
@keydown.enter.prevent="next"
/>
</div>
<button v-if="index < questions.length - 1" type="button" @click="next">Next</button>
<button v-else type="submit">Finish</button>
<p class="count">{{ index + 1 }} of {{ questions.length }}</p>
</form>
</div>
<script>
const { createApp } = Vue
createApp({
data: () => ({
index: 0,
answers: {},
questions: [
{ name: 'name', label: "What's your name?", autocomplete: 'name' },
{ name: 'email', label: 'Where should we reply?', type: 'email', autocomplete: 'email' },
{ name: 'role', label: 'What do you do?', autocomplete: 'organization-title' },
{ name: 'message', label: 'What are you hoping this solves?', long: true },
],
}),
methods: {
next() {
if (this.index < this.questions.length - 1) this.index++
},
onSubmit() {
// Let the browser post the form; nothing to intercept.
},
},
}).mount('#app')
</script>
</body>
</html>Replace YOUR_ACCESS_KEY with the access key from your dashboard. Create one free. The endpoint and the honeypot are already in place.
Useful for
The part behind every one of these is a form backend. This template posts to a PortForm form endpoint, which stores each submission and sends it to your email; the docs cover the fields and responses.
Other templates
- Basic HTML contact formHTML · CSS
- Ajax contact formHTML · CSS · JavaScript
- JavaScript form validationHTML · CSS · JavaScript
- Multi-column contact formHTML · CSS
- Masked phone inputHTML · CSS · JavaScript
- Contact form with dark modeHTML · CSS
- Raw contact form (no CSS)HTML
- Multi-step NPS surveyHTML · CSS · JavaScript
- Contact form popupTailwindCSS · Alpine.js · HTML
- Popover feedback widgetTailwindCSS · Alpine.js · HTML
- File upload formHTML · CSS
- JavaScript file upload formHTML · CSS · JavaScript
- React contact formReact · HTML · CSS