Custom Form Controls

Browsers render checkboxes, radios, selects, and file inputs with their own inconsistent native styling that's hard to theme with CSS alone. Bootstrap 4 solves this with a set of custom form control classes that visually hide the native control and rebuild its appearance from a sibling <label>, while keeping the real input underneath for accessibility and form submission. We'll use them to style the difficulty select and a public/private toggle on TrailMap's "Submit a Trail" form.

Bootstrap 4-only pattern: Bootstrap 5 renamed and restructured every one of these classes, and it's one of the biggest real migration headaches between the two versions:
  • .custom-select → .form-select
  • .custom-control + .custom-checkbox → simply .form-check
  • .custom-control + .custom-radio → simply .form-check (with type="radio")
  • .custom-switch → .form-switch
  • .custom-file → dropped; a plain .form-control now styles <input type="file">
The whole nested wrapper structure got simpler too — Bootstrap 5's .form-check needs far fewer wrapper elements than Bootstrap 4's .custom-control. Every example below is the Bootstrap 4 structure; don't copy this markup into a Bootstrap 5 project.

Custom Checkboxes: .custom-control / .custom-checkbox

Every custom checkbox needs this exact nested structure: a wrapper with .custom-control .custom-checkbox, an <input> with .custom-control-input, and a <label> with .custom-control-label whose for attribute matches the input's id. The label's ::before/::after pseudo-elements draw the visible box and checkmark.

<div class="custom-control custom-checkbox">
  <input type="checkbox" class="custom-control-input" id="agreeTerms">
  <label class="custom-control-label" for="agreeTerms">I agree to the terms & conditions</label>
</div>

Custom Radios: .custom-control / .custom-radio

Same nested structure as checkboxes, swapping .custom-checkbox for .custom-radio and type="radio" on the input. Radios sharing a group need the same name attribute.

<div class="custom-control custom-radio">
  <input type="radio" name="sortOrder" class="custom-control-input" id="sortDistance">
  <label class="custom-control-label" for="sortDistance">Sort by distance</label>
</div>
<div class="custom-control custom-radio">
  <input type="radio" name="sortOrder" class="custom-control-input" id="sortRating">
  <label class="custom-control-label" for="sortRating">Sort by rating</label>
</div>

Custom Select: .custom-select

.custom-select goes directly on the <select> element itself — no wrapper or extra label markup needed. It replaces the browser's native dropdown arrow with a consistent custom-drawn one.

<select class="custom-select" id="trailDifficulty">
  <option value="">Choose difficulty...</option>
  <option value="easy">Easy</option>
  <option value="moderate">Moderate</option>
  <option value="hard">Hard</option>
</select>

Custom Switch: .custom-switch

.custom-switch is a small addition on top of the checkbox structure — add it alongside .custom-control instead of .custom-checkbox, and the label renders as a sliding toggle switch rather than a square box.

<div class="custom-control custom-switch">
  <input type="checkbox" class="custom-control-input" id="trailPublic" checked>
  <label class="custom-control-label" for="trailPublic">Make this trail public</label>
</div>

Custom File Input: .custom-file

.custom-file wraps a hidden native <input type="file"> (with .custom-file-input) plus a .custom-file-label that displays either placeholder text or the chosen filename via a small script listening for the change event.

<div class="custom-file">
  <input type="file" class="custom-file-input" id="trailPhoto" accept="image/*">
  <label class="custom-file-label" for="trailPhoto">Choose a photo...</label>
</div>

Worked Example: TrailMap "Submit a Trail" Form

Here's the custom-styled portion of TrailMap's trail submission form — a .custom-select for difficulty, a .custom-switch for visibility, a .custom-file for the trail photo, and a .custom-checkbox for accepting the terms.

<!-- TrailMap: Submit a Trail — custom controls -->
<div class="form-group">
  <label for="trailDifficulty">Difficulty</label>
  <select class="custom-select" id="trailDifficulty" name="difficulty">
    <option value="easy">Easy</option>
    <option value="moderate">Moderate</option>
    <option value="hard">Hard</option>
  </select>
</div>

<div class="custom-file mb-3">
  <input type="file" class="custom-file-input" id="trailPhoto" name="photo" accept="image/*">
  <label class="custom-file-label" for="trailPhoto">Choose a trail photo...</label>
</div>

<div class="custom-control custom-switch mb-3">
  <input type="checkbox" class="custom-control-input" id="trailPublic" name="is_public" checked>
  <label class="custom-control-label" for="trailPublic">Make this trail public</label>
</div>

<div class="custom-control custom-checkbox mb-3">
  <input type="checkbox" class="custom-control-input" id="agreeTerms" name="agree_terms" required>
  <label class="custom-control-label" for="agreeTerms">I agree to TrailMap's submission terms</label>
</div>

<button type="submit" class="btn btn-primary">Submit Trail</button>
Up next: Form Controls covers the plain, non-custom styling for text inputs, selects, and checkboxes — the default look these custom classes are opting out of.