LearnAI ToolsCareerPractice BuildsPlayContact
BootstrapBeginner~1 hour

Multi-Field Signup Form

Create a validated registration form with Bootstrap form components.

FormsValidationUtilities

Overview

A signup form is where Bootstrap's form styling and its validation feedback have to work together, not separately. Bootstrap ships styled replacements for every native field — `form-control`, `form-select`, `form-check` — but the red-border, red-text "this field is wrong" feedback only appears once a form carries the `was-validated` class, which nothing adds automatically. That gap is exactly what this project fills in.

You will build a registration form with first/last name, email, password with confirmation, a country dropdown, and a required terms checkbox, then wire up the short vanilla-JavaScript snippet Bootstrap's own documentation recommends so submitting the form with invalid fields shows styled, per-field error messages instead of the browser's default validation bubbles.

What You'll Build
  • A centered `card`-wrapped registration form using `form-label` + `form-control` pairs for text/email/password fields.
  • A `form-select` dropdown for country and a required `form-check` checkbox for agreeing to terms.
  • Per-field `invalid-feedback` messages that appear only once a field actually fails validation.
  • A small JavaScript snippet using the browser's constraint validation API to trigger Bootstrap's `was-validated` styling on submit.
  • A full-width submit button using the `d-grid` utility.

Prerequisites

  • Native HTML form basics — `<form>`, `<input>`, `<label>`, and attributes like `required`, `type`, and `minlength`/`pattern`.
  • Bootstrap's grid (`row`/`col-*`) for laying fields out side by side.
  • Spacing and layout utility classes (`g-3`, `d-grid`, `mt-1`) used to space and arrange the fields.
  • Basic vanilla JavaScript — `querySelectorAll`, `addEventListener`, and reading a boolean from a method call — for Step 6.

Project Structure

The whole form lives in one file, `signup.html`. A `container`/`row`/`col` combination centers a `card` on the page, and the `<form>` inside that card carries two attributes from the very first line: `novalidate`, which turns off the browser's own default validation-bubble UI so Bootstrap's styled feedback can show instead, and `needs-validation`, a plain marker class with no built-in Bootstrap styling of its own — it exists purely as a hook the JavaScript in Step 6 looks for with `querySelectorAll`.

Every field that can fail validation gets an `.invalid-feedback` element immediately after it. That element is invisible by default; Bootstrap's CSS only reveals it when its `:invalid` sibling input sits inside a form carrying `.was-validated` — which is exactly the class the Step 6 script adds the moment the form is submitted.

Step 1: Set Up the Page and the Form Shell

Start with a standard HTML5 document, load Bootstrap, and build the centered card that will hold the form. The `row justify-content-center` + `col-md-8 col-lg-6` combination keeps the form a readable width on large screens instead of letting it stretch edge to edge.

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Your Account</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-5">
<div class="row justify-content-center"> <!-- centers the card instead of letting it stretch full width -->
<div class="col-md-8 col-lg-6">
<div class="card shadow-sm">
<div class="card-body p-4">
<h1 class="h3 mb-4 text-center">Create Your Account</h1>
<!-- novalidate: turns off the browser's default validation popups so
Bootstrap's own styled feedback (added in later steps) shows instead -->
<!-- needs-validation: not a Bootstrap style, just a marker class the
script in Step 6 uses to find this form with querySelectorAll -->
<form class="needs-validation" novalidate>
<!-- fields added in the steps that follow -->
</form>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

Step 2: Add Name Fields

First and last name sit side by side using `row g-3` + `col-md-6`, collapsing to one column per row automatically on narrow screens since no `col-*` breakpoint smaller than `md` is set. Each `invalid-feedback` div is written once here and, from this point on, follows the same pattern for every field: it sits immediately after the input it describes.

<!-- Goes inside the <form> from Step 1 -->
<div class="row g-3"> <!-- g-3: consistent gutter between the two columns below -->
<div class="col-md-6">
<label for="firstName" class="form-label">First name</label>
<input type="text" class="form-control" id="firstName" name="firstName" required>
<!-- .invalid-feedback is invisible until this input is :invalid AND the form
has .was-validated - both conditions come from the Step 6 script -->
<div class="invalid-feedback">First name is required.</div>
</div>
<div class="col-md-6">
<label for="lastName" class="form-label">Last name</label>
<input type="text" class="form-control" id="lastName" name="lastName" required>
<div class="invalid-feedback">Last name is required.</div>
</div>
</div>

Step 3: Add Email and Password Fields

The email field is wrapped in `input-group` so an `@` symbol sits flush against its left edge — a purely visual affordance, since `type="email"` alone already handles the actual format validation. The password field's `pattern` requires at least one letter and one digit across 8+ characters; `pattern` and `minlength` both run through the same browser-native `checkValidity()` call the Step 6 script relies on, so no extra validation logic is needed for either.

<div class="row g-3 mt-1">
<div class="col-12">
<label for="email" class="form-label">Email</label>
<div class="input-group"> <!-- input-group lets the @ icon sit flush against the input -->
<span class="input-group-text">@</span>
<input type="email" class="form-control" id="email" name="email" required>
<div class="invalid-feedback">Please enter a valid email address.</div>
</div>
</div>
<div class="col-md-6">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required
minlength="8" pattern="(?=.*\d)(?=.*[a-zA-Z]).{8,}">
<!-- pattern requires at least one letter and one digit across 8+ characters -->
<div class="invalid-feedback">Password must be 8+ characters and include a letter and a number.</div>
</div>
<div class="col-md-6">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
<!-- Checking the two password fields actually match needs a few lines of custom
JS (setCustomValidity) beyond what "required" alone checks - see Extend This Project -->
<div class="invalid-feedback">Please confirm your password.</div>
</div>
</div>

Step 4: Add a Country Select and Terms Checkbox

`form-select` restyles a native `<select>` to match `form-control`'s look, but it is still a genuine `<select>` underneath, so keyboard and screen-reader behavior is unchanged. The terms checkbox uses `form-check` — the same label-pairing pattern as `form-label`, just for checkboxes and radios — and `required` on a checkbox means the form is invalid until it is checked, not just present.

<div class="row g-3 mt-1">
<div class="col-md-6">
<label for="country" class="form-label">Country</label>
<select class="form-select" id="country" name="country" required>
<option value="" selected disabled>Choose...</option>
<!-- selected disabled: shown by default but not a real, selectable answer,
so leaving it chosen still fails the "required" check -->
<option value="IN">India</option>
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="AU">Australia</option>
</select>
<div class="invalid-feedback">Please select your country.</div>
</div>
<div class="col-md-6 d-flex align-items-end">
<!-- form-check pairs a checkbox with its label, mirroring how form-label
pairs with the inputs above; required here since agreeing is mandatory -->
<div class="form-check">
<input class="form-check-input" type="checkbox" id="terms" name="terms" required>
<label class="form-check-label" for="terms">
I agree to the <a href="#">Terms of Service</a>
</label>
<div class="invalid-feedback">You must agree before signing up.</div>
</div>
</div>
</div>

Step 5: Add the Submit Button

The submit button closes out the form. `d-grid` makes it stretch to the container's full width — a common pattern for a form's single primary action, since a small button floating at the left edge of a wide card looks unfinished.

<!-- d-grid: makes the button span the full width of its container -->
<div class="d-grid mt-4">
<button class="btn btn-primary btn-lg" type="submit">Create Account</button>
</div>

Step 6: Wire Up Bootstrap's Validation JavaScript

This is the piece that actually turns on every `.invalid-feedback` message added in the earlier steps. `form.checkValidity()` runs the browser's built-in constraint validation — checking every `required`, `pattern`, `minlength`, and `type="email"` at once — without submitting anything. If it returns `false`, the real submit is cancelled with `preventDefault()`; either way, `.was-validated` is added, which is the class Bootstrap's CSS actually watches for before showing red borders and feedback text.

<!-- Add just before </body>, after the Bootstrap bundle <script> tag -->
<script>
(() => {
'use strict'
// Finds every form carrying the "needs-validation" marker class from Step 1
const forms = document.querySelectorAll('.needs-validation')
Array.from(forms).forEach(form => {
form.addEventListener('submit', event => {
// checkValidity() runs the browser's native constraint validation
// (required, pattern, minlength, type="email", etc.) without submitting
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
// was-validated is what actually switches on Bootstrap's red/green field
// styling and reveals the matching .invalid-feedback text for each field
form.classList.add('was-validated')
}, false)
})
})()
</script>

Complete Code

Here is the full signup form with every field and the validation script assembled in order.

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Your Account</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-sm">
<div class="card-body p-4">
<h1 class="h3 mb-4 text-center">Create Your Account</h1>
<form class="needs-validation" novalidate>
<div class="row g-3">
<div class="col-md-6">
<label for="firstName" class="form-label">First name</label>
<input type="text" class="form-control" id="firstName" name="firstName" required>
<div class="invalid-feedback">First name is required.</div>
</div>
<div class="col-md-6">
<label for="lastName" class="form-label">Last name</label>
<input type="text" class="form-control" id="lastName" name="lastName" required>
<div class="invalid-feedback">Last name is required.</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-12">
<label for="email" class="form-label">Email</label>
<div class="input-group">
<span class="input-group-text">@</span>
<input type="email" class="form-control" id="email" name="email" required>
<div class="invalid-feedback">Please enter a valid email address.</div>
</div>
</div>
<div class="col-md-6">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required
minlength="8" pattern="(?=.*\d)(?=.*[a-zA-Z]).{8,}">
<div class="invalid-feedback">Password must be 8+ characters and include a letter and a number.</div>
</div>
<div class="col-md-6">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
<div class="invalid-feedback">Please confirm your password.</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-6">
<label for="country" class="form-label">Country</label>
<select class="form-select" id="country" name="country" required>
<option value="" selected disabled>Choose...</option>
<option value="IN">India</option>
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="AU">Australia</option>
</select>
<div class="invalid-feedback">Please select your country.</div>
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="terms" name="terms" required>
<label class="form-check-label" for="terms">
I agree to the <a href="#">Terms of Service</a>
</label>
<div class="invalid-feedback">You must agree before signing up.</div>
</div>
</div>
</div>
<div class="d-grid mt-4">
<button class="btn btn-primary btn-lg" type="submit">Create Account</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
(() => {
'use strict'
const forms = document.querySelectorAll('.needs-validation')
Array.from(forms).forEach(form => {
form.addEventListener('submit', event => {
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
form.classList.add('was-validated')
}, false)
})
})()
</script>
</body>
</html>
Live Preview

Sample Run

What Happens

Click Run to see what this code prints.

Extend This Project

  • Add real password-match checking with `confirmPassword.setCustomValidity(...)`, called on each `input` event, so the confirm field only passes once it actually equals the password field.
  • Swap `form-label` + `form-control` pairs for Bootstrap's `form-floating` variant so labels animate into a compact floating position once a field has a value.
  • Add a password-strength meter that updates live as the user types, using a `progress` bar styled by how many of the `pattern` requirements are currently met.
  • Replace the client-only validation with a real submit handler that posts the form data with `fetch` and shows a server-driven success or error state.
  • Add a profile-picture `type="file"` field, restricted with `accept="image/*"`, above the submit button.

Summary

You built a fully validated signup form using Bootstrap's form components paired with its documented validation script: `form-control`/`form-select`/`form-check` for styled fields, `invalid-feedback` for per-field error text, and a short JavaScript snippet that turns the browser's built-in constraint validation into Bootstrap's styled `was-validated` feedback. That same three-part pattern — styled fields, feedback elements, and the validation script — is reusable on any form Bootstrap renders.