Bootstrap Contact Form: 16 Examples With Validation

Bootstrap gives you the styling for a contact form and nothing more, so the form sends nothing until a server script or hosted endpoint receives the POST request. The Bootstrap 5.3 docs also admit that its custom validation styles are not exposed to assistive technologies.

Below are 16 Bootstrap contact form examples you can copy, followed by a guide to markup, validation, spam protection and email delivery.

By Bogdan Sandu ยท Updated October 29, 2026

Every example runs on Bootstrap 5.3.8 from the jsDelivr CDN. Add these tags to your page, then paste an example's HTML, CSS and JS.

<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.2/font/bootstrap-icons.min.css" rel="stylesheet" integrity="sha384-nWN3KqaaVcbEsCjLSV+2jMjh62Iny/+mN3srM8TZeM8N6aj7eDl5wwYggjAJ4/sJ" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>

Contact page layouts

Full contact sections: a details column next to the form, a photo split, an inline SVG map and a slim footer strip. All of them use the grid, cards and utilities, with a few lines of scoped CSS for color.

Classic contact section with a details column

  • card
  • row g-0
  • list-unstyled
  • was-validated
  • --bs-btn-*

Press the send button with empty fields to see the validation states.

A horizontal card splits into a dark details column (address, phone, email, hours with Bootstrap Icons) and a two-column form built with row g-3 and col-sm-6. The form uses novalidate, checkValidity() and .was-validated, and the status line is a role="status" region so the thank-you text is announced. The custom CSS only colors the details column and re-themes the button through its --bs-btn-* variables.

View code
<section class="card bct-classic border-0 shadow-sm overflow-hidden" aria-labelledby="bct-classic-title">
  <div class="row g-0">
    <div class="col-md-5 bct-classic-info p-4">
      <h2 id="bct-classic-title" class="h4 mb-2">Talk to Fernhill Studio</h2>
      <p class="bct-classic-lead mb-4">Tell us about your project and a designer replies within one business day.</p>
      <ul class="list-unstyled d-grid gap-3 mb-0">
        <li class="d-flex gap-3">
          <i class="bi bi-geo-alt fs-5" aria-hidden="true"></i>
          <div><span class="d-block fw-semibold">Studio</span>48 Alder Lane, Suite 3<br>Portland, OR 97201</div>
        </li>
        <li class="d-flex gap-3">
          <i class="bi bi-telephone fs-5" aria-hidden="true"></i>
          <div><span class="d-block fw-semibold">Phone</span><a href="#">(503) 555-0142</a></div>
        </li>
        <li class="d-flex gap-3">
          <i class="bi bi-envelope fs-5" aria-hidden="true"></i>
          <div><span class="d-block fw-semibold">Email</span><a href="#">studio@example.com</a></div>
        </li>
        <li class="d-flex gap-3">
          <i class="bi bi-clock fs-5" aria-hidden="true"></i>
          <div><span class="d-block fw-semibold">Hours</span>Mon to Fri, 9:00 to 17:30 PT</div>
        </li>
      </ul>
    </div>
    <div class="col-md-7 p-4">
      <form class="row g-3 bct-classic-form" novalidate>
        <div class="col-sm-6">
          <label for="bct-classic-name" class="form-label">Name</label>
          <input type="text" class="form-control" id="bct-classic-name" name="name" autocomplete="name" required>
          <div class="invalid-feedback">Enter your name.</div>
        </div>
        <div class="col-sm-6">
          <label for="bct-classic-email" class="form-label">Email</label>
          <input type="email" class="form-control" id="bct-classic-email" name="email" autocomplete="email" required>
          <div class="invalid-feedback">Enter an email we can reply to.</div>
        </div>
        <div class="col-12">
          <label for="bct-classic-need" class="form-label">What do you need?</label>
          <select class="form-select" id="bct-classic-need" name="need" required>
            <option value="">Choose one</option>
            <option>Brand identity</option>
            <option>Website design</option>
            <option>Packaging</option>
            <option>Something else</option>
          </select>
          <div class="invalid-feedback">Pick the closest match.</div>
        </div>
        <div class="col-12">
          <label for="bct-classic-msg" class="form-label">Message</label>
          <textarea class="form-control" id="bct-classic-msg" name="message" rows="4" required></textarea>
          <div class="invalid-feedback">Add a few words about the project.</div>
        </div>
        <div class="col-12">
          <button type="submit" class="btn bct-classic-send">Send message</button>
        </div>
      </form>
      <p class="bct-classic-status text-success small mt-3 mb-0" role="status"></p>
    </div>
  </div>
</section>

Split layout with a photo and consent checkbox

  • card
  • row g-0
  • object-fit
  • form-check
  • was-validated

Press the send button with empty fields to see the validation states.

The photo takes col-md-5 and turns into a short banner below the md breakpoint instead of disappearing. The consent checkbox is a required form-check, so .was-validated colors its label and shows its invalid-feedback like any other field. The CSS crops the image with object-fit and re-themes the button in a deep wine color through --bs-btn-* variables.

View code
<div class="card bct-split border-0 shadow-sm overflow-hidden">
  <div class="row g-0">
    <div class="col-md-5">
      <img src="canal-houses.webp" class="bct-split-photo" alt="Old brick canal houses with small tour boats moored along a wooden jetty" width="800" height="600">
    </div>
    <div class="col-md-7">
      <div class="card-body p-4">
        <h2 class="h4 mb-1">Plan a shoot with Lindenmark</h2>
        <p class="text-body-secondary mb-4">Portraits, small weddings and brand days along the old canals.</p>
        <form class="row g-3 bct-split-form" novalidate>
          <div class="col-sm-6">
            <label for="bct-split-name" class="form-label">Your name</label>
            <input type="text" class="form-control" id="bct-split-name" name="name" autocomplete="name" required>
            <div class="invalid-feedback">Enter your name.</div>
          </div>
          <div class="col-sm-6">
            <label for="bct-split-email" class="form-label">Email</label>
            <input type="email" class="form-control" id="bct-split-email" name="email" autocomplete="email" required>
            <div class="invalid-feedback">Enter a valid email.</div>
          </div>
          <div class="col-sm-6">
            <label for="bct-split-type" class="form-label">Type of shoot</label>
            <select class="form-select" id="bct-split-type" name="type">
              <option>Portrait session</option>
              <option>Small wedding</option>
              <option>Brand day</option>
            </select>
          </div>
          <div class="col-sm-6">
            <label for="bct-split-month" class="form-label">Preferred month</label>
            <select class="form-select" id="bct-split-month" name="month">
              <option>Not sure yet</option>
              <option>March</option>
              <option>April</option>
              <option>May</option>
              <option>June</option>
            </select>
          </div>
          <div class="col-12">
            <label for="bct-split-msg" class="form-label">Tell us a little about it</label>
            <textarea class="form-control" id="bct-split-msg" name="message" rows="3" required></textarea>
            <div class="invalid-feedback">A sentence or two is enough.</div>
          </div>
          <div class="col-12">
            <div class="form-check">
              <input class="form-check-input" type="checkbox" id="bct-split-consent" name="consent" required>
              <label class="form-check-label small" for="bct-split-consent">I agree that Lindenmark may store my details to answer this request, as described in the <a href="#">privacy notice</a>.</label>
              <div class="invalid-feedback">We need your consent to reply.</div>
            </div>
          </div>
          <div class="col-12 d-grid">
            <button type="submit" class="btn btn-lg bct-split-send">Request availability</button>
          </div>
        </form>
        <p class="bct-split-status small text-success mt-3 mb-0" role="status"></p>
      </div>
    </div>
  </div>
</div>

Visit us card with an inline SVG map

  • card
  • row g-0
  • inline SVG
  • input-group
  • list-unstyled

Press the send button with empty fields to see the validation states.

The map is a small inline SVG with a title, so it loads no third-party embed, sets no cookies and still describes the location to screen readers. Swap it for a static map image or a click-to-load embed on a live site. The right column pairs the address with a short question form whose submit button sits inside an input-group.

View code
<div class="card bct-map border-0 shadow-sm overflow-hidden">
  <div class="row g-0">
    <div class="col-md-7 bct-map-canvas">
      <svg class="bct-map-svg" viewBox="0 0 480 360" preserveAspectRatio="xMidYMid slice" role="img" aria-labelledby="bct-map-title">
        <title id="bct-map-title">Street map showing the showroom at 12 Harbour Row, on the corner of Quay Street, next to Riverside Park</title>
        <rect width="480" height="360" fill="#eef1ea"/>
        <rect x="300" y="30" width="150" height="115" rx="10" fill="#cfe3c4"/>
        <path d="M0 262 C 110 228 210 300 320 262 S 440 228 480 236 L 480 360 L 0 360 Z" fill="#bcd6ea"/>
        <g fill="#dfe3dc">
          <rect x="20" y="24" width="80" height="62" rx="4"/>
          <rect x="140" y="24" width="96" height="62" rx="4"/>
          <rect x="20" y="112" width="80" height="50" rx="4"/>
          <rect x="140" y="112" width="96" height="50" rx="4"/>
          <rect x="300" y="174" width="150" height="42" rx="4"/>
          <rect x="20" y="198" width="80" height="34" rx="4"/>
        </g>
        <g fill="none" stroke="#ffffff" stroke-linecap="round">
          <path d="M0 184 H 480" stroke-width="16"/>
          <path d="M120 0 V 240" stroke-width="16"/>
          <path d="M268 0 L 280 246" stroke-width="12"/>
          <path d="M0 99 H 268" stroke-width="7"/>
        </g>
        <g font-family="system-ui, sans-serif" font-size="13" fill="#4b5560">
          <text x="300" y="178" dy="-10">Quay Street</text>
          <text x="128" y="230" transform="rotate(-90 128 230)" dy="4">Mill Road</text>
        </g>
        <text x="375" y="92" font-family="system-ui, sans-serif" font-size="13" fill="#35532c" text-anchor="middle">Riverside Park</text>
        <text x="200" y="320" font-family="system-ui, sans-serif" font-size="13" fill="#2f4d66" text-anchor="middle">River Lune</text>
        <g transform="translate(200 172)">
          <ellipse cx="0" cy="12" rx="12" ry="4" fill="#000000" opacity=".15"/>
          <path d="M0 10 C 0 10 -20 -8 -20 -24 C -20 -36 -11 -44 0 -44 C 11 -44 20 -36 20 -24 C 20 -8 0 10 0 10 Z" fill="#d6452b"/>
          <circle cx="0" cy="-24" r="7" fill="#ffffff"/>
        </g>
      </svg>
    </div>
    <div class="col-md-5">
      <div class="card-body p-4">
        <h2 class="h5 mb-3">Visit the Harbour Row showroom</h2>
        <ul class="list-unstyled small d-grid gap-2 mb-4">
          <li class="d-flex gap-2"><i class="bi bi-geo-alt-fill bct-map-icon" aria-hidden="true"></i><span>12 Harbour Row, Lancaster, PA 17602</span></li>
          <li class="d-flex gap-2"><i class="bi bi-clock-fill bct-map-icon" aria-hidden="true"></i><span>Tue to Sat, 10:00 to 18:00</span></li>
          <li class="d-flex gap-2"><i class="bi bi-telephone-fill bct-map-icon" aria-hidden="true"></i><a href="#">(717) 555-0187</a></li>
        </ul>
        <form class="bct-map-form" novalidate>
          <label for="bct-map-email" class="form-label small fw-semibold">Ask before you visit</label>
          <input type="email" class="form-control mb-2" id="bct-map-email" name="email" placeholder="you@example.com" autocomplete="email" required>
          <div class="input-group has-validation">
            <label for="bct-map-q" class="visually-hidden">Your question</label>
            <input type="text" class="form-control" id="bct-map-q" name="question" placeholder="Is parking free on Saturdays?" required>
            <button type="submit" class="btn btn-dark">Ask</button>
            <div class="invalid-feedback">Add your email and a question.</div>
          </div>
        </form>
        <p class="bct-map-status small text-success mt-2 mb-0" role="status"></p>
      </div>
    </div>
  </div>
</div>

Floating labels, dark mode and themed forms

The same contact fields restyled four ways: form-floating, the dark color mode, an editorial theme built from CSS variables, and a message box with a live character counter.

Floating label contact form

  • form-floating
  • form-select
  • invalid-feedback
  • was-validated
  • --bs-btn-*

Every field sits in a form-floating wrapper with the input before its label and a non-empty placeholder, which the CSS-only floating label needs. The textarea gets its height from CSS, not rows, and the select floats its label too. Custom CSS swaps the blue focus ring and button for a violet theme, scoped to the form class.

View code
<div class="card bct-float border-0 shadow-sm">
  <div class="card-body p-4">
    <h2 class="h4 mb-1">Questions about Plotline?</h2>
    <p class="text-body-secondary mb-4">Pricing, trials or a demo for your team. We answer within a few hours.</p>
    <form class="row g-3 bct-float-form" novalidate>
      <div class="col-sm-6">
        <div class="form-floating">
          <input type="text" class="form-control" id="bct-float-name" name="name" placeholder="Your name" autocomplete="name" required>
          <label for="bct-float-name">Your name</label>
          <div class="invalid-feedback">Enter your name.</div>
        </div>
      </div>
      <div class="col-sm-6">
        <div class="form-floating">
          <input type="email" class="form-control" id="bct-float-email" name="email" placeholder="name@example.com" autocomplete="email" required>
          <label for="bct-float-email">Work email</label>
          <div class="invalid-feedback">Enter a valid email.</div>
        </div>
      </div>
      <div class="col-12">
        <div class="form-floating">
          <select class="form-select" id="bct-float-topic" name="topic">
            <option>Pricing and plans</option>
            <option>Book a demo</option>
            <option>Extend my trial</option>
            <option>Something else</option>
          </select>
          <label for="bct-float-topic">Topic</label>
        </div>
      </div>
      <div class="col-12">
        <div class="form-floating">
          <textarea class="form-control bct-float-msg" id="bct-float-msg" name="message" placeholder="Your message" required></textarea>
          <label for="bct-float-msg">Your message</label>
          <div class="invalid-feedback">Write a short message.</div>
        </div>
      </div>
      <div class="col-12 d-flex flex-wrap align-items-center gap-3">
        <button type="submit" class="btn btn-lg bct-float-send">Send question</button>
        <span class="small text-body-secondary">Average reply time: 3 hours</span>
      </div>
    </form>
    <p class="bct-float-status small mt-3 mb-0" role="status"></p>
  </div>
</div>

Dark mode support form with icon inputs

  • data-bs-theme="dark"
  • input-group-text
  • has-validation
  • btn-check
  • bg-body-tertiary

The frame runs in data-bs-theme="dark", so the card, inputs and feedback colors come from the dark color mode with no extra CSS. Icon add-ons use input-group-text inside has-validation groups, which keeps the rounded corners right when feedback appears, and the topic picker is a fieldset of btn-check radios. The CSS adds an amber accent through --bs-btn-* variables.

View code
<div class="card bct-dark bg-body-tertiary border-0">
  <div class="card-body p-4">
    <h2 class="h4 mb-1">Contact Nightjar support</h2>
    <p class="text-body-secondary mb-4">Real people, every day from 7:00 to 23:00 UTC.</p>
    <form class="bct-dark-form" novalidate>
      <div class="row g-3">
        <div class="col-sm-6">
          <label for="bct-dark-name" class="form-label">Name</label>
          <div class="input-group has-validation">
            <span class="input-group-text"><i class="bi bi-person" aria-hidden="true"></i></span>
            <input type="text" class="form-control" id="bct-dark-name" name="name" autocomplete="name" required>
            <div class="invalid-feedback">Enter your name.</div>
          </div>
        </div>
        <div class="col-sm-6">
          <label for="bct-dark-email" class="form-label">Email</label>
          <div class="input-group has-validation">
            <span class="input-group-text"><i class="bi bi-envelope" aria-hidden="true"></i></span>
            <input type="email" class="form-control" id="bct-dark-email" name="email" autocomplete="email" required>
            <div class="invalid-feedback">Enter a valid email.</div>
          </div>
        </div>
        <fieldset class="col-12">
          <legend class="form-label fs-6 mb-2">Topic</legend>
          <div class="d-flex flex-wrap gap-2">
            <input type="radio" class="btn-check" name="topic" id="bct-dark-t1" value="Question" checked>
            <label class="btn bct-dark-pill" for="bct-dark-t1">Question</label>
            <input type="radio" class="btn-check" name="topic" id="bct-dark-t2" value="Bug report">
            <label class="btn bct-dark-pill" for="bct-dark-t2">Bug report</label>
            <input type="radio" class="btn-check" name="topic" id="bct-dark-t3" value="Billing">
            <label class="btn bct-dark-pill" for="bct-dark-t3">Billing</label>
            <input type="radio" class="btn-check" name="topic" id="bct-dark-t4" value="Feature idea">
            <label class="btn bct-dark-pill" for="bct-dark-t4">Feature idea</label>
          </div>
        </fieldset>
        <div class="col-12">
          <label for="bct-dark-msg" class="form-label">How can we help?</label>
          <textarea class="form-control" id="bct-dark-msg" name="message" rows="4" required></textarea>
          <div class="invalid-feedback">Describe the problem in a sentence or two.</div>
        </div>
        <div class="col-12 d-grid d-sm-flex">
          <button type="submit" class="btn btn-lg bct-dark-send">Send to support</button>
        </div>
      </div>
    </form>
    <p class="bct-dark-status small text-success-emphasis mt-3 mb-0" role="status"></p>
  </div>
</div>

Editorial letters form themed with CSS variables

  • --bs-body-font-family
  • --bs-border-radius
  • form-switch
  • role="switch"
  • --bs-btn-*

A magazine letters page that shows how far the form classes stretch. The wrapper resets --bs-body-font-family, --bs-border-radius and the body colors, the inputs become underlined lines with a few scoped rules, and the switch is a plain form-switch with role="switch". No markup changes are needed beyond the wrapper class.

View code
<div class="bct-press">
  <h2 class="bct-press-title">Letters to the editor</h2>
  <p class="bct-press-lead">The Quarterly Ledger prints a selection of letters in every issue. Keep it under 300 words.</p>
  <form class="row g-4 bct-press-form" novalidate>
    <div class="col-sm-6">
      <label for="bct-press-name" class="form-label">Name as it should appear</label>
      <input type="text" class="form-control" id="bct-press-name" name="name" autocomplete="name" required>
      <div class="invalid-feedback">We do not print anonymous letters.</div>
    </div>
    <div class="col-sm-6">
      <label for="bct-press-town" class="form-label">Town</label>
      <input type="text" class="form-control" id="bct-press-town" name="town" autocomplete="address-level2">
    </div>
    <div class="col-12">
      <label for="bct-press-email" class="form-label">Email (never printed)</label>
      <input type="email" class="form-control" id="bct-press-email" name="email" autocomplete="email" required>
      <div class="invalid-feedback">We confirm every letter by email before printing.</div>
    </div>
    <div class="col-12">
      <label for="bct-press-letter" class="form-label">Your letter</label>
      <textarea class="form-control" id="bct-press-letter" name="letter" rows="5" required></textarea>
      <div class="invalid-feedback">The letter is empty.</div>
    </div>
    <div class="col-12">
      <div class="form-check form-switch">
        <input class="form-check-input" type="checkbox" role="switch" id="bct-press-print" name="print" checked>
        <label class="form-check-label" for="bct-press-print">You may edit this letter for length and print it</label>
      </div>
    </div>
    <div class="col-12">
      <button type="submit" class="btn bct-press-send">Send letter</button>
    </div>
  </form>
  <p class="bct-press-status mt-3 mb-0" role="status"></p>
</div>

Message box with a live character counter

  • maxlength
  • progress
  • aria-describedby
  • visually-hidden
  • aria-live

Type in the message box and watch the counter.

maxlength caps the message, and a thin Bootstrap progress bar plus a counter tied to the textarea with aria-describedby show how much room is left. The bar moves from bg-success to bg-warning to bg-danger, and a visually-hidden aria-live region speaks only at the 50-character and limit marks instead of on every keystroke. The subject field gets the same counter in a lighter form.

View code
<div class="card bct-count border-0 shadow-sm">
  <div class="card-body p-4">
    <h2 class="h5 mb-1">Send a quick note</h2>
    <p class="text-body-secondary small mb-4">Short messages get faster answers. The Tidewater team reads every one.</p>
    <form class="bct-count-form" novalidate>
      <div class="mb-3">
        <div class="d-flex justify-content-between align-items-baseline">
          <label for="bct-count-subject" class="form-label">Subject</label>
          <span id="bct-count-subject-out" class="small text-body-secondary">0 / 60</span>
        </div>
        <input type="text" class="form-control" id="bct-count-subject" name="subject" maxlength="60" aria-describedby="bct-count-subject-out" required>
        <div class="invalid-feedback">Add a subject.</div>
      </div>
      <div class="mb-2">
        <div class="d-flex justify-content-between align-items-baseline">
          <label for="bct-count-msg" class="form-label">Message</label>
          <span id="bct-count-out" class="small text-body-secondary bct-count-out">0 / 280</span>
        </div>
        <textarea class="form-control" id="bct-count-msg" name="message" rows="4" maxlength="280" aria-describedby="bct-count-out" required></textarea>
        <div class="invalid-feedback">Write your message.</div>
      </div>
      <div class="progress bct-count-meter mb-4" role="progressbar" aria-label="Message length" aria-valuenow="0" aria-valuemin="0" aria-valuemax="280">
        <div class="progress-bar bg-success bct-count-bar"></div>
      </div>
      <p id="bct-count-live" class="visually-hidden" aria-live="polite"></p>
      <button type="submit" class="btn bct-count-send">Send note</button>
    </form>
    <p class="bct-count-status small text-success mt-3 mb-0" role="status"></p>
  </div>
</div>

Quote, support, routing and callback forms

Contact forms that collect more than a message: a multi-step quote request, a support ticket with priority and attachments, department radio cards that route the message, and a callback booking with native date and time inputs.

Multi-step quote request with progress

  • progress
  • btn-check
  • fieldset hidden
  • checkValidity()
  • createElement

Pick a project type, then press Next.

Three fieldsets share one form: project type and budget are btn-check radio cards, and the last step collects contact details. Next checks only the visible fieldset with checkValidity(), the progress bar updates its aria-valuenow, and focus moves to each step legend. On submit the summary is built with createElement and textContent, never innerHTML.

View code
<div class="card bct-quote border-0 shadow-sm">
  <div class="card-body p-4">
    <div class="d-flex justify-content-between align-items-baseline gap-2 mb-2">
      <h2 class="h5 mb-0">Request a quote</h2>
      <span class="small text-body-secondary bct-quote-count">Step 1 of 3</span>
    </div>
    <div class="progress bct-quote-progress mb-4" role="progressbar" aria-label="Quote request progress" aria-valuenow="33" aria-valuemin="0" aria-valuemax="100">
      <div class="progress-bar bct-quote-bar"></div>
    </div>
    <form class="bct-quote-form" novalidate>
      <fieldset class="bct-quote-step">
        <legend class="h6 mb-3" tabindex="-1">What are we building?</legend>
        <div class="row g-2">
          <div class="col-6">
            <input type="radio" class="btn-check" name="project" id="bct-quote-web" value="website" required>
            <label class="btn bct-quote-opt w-100" for="bct-quote-web"><i class="bi bi-window d-block fs-4 mb-1" aria-hidden="true"></i>Website</label>
          </div>
          <div class="col-6">
            <input type="radio" class="btn-check" name="project" id="bct-quote-store" value="online store">
            <label class="btn bct-quote-opt w-100" for="bct-quote-store"><i class="bi bi-bag d-block fs-4 mb-1" aria-hidden="true"></i>Online store</label>
          </div>
          <div class="col-6">
            <input type="radio" class="btn-check" name="project" id="bct-quote-brand" value="brand identity">
            <label class="btn bct-quote-opt w-100" for="bct-quote-brand"><i class="bi bi-palette d-block fs-4 mb-1" aria-hidden="true"></i>Brand identity</label>
          </div>
          <div class="col-6">
            <input type="radio" class="btn-check" name="project" id="bct-quote-app" value="mobile app">
            <label class="btn bct-quote-opt w-100" for="bct-quote-app"><i class="bi bi-phone d-block fs-4 mb-1" aria-hidden="true"></i>Mobile app</label>
          </div>
        </div>
        <div class="invalid-feedback bct-quote-err">Pick a project type to continue.</div>
      </fieldset>
      <fieldset class="bct-quote-step" hidden>
        <legend class="h6 mb-3" tabindex="-1">What budget and timeline?</legend>
        <div class="row g-2">
          <div class="col-6">
            <input type="radio" class="btn-check" name="budget" id="bct-quote-b1" value="under $5k" required>
            <label class="btn bct-quote-opt w-100" for="bct-quote-b1">Under $5k</label>
          </div>
          <div class="col-6">
            <input type="radio" class="btn-check" name="budget" id="bct-quote-b2" value="$5k to $15k">
            <label class="btn bct-quote-opt w-100" for="bct-quote-b2">$5k to $15k</label>
          </div>
          <div class="col-6">
            <input type="radio" class="btn-check" name="budget" id="bct-quote-b3" value="$15k to $40k">
            <label class="btn bct-quote-opt w-100" for="bct-quote-b3">$15k to $40k</label>
          </div>
          <div class="col-6">
            <input type="radio" class="btn-check" name="budget" id="bct-quote-b4" value="over $40k">
            <label class="btn bct-quote-opt w-100" for="bct-quote-b4">Over $40k</label>
          </div>
        </div>
        <div class="invalid-feedback bct-quote-err">Pick a budget range.</div>
        <label for="bct-quote-when" class="form-label mt-3">Ideal launch</label>
        <select class="form-select" id="bct-quote-when" name="timeline" required>
          <option value="">Choose a timeline</option>
          <option>Within a month</option>
          <option>In 1 to 3 months</option>
          <option>In 3 to 6 months</option>
          <option>Flexible</option>
        </select>
        <div class="invalid-feedback">Choose a timeline.</div>
      </fieldset>
      <fieldset class="bct-quote-step" hidden>
        <legend class="h6 mb-3" tabindex="-1">Where do we send the quote?</legend>
        <div class="row g-3">
          <div class="col-sm-6">
            <label for="bct-quote-name" class="form-label">Name</label>
            <input type="text" class="form-control" id="bct-quote-name" name="name" autocomplete="name" required>
            <div class="invalid-feedback">Enter your name.</div>
          </div>
          <div class="col-sm-6">
            <label for="bct-quote-email" class="form-label">Email</label>
            <input type="email" class="form-control" id="bct-quote-email" name="email" autocomplete="email" required>
            <div class="invalid-feedback">Enter a valid email.</div>
          </div>
          <div class="col-12">
            <label for="bct-quote-details" class="form-label">Project details <span class="text-body-secondary">(optional)</span></label>
            <textarea class="form-control" id="bct-quote-details" name="details" rows="3"></textarea>
          </div>
        </div>
      </fieldset>
      <div class="d-flex justify-content-between gap-2 mt-4">
        <button type="button" class="btn btn-outline-secondary bct-quote-back" disabled>Back</button>
        <button type="button" class="btn bct-quote-btn bct-quote-next">Next</button>
        <button type="submit" class="btn bct-quote-btn bct-quote-send" hidden>Send request</button>
      </div>
    </form>
    <div class="bct-quote-done" role="status"></div>
  </div>
</div>

Support ticket with priority and attachments

  • card-header
  • form-select
  • badge text-bg-*
  • type="file" multiple
  • setCustomValidity()

Change the priority, then press Submit ticket.

The priority select drives a badge and a response-time line in an aria-live region, so the visitor sees what Urgent actually promises. The file input accepts several files, lists them in a list-group built with createElement, and uses setCustomValidity() to reject more than three files or anything over 5 MB. The account ID uses a pattern attribute with a format hint in aria-describedby.

View code
<div class="card bct-ticket shadow-sm">
  <div class="card-header d-flex align-items-center gap-2 py-3 px-4">
    <i class="bi bi-life-preserver fs-5 bct-ticket-accent" aria-hidden="true"></i>
    <h2 class="h5 mb-0">Open a support ticket</h2>
  </div>
  <div class="card-body p-4">
    <form class="row g-3 bct-ticket-form" novalidate>
      <div class="col-sm-6">
        <label for="bct-ticket-email" class="form-label">Account email</label>
        <input type="email" class="form-control" id="bct-ticket-email" name="email" autocomplete="email" required>
        <div class="invalid-feedback">Use the email on your Tallyhub account.</div>
      </div>
      <div class="col-sm-6">
        <label for="bct-ticket-id" class="form-label">Workspace ID</label>
        <input type="text" class="form-control" id="bct-ticket-id" name="workspace" placeholder="TH-12345" pattern="TH-[0-9]{5}" aria-describedby="bct-ticket-id-help" required>
        <div id="bct-ticket-id-help" class="form-text">Settings, then Workspace. Format TH-12345.</div>
        <div class="invalid-feedback">Use the format TH-12345.</div>
      </div>
      <div class="col-sm-6">
        <label for="bct-ticket-priority" class="form-label">Priority</label>
        <select class="form-select" id="bct-ticket-priority" name="priority">
          <option value="low">Low: a question, no rush</option>
          <option value="normal" selected>Normal: something is off</option>
          <option value="high">High: a feature is broken</option>
          <option value="urgent">Urgent: nothing works</option>
        </select>
      </div>
      <div class="col-sm-6 d-flex align-items-end">
        <p class="bct-ticket-sla small mb-1" aria-live="polite">
          <span class="badge rounded-pill text-bg-primary bct-ticket-badge">Normal</span>
          <span class="bct-ticket-time">First reply within 8 business hours</span>
        </p>
      </div>
      <div class="col-12">
        <label for="bct-ticket-subject" class="form-label">Subject</label>
        <input type="text" class="form-control" id="bct-ticket-subject" name="subject" maxlength="90" required>
        <div class="invalid-feedback">Summarize the problem in a few words.</div>
      </div>
      <div class="col-12">
        <label for="bct-ticket-desc" class="form-label">What happened?</label>
        <textarea class="form-control" id="bct-ticket-desc" name="description" rows="4" minlength="20" placeholder="Steps to reproduce, what you expected, what you saw" required></textarea>
        <div class="invalid-feedback">Give us at least 20 characters to work with.</div>
      </div>
      <div class="col-12">
        <label for="bct-ticket-file" class="form-label">Screenshots or logs</label>
        <input class="form-control" type="file" id="bct-ticket-file" name="attachments" accept=".png,.jpg,.jpeg,.pdf,.txt,.log" multiple aria-describedby="bct-ticket-file-help">
        <div id="bct-ticket-file-help" class="form-text">Up to 3 files, 5 MB each.</div>
        <div class="invalid-feedback bct-ticket-file-err">Remove some files.</div>
        <ul class="list-group list-group-flush small mt-2 bct-ticket-files"></ul>
      </div>
      <div class="col-12">
        <button type="submit" class="btn btn-lg bct-ticket-send">Submit ticket</button>
      </div>
    </form>
    <p class="bct-ticket-status small text-success mt-3 mb-0" role="status"></p>
  </div>
</div>

Department chooser with radio cards

  • card
  • form-check-input
  • :has()
  • aria-live
  • row g-2

Pick a department and watch the subject change.

Each department is a real radio input inside a label styled as a Bootstrap card, so the whole card is clickable and the group works with arrow keys. Picking one updates the routing line, the message placeholder and the subject, unless the visitor already typed their own subject. The CSS uses :has() to highlight the checked card and show a focus ring around it.

View code
<div class="card bct-dept border-0 shadow-sm">
  <div class="card-body p-4">
    <h2 class="h4 mb-1">Who do you want to reach?</h2>
    <p class="text-body-secondary mb-4">Pick a team and your message goes straight to the right inbox at Copperleaf.</p>
    <form class="bct-dept-form" novalidate>
      <fieldset class="mb-3">
        <legend class="visually-hidden">Department</legend>
        <div class="row g-2">
          <div class="col-6 col-md-3">
            <label class="card h-100 p-3 bct-dept-card" for="bct-dept-sales">
              <input class="form-check-input bct-dept-radio" type="radio" name="department" id="bct-dept-sales" value="sales" checked>
              <i class="bi bi-graph-up-arrow fs-4 mt-2 mb-1 bct-dept-icon" aria-hidden="true"></i>
              <span class="fw-semibold">Sales</span>
              <span class="small text-body-secondary">Plans and pricing</span>
            </label>
          </div>
          <div class="col-6 col-md-3">
            <label class="card h-100 p-3 bct-dept-card" for="bct-dept-support">
              <input class="form-check-input bct-dept-radio" type="radio" name="department" id="bct-dept-support" value="support">
              <i class="bi bi-tools fs-4 mt-2 mb-1 bct-dept-icon" aria-hidden="true"></i>
              <span class="fw-semibold">Support</span>
              <span class="small text-body-secondary">Account help</span>
            </label>
          </div>
          <div class="col-6 col-md-3">
            <label class="card h-100 p-3 bct-dept-card" for="bct-dept-press">
              <input class="form-check-input bct-dept-radio" type="radio" name="department" id="bct-dept-press" value="press">
              <i class="bi bi-newspaper fs-4 mt-2 mb-1 bct-dept-icon" aria-hidden="true"></i>
              <span class="fw-semibold">Press</span>
              <span class="small text-body-secondary">Media and talks</span>
            </label>
          </div>
          <div class="col-6 col-md-3">
            <label class="card h-100 p-3 bct-dept-card" for="bct-dept-jobs">
              <input class="form-check-input bct-dept-radio" type="radio" name="department" id="bct-dept-jobs" value="jobs">
              <i class="bi bi-briefcase fs-4 mt-2 mb-1 bct-dept-icon" aria-hidden="true"></i>
              <span class="fw-semibold">Careers</span>
              <span class="small text-body-secondary">Open roles</span>
            </label>
          </div>
        </div>
      </fieldset>
      <p class="small bct-dept-route mb-4" aria-live="polite">Goes to <strong class="bct-dept-to">sales@example.com</strong>, usual reply within 4 hours.</p>
      <div class="row g-3">
        <div class="col-sm-6">
          <label for="bct-dept-name" class="form-label">Name</label>
          <input type="text" class="form-control" id="bct-dept-name" name="name" autocomplete="name" required>
          <div class="invalid-feedback">Enter your name.</div>
        </div>
        <div class="col-sm-6">
          <label for="bct-dept-email" class="form-label">Email</label>
          <input type="email" class="form-control" id="bct-dept-email" name="email" autocomplete="email" required>
          <div class="invalid-feedback">Enter a valid email.</div>
        </div>
        <div class="col-12">
          <label for="bct-dept-subject" class="form-label">Subject</label>
          <input type="text" class="form-control" id="bct-dept-subject" name="subject" value="Question about plans and pricing" required>
          <div class="invalid-feedback">Add a subject.</div>
        </div>
        <div class="col-12">
          <label for="bct-dept-msg" class="form-label">Message</label>
          <textarea class="form-control" id="bct-dept-msg" name="message" rows="3" placeholder="How many seats do you need, and which plan are you looking at?" required></textarea>
          <div class="invalid-feedback">Write your message.</div>
        </div>
        <div class="col-12">
          <button type="submit" class="btn bct-dept-send">Send message</button>
        </div>
      </div>
    </form>
    <p class="bct-dept-status small text-success mt-3 mb-0" role="status"></p>
  </div>
</div>

Callback request with native date and time inputs

  • type="date"
  • type="time" min max step
  • type="tel" pattern
  • form-check-inline
  • alert

Pick a weekday and a time between 9:00 and 17:00.

Bootstrap has no date picker, so the booking uses the native date and time inputs styled by form-control. The script sets the date min to today and uses setCustomValidity() to refuse weekends, while min, max and step on the time input keep calls inside office hours. The confirmation is a Bootstrap alert assembled with createElement.

View code
<div class="card bct-call border-0 shadow-sm">
  <div class="card-body p-4">
    <div class="d-flex align-items-center gap-3 mb-4">
      <span class="bct-call-badge" aria-hidden="true"><i class="bi bi-telephone-inbound"></i></span>
      <div>
        <h2 class="h5 mb-0">Request a callback</h2>
        <p class="small text-body-secondary mb-0">Brightwell Physio calls you back at a time that suits you.</p>
      </div>
    </div>
    <form class="row g-3 bct-call-form" novalidate>
      <div class="col-sm-6">
        <label for="bct-call-name" class="form-label">Name</label>
        <input type="text" class="form-control" id="bct-call-name" name="name" autocomplete="name" required>
        <div class="invalid-feedback">Enter your name.</div>
      </div>
      <div class="col-sm-6">
        <label for="bct-call-phone" class="form-label">Phone</label>
        <input type="tel" class="form-control" id="bct-call-phone" name="phone" autocomplete="tel" placeholder="(555) 010-2030" pattern="[0-9 \(\)+\-]{7,20}" required>
        <div class="invalid-feedback">Enter a phone number with digits only.</div>
      </div>
      <div class="col-sm-6">
        <label for="bct-call-date" class="form-label">Day</label>
        <input type="date" class="form-control" id="bct-call-date" name="date" required>
        <div class="invalid-feedback bct-call-date-err">Pick a weekday from today on.</div>
      </div>
      <div class="col-sm-6">
        <label for="bct-call-time" class="form-label">Time</label>
        <input type="time" class="form-control" id="bct-call-time" name="time" min="09:00" max="17:00" step="900" aria-describedby="bct-call-time-help" required>
        <div id="bct-call-time-help" class="form-text">Between 9:00 and 17:00, in 15-minute slots.</div>
        <div class="invalid-feedback">Choose a slot between 9:00 and 17:00.</div>
      </div>
      <fieldset class="col-12">
        <legend class="form-label fs-6">How should we call?</legend>
        <div class="form-check form-check-inline">
          <input class="form-check-input" type="radio" name="channel" id="bct-call-phonecall" value="phone" checked>
          <label class="form-check-label" for="bct-call-phonecall">Phone call</label>
        </div>
        <div class="form-check form-check-inline">
          <input class="form-check-input" type="radio" name="channel" id="bct-call-video" value="video">
          <label class="form-check-label" for="bct-call-video">Video call</label>
        </div>
      </fieldset>
      <div class="col-12">
        <button type="submit" class="btn btn-lg bct-call-send">Book my callback</button>
      </div>
    </form>
    <div class="bct-call-result mt-3" role="status"></div>
  </div>
</div>

Modal, offcanvas, spam traps and success states

Where the form lives and what happens after the click: a modal and an offcanvas panel, a honeypot with a consent checkbox, and an accessible success state that replaces the form.

Contact sales form in a modal

  • modal
  • form="id" attribute
  • shown.bs.modal
  • hidden.bs.modal
  • --bs-modal-*

Open the modal and press Send with empty fields.

The form lives in modal-body while the submit button sits in modal-footer, connected by the form attribute so the button still submits it. The shown.bs.modal event moves focus to the first field and hidden.bs.modal clears the values and the .was-validated state. --bs-modal-border-radius and --bs-modal-header-border-color restyle the dialog.

View code
<div class="text-center bct-modal-launch">
  <p class="text-body-secondary mb-3">Need more than 50 seats or a custom contract?</p>
  <button type="button" class="btn btn-lg bct-modal-btn" data-bs-toggle="modal" data-bs-target="#bct-modal">Contact sales</button>
</div>
<div class="modal fade bct-modal" id="bct-modal" tabindex="-1" aria-labelledby="bct-modal-title" aria-hidden="true">
  <div class="modal-dialog modal-dialog-centered">
    <div class="modal-content">
      <div class="modal-header">
        <h2 class="modal-title fs-5" id="bct-modal-title">Talk to the Brightline sales team</h2>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        <form id="bct-modal-form" class="row g-3" novalidate>
          <div class="col-sm-6">
            <label for="bct-modal-name" class="form-label">Name</label>
            <input type="text" class="form-control" id="bct-modal-name" name="name" autocomplete="name" required>
            <div class="invalid-feedback">Enter your name.</div>
          </div>
          <div class="col-sm-6">
            <label for="bct-modal-email" class="form-label">Work email</label>
            <input type="email" class="form-control" id="bct-modal-email" name="email" autocomplete="email" required>
            <div class="invalid-feedback">Enter a work email.</div>
          </div>
          <div class="col-12">
            <label for="bct-modal-size" class="form-label">Team size</label>
            <select class="form-select" id="bct-modal-size" name="size" required>
              <option value="">Choose a range</option>
              <option>50 to 200 people</option>
              <option>200 to 1,000 people</option>
              <option>More than 1,000 people</option>
            </select>
            <div class="invalid-feedback">Pick a team size.</div>
          </div>
          <div class="col-12">
            <label for="bct-modal-msg" class="form-label">What do you need? <span class="text-body-secondary">(optional)</span></label>
            <textarea class="form-control" id="bct-modal-msg" name="message" rows="3"></textarea>
          </div>
        </form>
        <p class="bct-modal-status small text-success mt-3 mb-0" role="status"></p>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
        <button type="submit" form="bct-modal-form" class="btn bct-modal-btn bct-modal-send">Send to sales</button>
      </div>
    </div>
  </div>
</div>

Feedback form in an offcanvas panel

  • offcanvas-end
  • aria-controls
  • btn-check
  • btn-group
  • hidden.bs.offcanvas

Press Send feedback to open the panel.

A docs-page feedback form that slides in from the right with offcanvas-end, so the reader keeps their place. The rating is a btn-group of btn-check radios with icons and visible text, and only the comment is required. The trigger carries aria-controls and the panel uses aria-labelledby, as in the Bootstrap docs.

View code
<div class="card bct-oc-page border-0 shadow-sm">
  <div class="card-body p-4">
    <h2 class="h5">Exporting reports to CSV</h2>
    <p class="text-body-secondary small">Open a report, choose Export, then pick CSV. Large reports arrive by email as a zipped file.</p>
    <div class="d-flex flex-wrap align-items-center gap-3 border-top pt-3">
      <span class="small">Was this page helpful?</span>
      <button class="btn btn-dark btn-sm bct-oc-open" type="button" data-bs-toggle="offcanvas" data-bs-target="#bct-oc" aria-controls="bct-oc"><i class="bi bi-chat-left-text me-2" aria-hidden="true"></i>Send feedback</button>
    </div>
  </div>
</div>
<div class="offcanvas offcanvas-end bct-oc" tabindex="-1" id="bct-oc" aria-labelledby="bct-oc-title">
  <div class="offcanvas-header border-bottom">
    <h2 class="offcanvas-title h5" id="bct-oc-title">Send feedback</h2>
    <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
  </div>
  <div class="offcanvas-body">
    <form class="bct-oc-form" novalidate>
      <fieldset class="mb-3">
        <legend class="form-label fs-6">How useful was this page?</legend>
        <div class="btn-group w-100" role="group">
          <input type="radio" class="btn-check" name="rating" id="bct-oc-r1" value="not useful">
          <label class="btn btn-outline-secondary" for="bct-oc-r1"><i class="bi bi-emoji-frown d-block fs-5" aria-hidden="true"></i>Not really</label>
          <input type="radio" class="btn-check" name="rating" id="bct-oc-r2" value="somewhat" checked>
          <label class="btn btn-outline-secondary" for="bct-oc-r2"><i class="bi bi-emoji-neutral d-block fs-5" aria-hidden="true"></i>Somewhat</label>
          <input type="radio" class="btn-check" name="rating" id="bct-oc-r3" value="very useful">
          <label class="btn btn-outline-secondary" for="bct-oc-r3"><i class="bi bi-emoji-smile d-block fs-5" aria-hidden="true"></i>Very</label>
        </div>
      </fieldset>
      <div class="mb-3">
        <label for="bct-oc-msg" class="form-label">What can we improve?</label>
        <textarea class="form-control" id="bct-oc-msg" name="comment" rows="4" required></textarea>
        <div class="invalid-feedback">Tell us what was missing or unclear.</div>
      </div>
      <div class="mb-4">
        <label for="bct-oc-email" class="form-label">Email <span class="text-body-secondary">(optional)</span></label>
        <input type="email" class="form-control" id="bct-oc-email" name="email" autocomplete="email" aria-describedby="bct-oc-email-help">
        <div id="bct-oc-email-help" class="form-text">Only if you want a reply.</div>
        <div class="invalid-feedback">Check the email format.</div>
      </div>
      <div class="d-grid">
        <button type="submit" class="btn btn-dark">Send feedback</button>
      </div>
    </form>
    <p class="bct-oc-status small text-success mt-3 mb-0" role="status"></p>
  </div>
</div>

Honeypot field and consent checkbox

  • visually-hidden
  • aria-hidden
  • tabindex="-1"
  • form-check
  • requestSubmit()

Press Simulate a bot, then send a real message.

The honeypot is a normal text input inside a visually-hidden wrapper with aria-hidden="true", tabindex="-1" and autocomplete="off", so people never reach it and simple bots fill it in. It is never required. The panel below the form shows what a server would receive and the verdict it would reach, and Simulate a bot fills every field, the trap included, then calls requestSubmit().

View code
<div class="card bct-trap border-0 shadow-sm">
  <div class="card-body p-4">
    <h2 class="h5 mb-1">Write to Quillfield Press</h2>
    <p class="text-body-secondary small mb-4">Submissions, rights questions and bookshop orders.</p>
    <form class="row g-3 bct-trap-form" novalidate>
      <div class="col-sm-6">
        <label for="bct-trap-name" class="form-label">Name</label>
        <input type="text" class="form-control" id="bct-trap-name" name="name" autocomplete="name" required>
        <div class="invalid-feedback">Enter your name.</div>
      </div>
      <div class="col-sm-6">
        <label for="bct-trap-email" class="form-label">Email</label>
        <input type="email" class="form-control" id="bct-trap-email" name="email" autocomplete="email" required>
        <div class="invalid-feedback">Enter a valid email.</div>
      </div>
      <div class="col-12">
        <label for="bct-trap-msg" class="form-label">Message</label>
        <textarea class="form-control" id="bct-trap-msg" name="message" rows="3" required></textarea>
        <div class="invalid-feedback">Write your message.</div>
      </div>
      <div class="visually-hidden" aria-hidden="true">
        <label for="bct-trap-website">Leave this field empty</label>
        <input type="text" id="bct-trap-website" name="website" tabindex="-1" autocomplete="off">
      </div>
      <div class="col-12">
        <div class="form-check">
          <input class="form-check-input" type="checkbox" id="bct-trap-consent" name="consent" required>
          <label class="form-check-label small" for="bct-trap-consent">I agree that Quillfield Press may keep this message for 12 months to answer it. Read the <a href="#">privacy notice</a>.</label>
          <div class="invalid-feedback">Please tick the box so we can store and answer your message.</div>
        </div>
      </div>
      <div class="col-12 d-flex flex-wrap gap-2">
        <button type="submit" class="btn bct-trap-send">Send message</button>
        <button type="button" class="btn btn-outline-secondary bct-trap-bot"><i class="bi bi-robot me-2" aria-hidden="true"></i>Simulate a bot</button>
      </div>
    </form>
    <div class="bct-trap-log mt-4" role="status"></div>
  </div>
</div>

Accessible validation with a success state

  • aria-describedby
  • aria-invalid
  • was-validated
  • createElement
  • tabindex="-1"

Fill in the form and press Send message.

Each field points at its invalid-feedback element with aria-describedby, and on a failed submit the script sets aria-invalid and moves focus to the first bad field, which covers the gap the Bootstrap docs describe for custom validation styles. A valid submit hides the form and builds a success panel with createElement and textContent, then moves focus to its heading. Send another message restores the cleared form.

View code
<div class="card bct-done border-0 shadow-sm">
  <div class="card-body p-4 bct-done-body">
    <form class="bct-done-form" novalidate aria-labelledby="bct-done-title">
      <h2 id="bct-done-title" class="h4 mb-1">Write to Pinecrest Library</h2>
      <p class="text-body-secondary mb-4">Memberships, room bookings and events. We reply within two working days.</p>
      <div class="mb-3">
        <label for="bct-done-name" class="form-label">Full name</label>
        <input type="text" class="form-control" id="bct-done-name" name="name" autocomplete="name" aria-describedby="bct-done-name-err" required>
        <div id="bct-done-name-err" class="invalid-feedback">Enter your full name.</div>
      </div>
      <div class="mb-3">
        <label for="bct-done-email" class="form-label">Email</label>
        <input type="email" class="form-control" id="bct-done-email" name="email" autocomplete="email" aria-describedby="bct-done-email-err" required>
        <div id="bct-done-email-err" class="invalid-feedback">Enter an email address like name@example.com.</div>
      </div>
      <div class="mb-4">
        <label for="bct-done-msg" class="form-label">Message</label>
        <textarea class="form-control" id="bct-done-msg" name="message" rows="4" aria-describedby="bct-done-msg-err" required></textarea>
        <div id="bct-done-msg-err" class="invalid-feedback">Write your message.</div>
      </div>
      <button type="submit" class="btn btn-lg bct-done-send"><i class="bi bi-send me-2" aria-hidden="true"></i>Send message</button>
    </form>
  </div>
</div>

Basic Bootstrap contact form markup

Bootstrap has no contact form component. What it has is a set of form classes, and a contact form comes down to a few fields, some of those classes and a submit button.

The Bootstrap 5.3 documentation says to give every input a proper type (email for addresses, for example) so the browser’s own input checks apply.

Fields and input types

Name is a plain type=”text” input and it’s required. Email uses type=”email”, also required, and every reply depends on it. Subject is optional, and I’d drop it on short forms. The message box is a textarea with rows=”5″, required too.

Every field also needs a name attribute. Leave it off and the browser drops that field from the submitted data.

Classes that style the fields

Most of the styling work comes from a short list.

  • form-control goes on every input and textarea
  • Each label gets form-label, which Bootstrap 5 requires
  • The div wrapping a label and its field takes mb-3
  • The submit button gets btn btn-primary
  • row and col-* handle the outer layout

Bootstrap’s form styles build on its Reboot stylesheet, which is why the same markup renders alike across browsers.

Copy-paste markup

Load the Bootstrap 5.3 stylesheet first. The rest is plain HTML.

<div class="container my-5">
  <div class="row justify-content-center">
    <div class="col-md-8 col-lg-6">
      <form action="/contact" method="POST">
        <div class="mb-3">
          <label for="name" class="form-label">Name</label>
          <input type="text" class="form-control" id="name" name="name" required>
        </div>
        <div class="mb-3">
          <label for="email" class="form-label">Email address</label>
          <input type="email" class="form-control" id="email" name="email" required>
        </div>
        <div class="mb-3">
          <label for="subject" class="form-label">Subject</label>
          <input type="text" class="form-control" id="subject" name="subject">
        </div>
        <div class="mb-3">
          <label for="message" class="form-label">Message</label>
          <textarea class="form-control" id="message" name="message" rows="5" required></textarea>
        </div>
        <button type="submit" class="btn btn-primary">Send message</button>
      </form>
    </div>
  </div>
</div>

The action URL is a placeholder until a server script or hosted endpoint receives the request.

How to lay out the form with the grid or floating labels

Stacked or two columns covers most contact forms, and floating labels suit the short ones. It mostly depends on how much horizontal room the form gets.

Bootstrap’s CSS is mobile first, so the smallest layout gets the base styles and larger breakpoints add to them. The md breakpoint starts at 768px, and col-md-* columns stack below it (Bootstrap 5.3 breakpoints documentation).

Stacked layout

This is the default. Each label and field sits in its own mb-3 wrapper at full width, which works fine in a sidebar, a modal or a narrow column.

Two-column grid

Wrap the fields in a row with the g-3 gutter class, then give name and email col-md-6 each. Subject and message take col-12.

Bootstrap’s grid system uses min-width breakpoints, so the pair sits side by side at 768px and up and collapses to one column on phones.

Floating labels

Floating labels come with a few quirks the documentation spells out. Every input needs a non-empty placeholder, since the CSS-only label relies on :placeholder-shown to detect an empty field. The input also comes before the label in the markup, and a custom textarea height comes from an explicit height style rather than the rows attribute.

Wrap each input and label pair in form-floating. When floating labels sit inside the grid, the form-floating wrappers go inside the column classes.

I’d keep them to forms with three or four fields. Long labels get cramped, and a stacked layout wins there.

What changes between Bootstrap 4 and Bootstrap 5 contact forms?

Bootstrap 5 drops jQuery and the form-group wrapper, requires form-label on labels, and supports floating labels as a real component.

FeatureBootstrap 4Bootstrap 5
Field wrapper.form-groupmb-3 spacing utility
Label classNot required.form-label required
Floating labelsExample only.form-floating component
jQueryDependencyDropped

The Bootstrap 5 migration notes (as of release 5.3.8) list form-group, form-row and form-inline as dropped in favor of the grid and utilities.

Older tutorials are the biggest trap. The Tuts+ contact form guide, for one, is built on Bootstrap 3 with a jQuery Ajax call, and that code needs a rewrite before it fits a Bootstrap 5 page.

MDB has moved on too, and its old Bootstrap 4 (MDB4) documentation URLs now redirect to the MDB5 docs. A site that already runs v4 with jQuery can keep its form as it is.

Porting a Bootstrap 4 snippet

Porting comes down to swapping form-group for mb-3 and adding form-label to the labels. Any $.ajax() calls get rewritten with fetch.

Skip the first swap and the spacing between fields disappears, because Bootstrap 5 has no form-group class to supply it.

How to add client-side validation with needs-validation and novalidate

In short, the form gets the needs-validation class and the novalidate attribute, the fields get required, and each one gets an invalid-feedback message underneath. A submit listener then calls checkValidity() and adds was-validated. Bootstrap’s CSS handles the visual states.

  1. Add class=”needs-validation” and novalidate to the form tag.
  2. Set required on name, email and message, and keep type=”email” on the address field.
  3. Add a div with class invalid-feedback after each control, with a plain-language message.
  4. Attach a submit listener that stops submission when checkValidity() returns false.
  5. Add was-validated to the form on every submit attempt.
  6. Test an empty submit, a malformed email and a valid submit.

The listener is a dozen lines of JavaScript, and the Bootstrap docs supply a starter version.

<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" required>
<div class="invalid-feedback">Enter a valid email address.</div>
</div>

(() => {
  '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')
    })
  })
})()

What each piece does

novalidate turns off the browser’s default tooltips but keeps the validation APIs available in JavaScript (Bootstrap 5.3 documentation). was-validated scopes the :valid and :invalid styles, so empty required fields don’t show as errors on page load. And invalid-feedback is just the message that appears under a failing field.

Custom feedback styles carry an accessibility caveat, which the next section covers.

Where client-side validation stops

Any direct POST request skips the browser checks entirely. MDN Web Docs states that HTML constraint validation does not remove the need for server-side validation.

Bootstrap supports that route too. The .is-invalid and .is-valid classes mark fields from server results and work without a was-validated parent.

Are Bootstrap validation messages accessible to screen readers?

Not by default. The Bootstrap 5.3 documentation states that client-side custom validation styles and tooltips are not exposed to assistive technologies, and it points to the server-side option or the browser default method as the safe choices for now.

Other documentation takes the opposite view, so the sources need a closer look before you pick a pattern.

Where the sources disagree

The Bootstrap 5.3 documentation says custom styles and tooltips are invisible to assistive technologies, so it recommends server-side classes or browser defaults. MDB’s validation documentation argues the reverse. It recommends custom validation styles, because native browser validation messages are not consistently exposed to assistive technologies in all browsers, most notably Chrome.

Both describe a real gap. Custom styles are visual only unless markup connects them to the field, and Bootstrap’s own docs note that browser default messages differ per browser and cannot be styled with CSS.

A pattern that holds under both readings

Keep the Bootstrap classes for styling and add the announcement layer yourself. A few ARIA attributes and a live region cover what the styles lack.

Point each input at the id of its invalid-feedback element with aria-describedby, the same wiring Bootstrap’s server-side example uses. Put the post-submit message in a live region with role=”status” so it is announced without moving focus. On a failed submit, send focus to the first invalid field.

<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" required aria-describedby="emailError">
<div id="emailError" class="invalid-feedback">Enter a valid email address.</div>
</div>

<div id="formStatus" role="status"></div>

// inside the submit listener, after was-validated is added
if (!form.checkValidity()) {
  form.querySelector(':invalid').focus()
}

For a wider checklist covering labels, grouping and error text, this guide to accessible forms goes deeper.

What WCAG 2.2 asks for

Criterion 3.3.1 (Level A) requires that an input error is identified and described in text, so color alone fails. Criterion 4.1.3 (Level AA) covers status messages such as “Message sent”, which must be exposed through a role or property without taking focus.

An error or success message written into the page with no live region or role stays silent for screen reader users. W3C technique F103 lists exactly that pattern as a failure of 4.1.3.

Bootstrap’s forms documentation also says a visible label is generally the best approach for accessibility and usability. Placeholder-only fields are the weak option.

How to send Bootstrap contact form data to email

Match the method to the hosting. Static sites post to a hosted endpoint. On PHP hosting you’d run PHPMailer over SMTP, and Node.js apps tend to use Nodemailer.

MethodOwn server neededSetup effortMain failure case
PHP mail()Yes, PHP hostLowAccepted, never delivered
PHPMailer over SMTPYes, PHP hostMediumWrong SMTP credentials or port
NodemailerYes, Node.jsMediumSender IP without reputation
Hosted endpointNoLowMonthly submission cap
EmailJSNoLowMonthly request cap

PHP with mail() and PHPMailer

PHP’s built-in mail() is the shortest path and the weakest one. The PHP manual itself warns that a message accepted for delivery has not necessarily reached the recipient.

PHPMailer’s README goes further and says mail() should be avoided when possible, since SMTP to localhost is faster and safer.

On the plus side, mail() is one function call, needs no library and exists on most PHP hosts. The catch is no delivery confirmation, a required From header (PHP raises a warning without it) and a lot of guesswork when something fails.

PHPMailer brings SMTP authentication, TLS on port 587 (STARTTLS) or 465 (SMTPS) and exceptions with a readable error message. You do need Composer or manual includes, plus an SMTP account whose credentials you have to store somewhere.

Send from an address on your own domain and set the visitor’s address as Reply-To. That is the usual setup for a contact form.

Node.js with Nodemailer

Nodemailer is the default choice when the form handler already runs on Node.js.

The documentation lists Node.js 20 or later as the requirement for Nodemailer 10, and says to stay on the 9.x line for older Node.js versions. Its SMTP example uses port 587 with STARTTLS and reads credentials from environment variables, not from source code.

It has zero runtime dependencies and supports SMTP, Sendmail and Amazon SES transports, plus test accounts through Ethereal. The downside is the running Node.js server. Mail sent straight from a fresh VPS address often gets rejected or lands in spam, so relay through an authenticated SMTP service.

Hosted endpoints and EmailJS

A hosted endpoint replaces the backend script entirely. You point the form action at the vendor’s URL and the vendor emails you the data.

Hosted form endpoints (Formspree, Netlify Forms, Web3Forms)

These need no server, work on static hosting and take minutes to set up. Free plans cap submissions per month, though, a third party stores your messages, and prices change.

Caps differ by vendor and shift over time, so read the vendor’s current pricing page before committing.

EmailJS

EmailJS sends mail from client-side JavaScript through templates you set up in its dashboard. There’s no server code to write, and the template editor is handy. The free plan is limited to a small number of requests per month, and the integration lives in page source.

Submitting with fetch and showing a status message

The form can post without a page reload. The request goes to an API endpoint, which is either your own script or a hosted URL.

Replace the earlier validation listener with one that validates, then posts. The status region is the same live region from the accessibility section.

form.addEventListener('submit', async event => {
  event.preventDefault()
  form.classList.add('was-validated')
  if (!form.checkValidity()) return

  const status = document.getElementById('formStatus')
  try {
    const response = await fetch(form.action, {
      method: 'POST',
      body: new FormData(form)
    })

    if (!response.ok) throw new Error('Request failed')
    status.textContent = 'Message sent. We will reply by email.'
  } catch (error) {
    status.textContent = 'Something went wrong. Try again or email us directly.'
  }
})

preventDefault runs on every submit because fetch now owns the request, and response.ok separates a server error from a network failure.

A FormData body is sent as multipart, so check which content type the endpoint accepts.

How to stop spam on a Bootstrap contact form

Start with a honeypot field plus server-side validation. Add a CAPTCHA only after spam gets through, and verify its token on the server, because a widget alone protects nothing.

Cloudflare’s Turnstile documentation says this outright. The client-side widget does not protect a form until the server calls the siteverify endpoint.

Honeypot field

A honeypot is a field real visitors never see and simple bots fill in.

Hide it with CSS, add tabindex=”-1″ and aria-hidden=”true” so keyboard and screen reader users skip it, and discard any submission where it has a value. Never mark it required, or the validation listener blocks every real submit.

<div class="visually-hidden" aria-hidden="true">
  <label for="website">Leave this field empty</label>
  <input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>

Visitors never notice it, and there’s no third-party script or keys to manage. Targeted bots that parse the markup can still skip the trap.

Google reCAPTCHA

Each reCAPTCHA response token is valid for two minutes and can be verified once, according to Google’s documentation.

Version 3 returns a score from 0.0 to 1.0 with no visible challenge, and Google suggests 0.5 as the default threshold. The server also checks the action name returned with the score.

Score-based v3 adds no puzzle for the visitor, and libraries exist for most server languages. On the downside, it needs a server script to call siteverify, so a static site needs a hosted endpoint or function. A visitor who writes a long message can also submit an expired token and get a timeout-or-duplicate error.

hCaptcha

hCaptcha follows the same shape as reCAPTCHA. The widget writes a token into the h-captcha-response field, and the server verifies it at hCaptcha’s siteverify endpoint.

Tokens are single-use, and a new one is needed after each submission, expiration or error, per hCaptcha’s documentation. The upside is an easy swap from reCAPTCHA, since mostly the field name and endpoint change. The downside is a visible challenge for visitors.

Cloudflare Turnstile

Turnstile tokens stay valid for 300 seconds and are single use (Cloudflare Turnstile documentation, as of October 2026). The server POSTs the secret key and token to the siteverify API. If the token has expired, the API returns the timeout-or-duplicate error code, and turnstile.reset issues a fresh one. You do need a Cloudflare account.

The longer token window suits contact forms better than reCAPTCHA’s two minutes. The cost is the account and the extra setup.

Why is a Bootstrap contact form not sending email?

When a form won’t send, the cause is usually something mundane. These are the ones I’d check first.

  • If the page reloads to an error or 404 on submit, the action path doesn’t exist or the host doesn’t run PHP. Open the URL directly and confirm the script responds.
  • A button that does nothing usually means checkValidity() is failing on a field the visitor can’t see, such as a required honeypot, or a script error has stopped the listener. Check the browser console.
  • Emails that arrive with empty values mean the script reads keys that differ from the field names, for example $_POST[‘userEmail’] against name=”email”.
  • An error message that shows while the email still arrives points to a cross-origin endpoint that sends no CORS headers. The request reaches the server, but the browser blocks the response, so fetch throws.
  • When the success message shows and nothing arrives, the mail server accepted the message and a later hop dropped it, or it’s sitting in spam.
  • A CAPTCHA that rejects honest visitors has usually expired while they typed, which returns timeout-or-duplicate. Reset the widget and resubmit.

A PHPMailer issue report (#3012) documents the delivery case. Messages sent with mail() through the default sendmail path went out on PHP 7.4 but bounced on 8.0, 8.1, 8.2 and 8.3, and the reporter traced it to PHP 8 passing CRLF line endings to sendmail. Switching to SMTP delivered them.

Test the endpoint on its own with the form’s data before changing the markup. If the request works there, the fault is in the page.

Bootstrap Contact Form FAQ

Can a contact form sit inside a Bootstrap modal?

Yes. Put the fields in the modal-body and open the modal with data-bs-toggle=”modal” and data-bs-target.

If the submit button lives in the modal-footer, give it a form attribute that matches the form’s id, or wrap both parts in the form tag.

How do I reset the form after a successful submit?

Call form.reset() to clear the values, then remove the was-validated class so the green and red states disappear.

Bootstrap’s documentation recommends removing that class after dynamic submissions made with Ajax.

How do I add a phone number or consent checkbox?

For the phone number, add an input with type=”tel” and the form-control class.

For consent, wrap a required form-check-input and its form-check-label in a form-check div. The invalid-feedback message works inside that wrapper too.

How do I add icons to the fields?

Validation icons are built in and appear on their own when a field turns valid or invalid.

For decorative icons, place a Bootstrap Icons SVG inside an input-group-text span next to the field. Add has-validation to that input group so the field corners render correctly in validation states.

Does a contact form need a CSRF token?

Usually not for an anonymous form. A CSRF token protects actions that ride on a logged-in session, and a public contact form has no session to hijack.

Add one when the form sits behind a login or the handler touches account data.

Where can I find free contact form templates?

Galleries such as Colorlib, uiCookies and FreeFrontend publish free designs. Check the Bootstrap version in the template’s stylesheet link first, because Bootstrap 4 markup needs porting before it fits a Bootstrap 5 page.

Templates ship front-end code only, so the form still needs a handler or endpoint.

What to Recheck Once a Bootstrap Contact Form Is Live

Once the form is live, the delivery path, the spam keys and the Bootstrap version are what need another look.

The order follows the cost of failure. A broken delivery path loses messages, a rotated key blocks honest visitors, and a Bootstrap release changes appearance.

  • Send a real test message every month
  • Update SMTP credentials and CAPTCHA keys whenever a provider rotates them
  • Read the release notes before upgrading Bootstrap

It’s a few minutes of routine work, and in return you find a dead form before a visitor does.

The accessibility advice stands as of Bootstrap 5.3.8, checked October 29, 2026. Once the documentation drops its warning about custom validation styles, the extra aria-describedby wiring is worth revisiting.