Multi-step NPS survey
Net Promoter Score in two steps: pick a number, and the follow-up you get depends on what you picked: detractors are asked what went wrong, promoters what went right. Both steps live in one form, so the whole response arrives as a single submission rather than two.
HTML · CSS · JavaScript
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>How did we do?</title>
<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; }
/* 26px basis, not 34: eleven buttons plus gaps have to fit one row inside
the card, and a wrapped 10 reads as a different question. */
.scale { display: flex; gap: 5px; margin-bottom: 8px; }
.score {
width: auto;
flex: 1 1 26px;
padding-left: 0;
padding-right: 0;
padding: 10px 0;
color: #18181b;
background: #fff;
border: 1px solid #d4d4d8;
font-variant-numeric: tabular-nums;
}
.score:hover { background: #18181b; color: #fff; border-color: #18181b; }
.ends { display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: #a1a1aa; }
.link {
width: auto;
margin: 12px auto 0;
display: block;
padding: 4px;
color: #71717a;
background: none;
font-size: 13px;
text-decoration: underline;
}
.link:hover { background: none; color: #18181b; }
</style>
</head>
<body>
<form id="form" class="card" action="https://app.portform.co/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="checkbox" name="botcheck" class="hp" tabindex="-1" autocomplete="off" />
<div class="step" data-step="1">
<h1>How likely are you to recommend us?</h1>
<p class="sub">0 is not at all, 10 is definitely.</p>
<div class="scale" role="group" aria-label="Score from 0 to 10"></div>
<p class="ends"><span>Not likely</span><span>Very likely</span></p>
</div>
<div class="step" data-step="2" hidden>
<h1 id="followup-title">Thanks, one more thing.</h1>
<p class="sub" id="followup">What is the main reason for your score?</p>
<div class="field">
<textarea id="message" name="message" required></textarea>
</div>
<button type="submit">Send feedback</button>
<button type="button" class="link" id="back">Back</button>
</div>
</form>
<script>
const form = document.getElementById('form')
const scale = form.querySelector('.scale')
// Built in JS so the 11 buttons are one line of markup, not eleven.
for (let n = 0; n <= 10; n++) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'score'
button.textContent = n
button.addEventListener('click', () => choose(n))
scale.append(button)
}
function step(n) {
form.querySelectorAll('.step').forEach((el) => {
el.hidden = Number(el.dataset.step) !== n
})
}
function choose(n) {
let field = form.querySelector('input[name="score"]')
if (!field) {
field = Object.assign(document.createElement('input'), { type: 'hidden', name: 'score' })
form.append(field)
}
field.value = n
document.getElementById('followup').textContent =
n <= 6
? 'What went wrong? We read every one of these.'
: n <= 8
? 'What would have made it a 10?'
: 'What worked best for you?'
step(2)
document.getElementById('message').focus()
}
document.getElementById('back').addEventListener('click', () => step(1))
</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
- Feedback formsLet customers tell you what to fix, straight to your inbox.
- Survey formsMulti-question responses collected as one submission.
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 survey formVue · HTML · CSS
- 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