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.
- 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>Sample Run
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.