Bootstrap Gallery: 16 Examples With Lightbox and Filters

Bootstrap doesn't ship a gallery. The 5.3.8 documentation lists 24 components, and none of them is a gallery or a lightbox. What it does ship is the raw material: a 12-column grid, a ratio helper, a modal and a carousel.

Below are 16 Bootstrap gallery examples you can copy, followed by a guide to grids, viewers, masonry, filtering, speed and accessibility.

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>

Grid, masonry and mosaic layouts

Four ways to lay out thumbnails: the row-cols grid with the ratio helper, a masonry look from CSS columns, a featured mosaic on CSS grid and a sideways strip with scroll snapping.

Responsive grid with row-cols and ratio

  • row-cols-*
  • ratio
  • object-fit-cover
  • btn-check

Switch the crop with the 1:1, 4:3 and 16:9 buttons.

The column count lives on the row (row-cols-2 row-cols-md-3), and every photo sits in a ratio box with object-fit-cover, so mixed source files still line up. The radio buttons swap the ratio class on every box to show how 1x1, 4x3 and 16x9 crop the same set.

View code
<section id="bgl-rc" class="bgl-rc" aria-labelledby="bgl-rc-title">
  <div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
    <div>
      <h2 id="bgl-rc-title" class="h5 mb-0">Field notes, spring set</h2>
      <p class="small text-body-secondary mb-0">Six photos from the Northlight Collective</p>
    </div>
    <div class="btn-group" role="group" aria-label="Thumbnail crop">
      <input type="radio" class="btn-check bgl-rc-crop" name="bgl-rc-crop" id="bgl-rc-11" value="ratio-1x1" autocomplete="off">
      <label class="btn btn-outline-secondary" for="bgl-rc-11">1:1</label>
      <input type="radio" class="btn-check bgl-rc-crop" name="bgl-rc-crop" id="bgl-rc-43" value="ratio-4x3" autocomplete="off" checked>
      <label class="btn btn-outline-secondary" for="bgl-rc-43">4:3</label>
      <input type="radio" class="btn-check bgl-rc-crop" name="bgl-rc-crop" id="bgl-rc-169" value="ratio-16x9" autocomplete="off">
      <label class="btn btn-outline-secondary" for="bgl-rc-169">16:9</label>
    </div>
  </div>
  <div class="row row-cols-2 row-cols-md-3 g-2 g-md-3">
    <div class="col"><div class="ratio ratio-4x3"><img src="fjord.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="A deep blue fjord between steep mountains, seen from a granite cliff top"></div></div>
    <div class="col"><div class="ratio ratio-4x3"><img src="highland-road.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="A narrow road winding below green cliffs under a grey sky"></div></div>
    <div class="col"><div class="ratio ratio-4x3"><img src="forest-waterfall.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="A tall waterfall dropping into a mossy gorge in a pine forest"></div></div>
    <div class="col"><div class="ratio ratio-4x3"><img src="granite-valley.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="Granite cliffs above tall pines and a calm river" loading="lazy"></div></div>
    <div class="col"><div class="ratio ratio-4x3"><img src="snow-camp.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="Yellow tents in deep snow behind a stone wall, with snowy peaks beyond" loading="lazy"></div></div>
    <div class="col"><div class="ratio ratio-4x3"><img src="coast-road.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="Green cliffs with terraced slopes dropping to a calm sea" loading="lazy"></div></div>
  </div>
</section>

Masonry-style layout with CSS columns

  • column-count
  • break-inside
  • object-fit-cover
  • border-rounded utilities

A masonry look without the Masonry plugin: the wrapper sets column-count (two on phones, three from 768px) and each figure gets break-inside: avoid so it never splits. The cards are plain Bootstrap utilities, and three aspect-ratio classes give the photos their uneven heights. Items run down each column, so keep this for sets where reading order does not matter.

View code
<div class="bgl-mas">
  <figure class="bgl-mas-item bg-body border rounded-3 p-2 shadow-sm">
    <img src="mountain-hiker.webp" class="w-100 rounded-2 object-fit-cover bgl-mas-tall" width="800" height="600" alt="A hiker with a large backpack looking out over hazy mountains">
    <figcaption class="small px-1 pt-2"><strong>Ridge at first light</strong> <span class="text-body-secondary">by Mara Lind</span></figcaption>
  </figure>
  <figure class="bgl-mas-item bg-body border rounded-3 p-2 shadow-sm">
    <img src="canal-houses.webp" class="w-100 rounded-2 object-fit-cover" width="800" height="600" alt="Gabled canal houses with small boats moored along the water">
    <figcaption class="small px-1 pt-2"><strong>Canal mornings</strong> <span class="text-body-secondary">by Jonas Veld</span></figcaption>
  </figure>
  <figure class="bgl-mas-item bg-body border rounded-3 p-2 shadow-sm">
    <img src="jellyfish.webp" class="w-100 rounded-2 object-fit-cover bgl-mas-square" width="800" height="600" alt="An orange jellyfish with long trailing tentacles in deep blue water">
    <figcaption class="small px-1 pt-2"><strong>Drift</strong> <span class="text-body-secondary">by Ana Ruiz</span></figcaption>
  </figure>
  <figure class="bgl-mas-item bg-body border rounded-3 p-2 shadow-sm">
    <img src="red-rock-canyon.webp" class="w-100 rounded-2 object-fit-cover" width="800" height="600" alt="Red sandstone cliffs glowing orange at sunset above a rocky slope">
    <figcaption class="small px-1 pt-2"><strong>Last light on the rim</strong> <span class="text-body-secondary">by Theo Marsh</span></figcaption>
  </figure>
  <figure class="bgl-mas-item bg-body border rounded-3 p-2 shadow-sm">
    <img src="brick-alley.webp" class="w-100 rounded-2 object-fit-cover bgl-mas-tall" width="800" height="600" alt="A narrow brick alley with fire escapes and ivy, high-rise towers at the far end" loading="lazy">
    <figcaption class="small px-1 pt-2"><strong>Back streets</strong> <span class="text-body-secondary">by Jonas Veld</span></figcaption>
  </figure>
  <figure class="bgl-mas-item bg-body border rounded-3 p-2 shadow-sm">
    <img src="snow-camp.webp" class="w-100 rounded-2 object-fit-cover" width="800" height="600" alt="Yellow tents in deep snow behind a stone wall, with snowy peaks beyond" loading="lazy">
    <figcaption class="small px-1 pt-2"><strong>Camp two</strong> <span class="text-body-secondary">by Mara Lind</span></figcaption>
  </figure>
  <figure class="bgl-mas-item bg-body border rounded-3 p-2 shadow-sm">
    <img src="city-skyline.webp" class="w-100 rounded-2 object-fit-cover bgl-mas-square" width="800" height="600" alt="A city skyline at sunrise beside a wide lake, seen from above" loading="lazy">
    <figcaption class="small px-1 pt-2"><strong>Lakefront, 6:12 am</strong> <span class="text-body-secondary">by Ana Ruiz</span></figcaption>
  </figure>
  <figure class="bgl-mas-item bg-body border rounded-3 p-2 shadow-sm">
    <img src="fjord.webp" class="w-100 rounded-2 object-fit-cover" width="800" height="600" alt="A deep blue fjord between steep mountains, seen from a granite cliff top" loading="lazy">
    <figcaption class="small px-1 pt-2"><strong>The long drop</strong> <span class="text-body-secondary">by Theo Marsh</span></figcaption>
  </figure>
  <figure class="bgl-mas-item bg-body border rounded-3 p-2 shadow-sm">
    <img src="forest-waterfall.webp" class="w-100 rounded-2 object-fit-cover bgl-mas-tall" width="800" height="600" alt="A tall waterfall dropping into a mossy gorge in a pine forest" loading="lazy">
    <figcaption class="small px-1 pt-2"><strong>Falls in the fog</strong> <span class="text-body-secondary">by Mara Lind</span></figcaption>
  </figure>
</div>

Horizontal scroll-snap strip

  • scroll-snap-type
  • card
  • btn-light
  • scrollBy

Scroll the strip, or use the arrow buttons.

A single row of photos that scrolls sideways and snaps each card to the left edge, a lighter alternative to a carousel for "recently added" rows. The scroller is a focusable region so arrow keys work, and the two buttons call scrollBy with one card width and disable themselves at either end. Smooth scrolling only switches on when reduced motion is not requested.

View code
<section class="card border-0 shadow-sm bgl-strip-wrap" aria-labelledby="bgl-strip-title">
  <div class="card-body">
    <div class="d-flex justify-content-between align-items-center gap-2 mb-3">
      <h2 id="bgl-strip-title" class="h5 mb-0">Recently added to the archive</h2>
      <div class="d-flex gap-2">
        <button type="button" class="btn btn-light border bgl-strip-btn bgl-strip-prev" aria-controls="bgl-strip" aria-label="Scroll back" disabled><i class="bi bi-arrow-left" aria-hidden="true"></i></button>
        <button type="button" class="btn btn-light border bgl-strip-btn bgl-strip-next" aria-controls="bgl-strip" aria-label="Scroll forward"><i class="bi bi-arrow-right" aria-hidden="true"></i></button>
      </div>
    </div>
    <div id="bgl-strip" class="bgl-strip" role="region" tabindex="0" aria-label="Recently added photos, scrolls sideways">
      <ul class="list-unstyled d-flex gap-3 mb-0">
        <li class="bgl-strip-item"><figure class="mb-0"><img src="granite-valley.webp" class="w-100 rounded-3 object-fit-cover" width="800" height="600" alt="Granite cliffs above tall pines and a calm river"><figcaption class="small mt-2"><span class="fw-semibold d-block">Valley floor</span><span class="text-body-secondary">Added 2 hours ago</span></figcaption></figure></li>
        <li class="bgl-strip-item"><figure class="mb-0"><img src="jellyfish.webp" class="w-100 rounded-3 object-fit-cover" width="800" height="600" alt="An orange jellyfish with long trailing tentacles in deep blue water"><figcaption class="small mt-2"><span class="fw-semibold d-block">Sea nettle</span><span class="text-body-secondary">Added 5 hours ago</span></figcaption></figure></li>
        <li class="bgl-strip-item"><figure class="mb-0"><img src="canal-houses.webp" class="w-100 rounded-3 object-fit-cover" width="800" height="600" alt="Gabled canal houses with small boats moored along the water"><figcaption class="small mt-2"><span class="fw-semibold d-block">Quay at noon</span><span class="text-body-secondary">Added yesterday</span></figcaption></figure></li>
        <li class="bgl-strip-item"><figure class="mb-0"><img src="red-rock-canyon.webp" class="w-100 rounded-3 object-fit-cover" width="800" height="600" alt="Red sandstone cliffs glowing orange at sunset above a rocky slope" loading="lazy"><figcaption class="small mt-2"><span class="fw-semibold d-block">Mesa edge</span><span class="text-body-secondary">Added yesterday</span></figcaption></figure></li>
        <li class="bgl-strip-item"><figure class="mb-0"><img src="city-skyline.webp" class="w-100 rounded-3 object-fit-cover" width="800" height="600" alt="A city skyline at sunrise beside a wide lake, seen from above" loading="lazy"><figcaption class="small mt-2"><span class="fw-semibold d-block">Tower view</span><span class="text-body-secondary">Added 2 days ago</span></figcaption></figure></li>
        <li class="bgl-strip-item"><figure class="mb-0"><img src="snow-camp.webp" class="w-100 rounded-3 object-fit-cover" width="800" height="600" alt="Yellow tents in deep snow behind a stone wall, with snowy peaks beyond" loading="lazy"><figcaption class="small mt-2"><span class="fw-semibold d-block">High camp</span><span class="text-body-secondary">Added 3 days ago</span></figcaption></figure></li>
      </ul>
    </div>
  </div>
</section>

Lightboxes and full-size viewers

Full-size viewers built on one Bootstrap modal, so focus moves into the dialog, stays there and returns to the thumbnail that opened it. No lightbox library involved.

Image zoom in a modal with object-fit

  • modal-fullscreen-sm-down
  • object-fit-contain
  • object-fit-cover
  • btn-check

Click a photo to open it. Use the buttons or the arrow keys, and Escape to close.

The viewer swaps the Bootstrap object-fit utilities on one image: Fit shows the whole photo with object-fit-contain, Fill crops it with object-fit-cover, and Zoom 2x scales it with a transform. While zoomed, the transform origin follows the pointer, and the focusable viewport pans with the arrow keys. The dialog goes fullscreen on phones through modal-fullscreen-sm-down.

View code
<div class="bgl-zm">
  <div class="row row-cols-3 g-2">
    <div class="col"><button type="button" class="bgl-zm-thumb ratio ratio-1x1" data-bs-toggle="modal" data-bs-target="#bgl-zm-modal" data-bgl-title="Sea nettle, aquarium hall"><img src="jellyfish.webp" class="object-fit-cover" width="800" height="600" alt="An orange jellyfish with long trailing tentacles in deep blue water"></button></div>
    <div class="col"><button type="button" class="bgl-zm-thumb ratio ratio-1x1" data-bs-toggle="modal" data-bs-target="#bgl-zm-modal" data-bgl-title="Brick alley, downtown"><img src="brick-alley.webp" class="object-fit-cover" width="800" height="600" alt="A narrow brick alley with fire escapes and ivy, high-rise towers at the far end"></button></div>
    <div class="col"><button type="button" class="bgl-zm-thumb ratio ratio-1x1" data-bs-toggle="modal" data-bs-target="#bgl-zm-modal" data-bgl-title="Skyline at sunrise"><img src="city-skyline.webp" class="object-fit-cover" width="800" height="600" alt="A city skyline at sunrise beside a wide lake, seen from above"></button></div>
  </div>
  <div class="modal fade" id="bgl-zm-modal" tabindex="-1" aria-labelledby="bgl-zm-title" aria-hidden="true">
    <div class="modal-dialog modal-lg modal-dialog-centered modal-fullscreen-sm-down">
      <div class="modal-content">
        <div class="modal-header py-2">
          <h2 class="modal-title fs-6" id="bgl-zm-title">Photo</h2>
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body p-0">
          <div class="bgl-zm-view" tabindex="0" role="region" aria-label="Photo viewer. When zoomed, use the arrow keys to pan.">
            <img src="jellyfish.webp" class="w-100 h-100 object-fit-contain bgl-zm-img" width="800" height="600" alt="">
          </div>
        </div>
        <div class="modal-footer justify-content-between">
          <div class="btn-group" role="group" aria-label="Image size">
            <input type="radio" class="btn-check bgl-zm-mode" name="bgl-zm-mode" id="bgl-zm-fit" value="contain" autocomplete="off" checked>
            <label class="btn btn-outline-dark" for="bgl-zm-fit">Fit</label>
            <input type="radio" class="btn-check bgl-zm-mode" name="bgl-zm-mode" id="bgl-zm-fill" value="cover" autocomplete="off">
            <label class="btn btn-outline-dark" for="bgl-zm-fill">Fill</label>
            <input type="radio" class="btn-check bgl-zm-mode" name="bgl-zm-mode" id="bgl-zm-zoom" value="zoom" autocomplete="off">
            <label class="btn btn-outline-dark" for="bgl-zm-zoom"><i class="bi bi-zoom-in" aria-hidden="true"></i> Zoom 2x</label>
          </div>
          <button type="button" class="btn btn-dark" data-bs-dismiss="modal">Done</button>
        </div>
      </div>
    </div>
  </div>
</div>

Captions, cards and figures

Different ways to put text next to a photo: an overlay that also works on focus and touch, polaroid cards, lazy-loaded figures and selectable tiles for a print order.

Caption overlay on hover, focus and touch

  • row-cols-*
  • position-absolute
  • @media (hover: none)
  • :focus-visible

Hover a photo or Tab to it. On a touch screen the captions are always shown.

Each photo is a link with its caption on a solid translucent panel that slides up on hover. The same rule fires on :focus-visible, so keyboard users see it too, and @media (hover: none) shows every caption permanently on touch screens, where hover does not exist. The slide only animates when reduced motion is not requested.

View code
<div class="row row-cols-2 row-cols-md-3 g-3 bgl-ov">
  <div class="col"><a href="#" class="bgl-ov-item"><img src="red-rock-canyon.webp" width="800" height="600" alt="Red sandstone cliffs glowing orange at sunset above a rocky slope"><span class="bgl-ov-cap"><span class="fw-semibold d-block">Mesa at dusk</span><span class="small">Desert series, 2025</span></span></a></div>
  <div class="col"><a href="#" class="bgl-ov-item"><img src="jellyfish.webp" width="800" height="600" alt="An orange jellyfish with long trailing tentacles in deep blue water"><span class="bgl-ov-cap"><span class="fw-semibold d-block">Sea nettle</span><span class="small">Blue room, 2024</span></span></a></div>
  <div class="col"><a href="#" class="bgl-ov-item"><img src="brick-alley.webp" width="800" height="600" alt="A narrow brick alley with fire escapes and ivy, high-rise towers at the far end"><span class="bgl-ov-cap"><span class="fw-semibold d-block">Service lane</span><span class="small">City series, 2026</span></span></a></div>
  <div class="col"><a href="#" class="bgl-ov-item"><img src="snow-camp.webp" width="800" height="600" alt="Yellow tents in deep snow behind a stone wall, with snowy peaks beyond" loading="lazy"><span class="bgl-ov-cap"><span class="fw-semibold d-block">High camp</span><span class="small">Winter series, 2026</span></span></a></div>
  <div class="col"><a href="#" class="bgl-ov-item"><img src="canal-houses.webp" width="800" height="600" alt="Gabled canal houses with small boats moored along the water" loading="lazy"><span class="bgl-ov-cap"><span class="fw-semibold d-block">Quay at noon</span><span class="small">City series, 2025</span></span></a></div>
  <div class="col"><a href="#" class="bgl-ov-item"><img src="mountain-hiker.webp" width="800" height="600" alt="A hiker with a large backpack looking out over hazy mountains" loading="lazy"><span class="bgl-ov-cap"><span class="fw-semibold d-block">The last ridge</span><span class="small">Trail series, 2024</span></span></a></div>
</div>

Polaroid card gallery

  • card
  • row-cols-*
  • --bs-card-* variables
  • rotate

Each print is a Bootstrap card restyled through its own variables (--bs-card-border-width, --bs-card-border-radius, --bs-card-inner-border-radius) into a square-cornered polaroid with a thick bottom margin. Alternating rotate values on the columns pin the prints at slight angles on a cork-toned board, and the straighten-on-hover only animates when reduced motion is off.

View code
<div class="bgl-pol-board">
  <div class="row row-cols-2 row-cols-md-3 g-3 g-md-4">
    <div class="col"><figure class="card bgl-pol mb-0"><img src="snow-camp.webp" class="card-img-top" width="800" height="600" alt="Yellow tents in deep snow behind a stone wall, with snowy peaks beyond"><figcaption class="card-body px-1 py-2"><span class="bgl-pol-note">Camp two, -14 C</span><small class="d-block text-body-secondary">Feb 2026</small></figcaption></figure></div>
    <div class="col"><figure class="card bgl-pol mb-0"><img src="fjord.webp" class="card-img-top" width="800" height="600" alt="A deep blue fjord between steep mountains, seen from a granite cliff top"><figcaption class="card-body px-1 py-2"><span class="bgl-pol-note">Do not look down</span><small class="d-block text-body-secondary">Jul 2025</small></figcaption></figure></div>
    <div class="col"><figure class="card bgl-pol mb-0"><img src="canal-houses.webp" class="card-img-top" width="800" height="600" alt="Gabled canal houses with small boats moored along the water"><figcaption class="card-body px-1 py-2"><span class="bgl-pol-note">Our rented boat</span><small class="d-block text-body-secondary">Apr 2025</small></figcaption></figure></div>
    <div class="col"><figure class="card bgl-pol mb-0"><img src="mountain-hiker.webp" class="card-img-top" width="800" height="600" alt="A hiker with a large backpack looking out over hazy mountains" loading="lazy"><figcaption class="card-body px-1 py-2"><span class="bgl-pol-note">Sam, summit day</span><small class="d-block text-body-secondary">Sep 2025</small></figcaption></figure></div>
    <div class="col"><figure class="card bgl-pol mb-0"><img src="coast-road.webp" class="card-img-top" width="800" height="600" alt="Green cliffs with terraced slopes dropping to a calm sea" loading="lazy"><figcaption class="card-body px-1 py-2"><span class="bgl-pol-note">Road to the lighthouse</span><small class="d-block text-body-secondary">Jun 2025</small></figcaption></figure></div>
    <div class="col"><figure class="card bgl-pol mb-0"><img src="forest-waterfall.webp" class="card-img-top" width="800" height="600" alt="A tall waterfall dropping into a mossy gorge in a pine forest" loading="lazy"><figcaption class="card-body px-1 py-2"><span class="bgl-pol-note">Worth the 600 steps</span><small class="d-block text-body-secondary">Oct 2025</small></figcaption></figure></div>
  </div>
</div>

Lazy-loaded figures with figcaption

  • figure
  • figure-img
  • figure-caption
  • loading="lazy"

A trip journal built from the Bootstrap figure component: figure, figure-img and figure-caption, with img-fluid added by hand because figure images have no set size. The first row loads normally and every later image gets loading="lazy" and decoding="async", while the width and height attributes plus a tinted background reserve each box before the file arrives.

View code
<section class="bgl-lz" aria-labelledby="bgl-lz-title">
  <h2 id="bgl-lz-title" class="h4 mb-1">Six days on the coast road</h2>
  <p class="text-body-secondary mb-4">A photo journal, one stop per day.</p>
  <div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-4">
    <div class="col"><figure class="figure d-block mb-0"><img src="coast-road.webp" class="figure-img img-fluid rounded-3 bgl-lz-img" width="800" height="600" alt="Green cliffs with terraced slopes dropping to a calm sea"><figcaption class="figure-caption">Morning on the cliff road, 41 km to the next town.</figcaption></figure></div>
    <div class="col"><figure class="figure d-block mb-0"><img src="canal-houses.webp" class="figure-img img-fluid rounded-3 bgl-lz-img" width="800" height="600" alt="Gabled canal houses with small boats moored along the water"><figcaption class="figure-caption">Lunch stop in the old harbor quarter.</figcaption></figure></div>
    <div class="col"><figure class="figure d-block mb-0"><img src="forest-waterfall.webp" class="figure-img img-fluid rounded-3 bgl-lz-img" width="800" height="600" alt="A tall waterfall dropping into a mossy gorge in a pine forest"><figcaption class="figure-caption">A detour inland to the falls after two days of rain.</figcaption></figure></div>
    <div class="col"><figure class="figure d-block mb-0"><img src="highland-road.webp" class="figure-img img-fluid rounded-3 bgl-lz-img" width="800" height="600" alt="A narrow road winding below green cliffs under a grey sky" loading="lazy" decoding="async"><figcaption class="figure-caption">Single-track road under the ridge, passing places only.</figcaption></figure></div>
    <div class="col"><figure class="figure d-block mb-0"><img src="fjord.webp" class="figure-img img-fluid rounded-3 bgl-lz-img" width="800" height="600" alt="A deep blue fjord between steep mountains, seen from a granite cliff top" loading="lazy" decoding="async"><figcaption class="figure-caption">The two-hour hike to the cliff top, crowded by ten.</figcaption></figure></div>
    <div class="col"><figure class="figure d-block mb-0"><img src="red-rock-canyon.webp" class="figure-img img-fluid rounded-3 bgl-lz-img" width="800" height="600" alt="Red sandstone cliffs glowing orange at sunset above a rocky slope" loading="lazy" decoding="async"><figcaption class="figure-caption">Last evening, the sun on the red cliffs.</figcaption></figure></div>
  </div>
</section>

Selectable photo tiles for a print order

  • btn-check
  • fieldset
  • aria-live
  • card

Click photos to select them, then order the prints.

Each tile is a real checkbox with the btn-check class, so the label around the photo takes the clicks and keyboard focus still lands on a native control. A checked tile gets a primary ring and a filled check mark, a live region counts the selection, and the order button stays disabled until at least one photo is picked.

View code
<form class="card border-0 shadow-sm bgl-pick" aria-labelledby="bgl-pick-title">
  <div class="card-body">
    <div class="d-flex justify-content-between align-items-baseline gap-2 mb-3">
      <h2 id="bgl-pick-title" class="h5 mb-0">Pick photos to print</h2>
      <span class="small text-body-secondary text-nowrap">Matte, 20 x 30 cm</span>
    </div>
    <fieldset>
      <legend class="visually-hidden">Photos</legend>
      <div class="row row-cols-3 g-2">
        <div class="col"><input type="checkbox" class="btn-check" id="bgl-pick-1" name="photos" value="fjord" autocomplete="off"><label class="bgl-pick-tile" for="bgl-pick-1"><img src="fjord.webp" width="800" height="600" alt="A deep blue fjord between steep mountains, seen from a granite cliff top"><span class="bgl-pick-mark" aria-hidden="true"><i class="bi bi-check-lg"></i></span></label></div>
        <div class="col"><input type="checkbox" class="btn-check" id="bgl-pick-2" name="photos" value="granite-valley" autocomplete="off"><label class="bgl-pick-tile" for="bgl-pick-2"><img src="granite-valley.webp" width="800" height="600" alt="Granite cliffs above tall pines and a calm river"><span class="bgl-pick-mark" aria-hidden="true"><i class="bi bi-check-lg"></i></span></label></div>
        <div class="col"><input type="checkbox" class="btn-check" id="bgl-pick-3" name="photos" value="jellyfish" autocomplete="off"><label class="bgl-pick-tile" for="bgl-pick-3"><img src="jellyfish.webp" width="800" height="600" alt="An orange jellyfish with long trailing tentacles in deep blue water"><span class="bgl-pick-mark" aria-hidden="true"><i class="bi bi-check-lg"></i></span></label></div>
        <div class="col"><input type="checkbox" class="btn-check" id="bgl-pick-4" name="photos" value="city-skyline" autocomplete="off"><label class="bgl-pick-tile" for="bgl-pick-4"><img src="city-skyline.webp" width="800" height="600" alt="A city skyline at sunrise beside a wide lake, seen from above"><span class="bgl-pick-mark" aria-hidden="true"><i class="bi bi-check-lg"></i></span></label></div>
        <div class="col"><input type="checkbox" class="btn-check" id="bgl-pick-5" name="photos" value="red-rock-canyon" autocomplete="off"><label class="bgl-pick-tile" for="bgl-pick-5"><img src="red-rock-canyon.webp" width="800" height="600" alt="Red sandstone cliffs glowing orange at sunset above a rocky slope"><span class="bgl-pick-mark" aria-hidden="true"><i class="bi bi-check-lg"></i></span></label></div>
        <div class="col"><input type="checkbox" class="btn-check" id="bgl-pick-6" name="photos" value="snow-camp" autocomplete="off"><label class="bgl-pick-tile" for="bgl-pick-6"><img src="snow-camp.webp" width="800" height="600" alt="Yellow tents in deep snow behind a stone wall, with snowy peaks beyond"><span class="bgl-pick-mark" aria-hidden="true"><i class="bi bi-check-lg"></i></span></label></div>
      </div>
    </fieldset>
    <div class="d-flex justify-content-between align-items-center gap-2 mt-3">
      <p class="small mb-0 bgl-pick-status" aria-live="polite"><span class="bgl-pick-count">0</span> selected</p>
      <button type="submit" class="btn btn-primary bgl-pick-submit" disabled>Order prints</button>
    </div>
  </div>
</form>

Filters, albums, comparisons and color modes

Galleries that do more than show a grid: category filters, albums in tabs, batch loading, a before and after slider and a photo wall that switches color mode.

Filterable portfolio with a button group

  • btn-group
  • aria-pressed
  • d-none
  • --bs-btn-* variables

Pick a category to filter the photos.

Each column carries a data-bgl-cat attribute, and the filter buttons toggle d-none on the ones that do not match, so the flexbox row closes the gaps by itself. The buttons are a btn-group with aria-pressed and the active class, themed in plum through the --bs-btn-* variables, and a live region reports how many photos are showing.

View code
<section class="bgl-flt" aria-labelledby="bgl-flt-title">
  <div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-2">
    <h2 id="bgl-flt-title" class="h4 mb-0">Portfolio</h2>
    <div class="btn-group" role="group" aria-label="Filter by category">
      <button type="button" class="btn bgl-flt-btn active" data-bgl-filter="all" aria-pressed="true">All</button>
      <button type="button" class="btn bgl-flt-btn" data-bgl-filter="land" aria-pressed="false">Land</button>
      <button type="button" class="btn bgl-flt-btn" data-bgl-filter="city" aria-pressed="false">Cities</button>
      <button type="button" class="btn bgl-flt-btn" data-bgl-filter="water" aria-pressed="false">Water</button>
    </div>
  </div>
  <p class="small text-body-secondary mb-3 bgl-flt-status" aria-live="polite">Showing all 12 photos</p>
  <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 g-2">
    <div class="col bgl-flt-item" data-bgl-cat="water"><div class="ratio ratio-1x1"><img src="fjord.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="A deep blue fjord between steep mountains, seen from a granite cliff top"></div></div>
    <div class="col bgl-flt-item" data-bgl-cat="land"><div class="ratio ratio-1x1"><img src="red-rock-canyon.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="Red sandstone cliffs glowing orange at sunset above a rocky slope"></div></div>
    <div class="col bgl-flt-item" data-bgl-cat="city"><div class="ratio ratio-1x1"><img src="brick-alley.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="A narrow brick alley with fire escapes and ivy, high-rise towers at the far end"></div></div>
    <div class="col bgl-flt-item" data-bgl-cat="land"><div class="ratio ratio-1x1"><img src="highland-road.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="A narrow road winding below green cliffs under a grey sky"></div></div>
    <div class="col bgl-flt-item" data-bgl-cat="water"><div class="ratio ratio-1x1"><img src="jellyfish.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="An orange jellyfish with long trailing tentacles in deep blue water" loading="lazy"></div></div>
    <div class="col bgl-flt-item" data-bgl-cat="city"><div class="ratio ratio-1x1"><img src="city-skyline.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="A city skyline at sunrise beside a wide lake, seen from above" loading="lazy"></div></div>
    <div class="col bgl-flt-item" data-bgl-cat="land"><div class="ratio ratio-1x1"><img src="granite-valley.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="Granite cliffs above tall pines and a calm river" loading="lazy"></div></div>
    <div class="col bgl-flt-item" data-bgl-cat="water"><div class="ratio ratio-1x1"><img src="forest-waterfall.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="A tall waterfall dropping into a mossy gorge in a pine forest" loading="lazy"></div></div>
    <div class="col bgl-flt-item" data-bgl-cat="land"><div class="ratio ratio-1x1"><img src="mountain-hiker.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="A hiker with a large backpack looking out over hazy mountains" loading="lazy"></div></div>
    <div class="col bgl-flt-item" data-bgl-cat="city"><div class="ratio ratio-1x1"><img src="canal-houses.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="Gabled canal houses with small boats moored along the water" loading="lazy"></div></div>
    <div class="col bgl-flt-item" data-bgl-cat="land"><div class="ratio ratio-1x1"><img src="snow-camp.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="Yellow tents in deep snow behind a stone wall, with snowy peaks beyond" loading="lazy"></div></div>
    <div class="col bgl-flt-item" data-bgl-cat="water"><div class="ratio ratio-1x1"><img src="coast-road.webp" class="object-fit-cover rounded-3" width="800" height="600" alt="Green cliffs with terraced slopes dropping to a calm sea" loading="lazy"></div></div>
  </div>
</section>

Photo albums in tabs

  • nav-tabs
  • card-header-tabs
  • tab-pane fade
  • data-bs-toggle="tab"

Switch albums with the tabs or the arrow keys.

Albums as card header tabs, using the full Bootstrap tab markup (role="tablist", aria-controls, aria-selected and tabpanels with tabindex="0"), so the tab plugin handles arrow-key navigation between albums. Each pane holds its own ratio grid and album details, and the hidden albums keep loading="lazy" so they only download when opened.

View code
<section class="card shadow-sm bgl-tabs" aria-label="Photo albums">
  <div class="card-header">
    <ul class="nav nav-tabs card-header-tabs" role="tablist">
      <li class="nav-item" role="presentation"><button class="nav-link active" id="bgl-tab-coast-tab" data-bs-toggle="tab" data-bs-target="#bgl-tab-coast" type="button" role="tab" aria-controls="bgl-tab-coast" aria-selected="true">Coast</button></li>
      <li class="nav-item" role="presentation"><button class="nav-link" id="bgl-tab-city-tab" data-bs-toggle="tab" data-bs-target="#bgl-tab-city" type="button" role="tab" aria-controls="bgl-tab-city" aria-selected="false">Cities</button></li>
      <li class="nav-item" role="presentation"><button class="nav-link" id="bgl-tab-peak-tab" data-bs-toggle="tab" data-bs-target="#bgl-tab-peak" type="button" role="tab" aria-controls="bgl-tab-peak" aria-selected="false">Mountains</button></li>
    </ul>
  </div>
  <div class="card-body tab-content">
    <div class="tab-pane fade show active" id="bgl-tab-coast" role="tabpanel" aria-labelledby="bgl-tab-coast-tab" tabindex="0">
      <div class="d-flex justify-content-between align-items-baseline gap-2 mb-3"><h2 class="h6 mb-0">Coast and fjords</h2><span class="small text-body-secondary">24 photos</span></div>
      <div class="row row-cols-3 g-2">
        <div class="col"><div class="ratio ratio-4x3"><img src="fjord.webp" class="object-fit-cover rounded-2" width="800" height="600" alt="A deep blue fjord between steep mountains, seen from a granite cliff top"></div></div>
        <div class="col"><div class="ratio ratio-4x3"><img src="coast-road.webp" class="object-fit-cover rounded-2" width="800" height="600" alt="Green cliffs with terraced slopes dropping to a calm sea"></div></div>
        <div class="col"><div class="ratio ratio-4x3"><img src="forest-waterfall.webp" class="object-fit-cover rounded-2" width="800" height="600" alt="A tall waterfall dropping into a mossy gorge in a pine forest"></div></div>
      </div>
    </div>
    <div class="tab-pane fade" id="bgl-tab-city" role="tabpanel" aria-labelledby="bgl-tab-city-tab" tabindex="0">
      <div class="d-flex justify-content-between align-items-baseline gap-2 mb-3"><h2 class="h6 mb-0">City breaks</h2><span class="small text-body-secondary">17 photos</span></div>
      <div class="row row-cols-3 g-2">
        <div class="col"><div class="ratio ratio-4x3"><img src="city-skyline.webp" class="object-fit-cover rounded-2" width="800" height="600" alt="A city skyline at sunrise beside a wide lake, seen from above" loading="lazy"></div></div>
        <div class="col"><div class="ratio ratio-4x3"><img src="canal-houses.webp" class="object-fit-cover rounded-2" width="800" height="600" alt="Gabled canal houses with small boats moored along the water" loading="lazy"></div></div>
        <div class="col"><div class="ratio ratio-4x3"><img src="brick-alley.webp" class="object-fit-cover rounded-2" width="800" height="600" alt="A narrow brick alley with fire escapes and ivy, high-rise towers at the far end" loading="lazy"></div></div>
      </div>
    </div>
    <div class="tab-pane fade" id="bgl-tab-peak" role="tabpanel" aria-labelledby="bgl-tab-peak-tab" tabindex="0">
      <div class="d-flex justify-content-between align-items-baseline gap-2 mb-3"><h2 class="h6 mb-0">Mountains and camps</h2><span class="small text-body-secondary">31 photos</span></div>
      <div class="row row-cols-3 g-2">
        <div class="col"><div class="ratio ratio-4x3"><img src="snow-camp.webp" class="object-fit-cover rounded-2" width="800" height="600" alt="Yellow tents in deep snow behind a stone wall, with snowy peaks beyond" loading="lazy"></div></div>
        <div class="col"><div class="ratio ratio-4x3"><img src="mountain-hiker.webp" class="object-fit-cover rounded-2" width="800" height="600" alt="A hiker with a large backpack looking out over hazy mountains" loading="lazy"></div></div>
        <div class="col"><div class="ratio ratio-4x3"><img src="granite-valley.webp" class="object-fit-cover rounded-2" width="800" height="600" alt="Granite cliffs above tall pines and a calm river" loading="lazy"></div></div>
      </div>
    </div>
  </div>
</section>

Load more photos in batches

  • template
  • importNode
  • aria-live
  • ratio

Click the button to load the next batch.

Only the first four photos are in the live DOM. The rest wait inside a template element, where images do not download, and each click clones the next batch of four into the row with loading="lazy". Focus moves to the first new photo, a live region reports the total, and the button turns into a disabled "All photos loaded" at the end.

View code
<section class="bgl-more" aria-labelledby="bgl-more-title">
  <div class="d-flex justify-content-between align-items-baseline gap-2 mb-3">
    <h2 id="bgl-more-title" class="h5 mb-0">Latest uploads</h2>
    <a href="#" class="small link-secondary">Upload rules</a>
  </div>
  <div id="bgl-more-grid" class="row row-cols-2 row-cols-md-4 g-2">
    <div class="col"><a href="#" class="bgl-more-item ratio ratio-4x3"><img src="highland-road.webp" class="object-fit-cover" width="800" height="600" alt="A narrow road winding below green cliffs under a grey sky"></a></div>
    <div class="col"><a href="#" class="bgl-more-item ratio ratio-4x3"><img src="jellyfish.webp" class="object-fit-cover" width="800" height="600" alt="An orange jellyfish with long trailing tentacles in deep blue water"></a></div>
    <div class="col"><a href="#" class="bgl-more-item ratio ratio-4x3"><img src="brick-alley.webp" class="object-fit-cover" width="800" height="600" alt="A narrow brick alley with fire escapes and ivy, high-rise towers at the far end"></a></div>
    <div class="col"><a href="#" class="bgl-more-item ratio ratio-4x3"><img src="snow-camp.webp" class="object-fit-cover" width="800" height="600" alt="Yellow tents in deep snow behind a stone wall, with snowy peaks beyond"></a></div>
  </div>
  <template id="bgl-more-tpl">
    <div class="col"><a href="#" class="bgl-more-item ratio ratio-4x3"><img src="fjord.webp" class="object-fit-cover" width="800" height="600" alt="A deep blue fjord between steep mountains, seen from a granite cliff top" loading="lazy"></a></div>
    <div class="col"><a href="#" class="bgl-more-item ratio ratio-4x3"><img src="city-skyline.webp" class="object-fit-cover" width="800" height="600" alt="A city skyline at sunrise beside a wide lake, seen from above" loading="lazy"></a></div>
    <div class="col"><a href="#" class="bgl-more-item ratio ratio-4x3"><img src="forest-waterfall.webp" class="object-fit-cover" width="800" height="600" alt="A tall waterfall dropping into a mossy gorge in a pine forest" loading="lazy"></a></div>
    <div class="col"><a href="#" class="bgl-more-item ratio ratio-4x3"><img src="canal-houses.webp" class="object-fit-cover" width="800" height="600" alt="Gabled canal houses with small boats moored along the water" loading="lazy"></a></div>
    <div class="col"><a href="#" class="bgl-more-item ratio ratio-4x3"><img src="red-rock-canyon.webp" class="object-fit-cover" width="800" height="600" alt="Red sandstone cliffs glowing orange at sunset above a rocky slope" loading="lazy"></a></div>
    <div class="col"><a href="#" class="bgl-more-item ratio ratio-4x3"><img src="mountain-hiker.webp" class="object-fit-cover" width="800" height="600" alt="A hiker with a large backpack looking out over hazy mountains" loading="lazy"></a></div>
    <div class="col"><a href="#" class="bgl-more-item ratio ratio-4x3"><img src="granite-valley.webp" class="object-fit-cover" width="800" height="600" alt="Granite cliffs above tall pines and a calm river" loading="lazy"></a></div>
    <div class="col"><a href="#" class="bgl-more-item ratio ratio-4x3"><img src="coast-road.webp" class="object-fit-cover" width="800" height="600" alt="Green cliffs with terraced slopes dropping to a calm sea" loading="lazy"></a></div>
  </template>
  <div class="d-flex flex-column align-items-center gap-2 mt-3">
    <button type="button" class="btn btn-dark bgl-more-btn" aria-controls="bgl-more-grid">Load 4 more photos</button>
    <p class="small text-body-secondary mb-0 bgl-more-status" aria-live="polite">Showing 4 of 12 photos</p>
  </div>
</section>

Before and after comparison slider

  • form-range
  • clip-path
  • badge
  • CSS custom property

Drag across the photo or use the slider.

Two copies of the photo are stacked, and the "before" copy is clipped with clip-path: inset() driven by one custom property. A standard form-range input sets that property, so keyboard and screen reader users get a native slider with an aria-valuetext, and dragging on the photo itself moves the same input through pointer capture.

View code
<figure class="bgl-ba mb-0">
  <div class="bgl-ba-frame" id="bgl-ba-frame">
    <img src="forest-waterfall.webp" class="bgl-ba-img" width="800" height="600" alt="A tall waterfall in a mossy gorge after color grading, with deep greens and crisp contrast">
    <img src="forest-waterfall.webp" class="bgl-ba-img bgl-ba-before" width="800" height="600" alt="The same photo before grading, flat and washed out">
    <span class="badge text-bg-dark bgl-ba-tag bgl-ba-tag-before">Before</span>
    <span class="badge text-bg-light bgl-ba-tag bgl-ba-tag-after">After</span>
    <span class="bgl-ba-line" aria-hidden="true"></span>
  </div>
  <figcaption class="mt-3">
    <label for="bgl-ba-range" class="form-label small fw-semibold mb-1">Compare the raw file and the graded photo</label>
    <input type="range" class="form-range" id="bgl-ba-range" min="0" max="100" value="50" aria-valuetext="50% before, 50% after">
  </figcaption>
</figure>

Dark mode photo wall

  • data-bs-theme
  • form-switch
  • bg-body
  • grid-auto-flow: dense

Use the switch to change the color mode of the wall.

The wall sets data-bs-theme="dark" on its own wrapper, so bg-body, text-body-secondary and the border colors all resolve to the dark palette, and the switch flips that one attribute to show the same wall in light mode. The layout is a CSS grid with dense packing, where wide and tall tiles span two tracks and the small ones fill the gaps.

View code
<section id="bgl-wall" class="bgl-wall bg-body text-body border rounded-4 p-3 p-md-4" data-bs-theme="dark" aria-labelledby="bgl-wall-title">
  <div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
    <div>
      <h2 id="bgl-wall-title" class="h4 mb-0 text-body-emphasis">Places, 2021 to 2026</h2>
      <p class="small text-body-secondary mb-0">A photo wall by Ines Vale</p>
    </div>
    <div class="form-check form-switch mb-0">
      <input class="form-check-input" type="checkbox" role="switch" id="bgl-wall-mode" checked>
      <label class="form-check-label" for="bgl-wall-mode">Dark wall</label>
    </div>
  </div>
  <div class="bgl-wall-grid">
    <a href="#" class="bgl-wall-item bgl-wall-wide"><img src="city-skyline.webp" class="w-100 h-100 object-fit-cover" width="800" height="600" alt="A city skyline at sunrise beside a wide lake, seen from above"></a>
    <a href="#" class="bgl-wall-item bgl-wall-tall"><img src="mountain-hiker.webp" class="w-100 h-100 object-fit-cover" width="800" height="600" alt="A hiker with a large backpack looking out over hazy mountains"></a>
    <a href="#" class="bgl-wall-item"><img src="jellyfish.webp" class="w-100 h-100 object-fit-cover" width="800" height="600" alt="An orange jellyfish with long trailing tentacles in deep blue water"></a>
    <a href="#" class="bgl-wall-item bgl-wall-tall"><img src="brick-alley.webp" class="w-100 h-100 object-fit-cover" width="800" height="600" alt="A narrow brick alley with fire escapes and ivy, high-rise towers at the far end"></a>
    <a href="#" class="bgl-wall-item"><img src="red-rock-canyon.webp" class="w-100 h-100 object-fit-cover" width="800" height="600" alt="Red sandstone cliffs glowing orange at sunset above a rocky slope"></a>
    <a href="#" class="bgl-wall-item bgl-wall-wide"><img src="snow-camp.webp" class="w-100 h-100 object-fit-cover" width="800" height="600" alt="Yellow tents in deep snow behind a stone wall, with snowy peaks beyond" loading="lazy"></a>
    <a href="#" class="bgl-wall-item"><img src="canal-houses.webp" class="w-100 h-100 object-fit-cover" width="800" height="600" alt="Gabled canal houses with small boats moored along the water" loading="lazy"></a>
    <a href="#" class="bgl-wall-item"><img src="granite-valley.webp" class="w-100 h-100 object-fit-cover" width="800" height="600" alt="Granite cliffs above tall pines and a calm river" loading="lazy"></a>
  </div>
</section>

What Is a Bootstrap Gallery?

Nothing inside the Bootstrap framework carries the name, so it works as a pattern more than a package. Thumbnails sit in a responsive grid built from Bootstrap’s classes, and each one opens its full-size image in a modal, a carousel or a lightbox plugin.

Bootstrap 5 also ships without a lightbox, so you either add a third-party library or build on the modal. The second route builds the lightbox on Bootstrap’s own modal with no external dependency.

The grid and thumbnail side uses container, row, col-* or row-cols-*, img-fluid and the ratio helper, with a card or figure around each image if it needs a caption. The viewer is separate: the modal, the carousel, or a third-party lightbox.

Every one of those parts is among the stock Bootstrap components, so a gallery adds no dependency until you pick a lightbox library.

The University of Arizona packages the idea as a Photo Gallery component in Arizona Bootstrap. It sits on Bootstrap’s carousel and modal and offers grid and slider display options. Its docs warn that the markup changed because of breaking changes to carousels and modals in Bootstrap 5.

Watch the version on anything you find in search results. The blueimp Bootstrap Image Gallery plugin lists Bootstrap 3.3.6+ and jQuery 1.11.3+ as requirements (blueimp README). That puts it in the Bootstrap 3 era, so skip it for a Bootstrap 5 build.

As of October 2026, Bootstrap 6.0.0 Alpha 1 (released October 8, 2026) is out with ESM-only JavaScript and renamed classes, while Bootstrap 5 stays supported, with v5.4.0 still planned before an end-of-life date is announced (Bootstrap blog). I’d treat the alpha as a test build for now.

The markup in this article targets Bootstrap 5.3.

How Do Grid Classes Control Gallery Columns?

Set the column count once on the row with row-cols-* classes, then add breakpoint variants so the count rises with screen width. Gutter classes (g-*) set the spacing between thumbnails.

A typical gallery row reads row row-cols-2 row-cols-md-3 row-cols-lg-4 g-3. That gives two thumbnails per row on phones, three from 768 px and four from 992 px.

Each thumbnail then sits in a plain col. Bootstrap’s grid documentation describes row-cols-* as a shortcut set on the parent row, and it beats repeating col-md-4 on every item once the gallery grows past a handful of images.

Under the hood, each thumbnail takes an equal share of the 12-column grid system. With row-cols-4, every item gets the width of three of the twelve columns.

The grid has six breakpoint tiers (xs, sm, md, lg, xl and xxl). The sm tier starts at 576 px, md at 768 px and lg at 992 px, with xl at 1200 px and xxl at 1400 px (Bootstrap 5.3 grid documentation).

These tiers are min-width media queries. A class like row-cols-md-3 applies at md and every tier above it, until a larger tier overrides it.

The g-* classes use the same sizes as margin and padding spacing, so g-3 is a mid-size gap and g-0 removes it.

Images with different aspect ratios leave ragged rows, and grid classes cannot fix that on their own.

How Do You Keep Thumbnails the Same Size and Add Captions?

Wrap each thumbnail in a fixed-ratio box and crop the image to fill it. The caption then goes in a figure element or a card body, depending on how much text each item carries.

Equal Sizes With the Ratio Helper and object-fit

The img-fluid class sets max-width to 100% and height to auto, so an image scales with its column but keeps its own proportions (Bootstrap 5.3 images documentation). Alone, that leaves uneven rows whenever source files differ.

The ratio helper fixes the box instead. Its default classes are ratio-1×1, ratio-4×3, ratio-16×9 and ratio-21×9, and the docs confirm it works on any child element, an img included.

Locking the aspect ratio of the wrapper also reserves the thumbnail’s space before the file loads. A custom value comes from the –bs-aspect-ratio variable, for example 50% for 2×1.

The wrapper is a div with ratio and ratio-4×3. The image goes in as a direct child, which the ratio class sizes automatically, and it takes the object-fit-cover utility class (added in Bootstrap 5.3) to apply object-fit: cover. If you want a frame around it, img-thumbnail adds a rounded 1px border with a little padding and a background.

Cropping has a cost, though. object-fit: cover cuts the edges, so a portrait photo in a 4×3 box loses its top and bottom. Pick the ratio that fits most of the set, or switch to object-fit: contain (the object-fit-contain class) on a neutral background when nothing can be cropped.

Card or Figure for Captions

Both work. It comes down to how much text each thumbnail carries.

A figure pairs the image and caption through figure and figcaption, using the classes figure, figure-img and figure-caption. You add img-fluid by hand, since images in figures have no explicit size (Bootstrap docs).

A card puts card-img-top on the image and the title and text in card-body. Add h-100 to the card and heights equalize across a row.

A Bootstrap card costs more markup per item, but it brings padding, a border and room for a title, a date or a link. For a plain photo gallery, figure is the lighter choice.

There’s also the hover overlay, which sits on the image with absolute positioning. Touch screens have no hover, so show the caption by default on small screens.

The Arizona Photo Gallery sidesteps the problem by keeping captions off the image in its grid viewer (University of Arizona Digital docs).

Modal, Carousel or Lightbox Library: Which Full-Size Viewer Fits?

Use one Bootstrap modal for small galleries that only need enlarge and close. Add a carousel inside it for previous and next, and move to a lightbox library once the gallery needs zoom or video.

ViewerNeedsStrengthConstraint
Modal with carouselBootstrap bundle onlyNo extra download, touch swipeNo built-in zoom
GLightboxNothing, vanilla JSImages, video, iframes, zoom optionExtra asset, about 11 KB gzipped
Lightbox2jQuery 1.7+Original lightbox script, MIT licensejQuery slim build unsupported
FancyboxjQuery (v3)Touch enabled, responsiveCommercial license for commercial sites
PhotoSwipe v5Core, Lightbox and CSS filesTouch gestures, modularWidth and height required per image

The table draws on the Bootstrap carousel docs, the GLightbox npm page, the Lightbox2 project page, the fancyBox README and the PhotoSwipe getting-started docs.

Bootstrap’s own modal documentation lists lightboxes among its intended uses, so a single Bootstrap modal reused for every thumbnail is a legitimate default, not a workaround.

The cleanest setup keeps one dialog in the DOM and has each thumbnail pass its image index through a data attribute. The alternative is one modal per image, which drags a copy of the dialog markup along with every thumbnail, and each copy needs its own id.

Navigation between images comes from a Bootstrap carousel placed in the modal body. It gives arrows, indicators and swipe without a plugin.

My rule of thumb is that under roughly 12 images with no zoom, the plain modal is enough. That threshold is an editorial guideline, not a Bootstrap limit.

Check licenses before choosing. As of October 2026, Lightbox2 and GLightbox are MIT licensed, while fancyBox 3’s README requires a commercial license for commercial applications and the later fancyapps/ui releases fall under the separate Fancyapps UI license agreement.

PhotoSwipe’s docs say every image needs predefined width and height. That is a constraint, but it also keeps layout shift out of the gallery.

How to Build the Modal and Carousel Viewer Step by Step

Bootstrap’s data attributes open the modal, and a short script keeps the carousel on the right slide. Everything else is markup.

  1. Load the Bootstrap 5.3 CSS and bootstrap.bundle.min.js from jsDelivr or npm. The bundle includes Popper and needs no jQuery (Bootstrap README).
  2. Build the thumbnail grid from the grid section. Make each thumbnail a button with data-bs-toggle="modal", data-bs-target="#galleryModal" and a data-index holding its position, starting at 0.
  3. Add one modal with the classes modal and fade, tabindex=”-1″ on the outer element, and a btn-close button with data-bs-dismiss="modal". The tabindex lets focus enter the modal and Escape close it.
  4. Inside the modal, add a carousel with one carousel-item per image. Mark exactly one item active, give each img d-block and w-100, and leave out data-bs-ride.
  5. Add previous and next buttons with data-bs-slide, each with a visually-hidden text label.
  6. Add the sync script below so the carousel jumps to the clicked thumbnail when the modal opens.
  7. Test every thumbnail: start slide, Escape to close, arrow keys between slides.

This is the markup for one thumbnail and the modal.

<button type="button" class="p-0 border-0 bg-transparent" data-bs-toggle="modal" data-bs-target="#galleryModal" data-index="0">
  <img src="thumb-1.jpg" class="img-fluid" alt="Short description of image 1">
</button>

<div class="modal fade" id="galleryModal" tabindex="-1" aria-label="Image viewer">
  <div class="modal-dialog modal-xl modal-dialog-centered">
    <div class="modal-content">
      <button type="button" class="btn-close ms-auto m-2" data-bs-dismiss="modal" aria-label="Close"></button>
      <div id="galleryCarousel" class="carousel slide">
        <div class="carousel-inner">
          <div class="carousel-item active">
            <img src="full-1.jpg" class="d-block w-100" alt="Short description of image 1">
          </div>
          <!-- one carousel-item per image -->
        </div>
        <button class="carousel-control-prev" type="button" data-bs-target="#galleryCarousel" data-bs-slide="prev">
          <span class="carousel-control-prev-icon" aria-hidden="true"></span>
          <span class="visually-hidden">Previous</span>
        </button>

        <button class="carousel-control-next" type="button" data-bs-target="#galleryCarousel" data-bs-slide="next">
          <span class="carousel-control-next-icon" aria-hidden="true"></span>
          <span class="visually-hidden">Next</span>
        </button>
      </div>
    </div>
  </div>
</div>

The sync script is short.

const modalEl = document.getElementById('galleryModal');
const carouselEl = document.getElementById('galleryCarousel');
const carousel = bootstrap.Carousel.getOrCreateInstance(carouselEl);

modalEl.addEventListener('show.bs.modal', (event) => {
  carousel.to(Number(event.relatedTarget.dataset.index));
});

Pass { wrap: false } as the second argument to getOrCreateInstance if the viewer should stop at the first and last image.

Autoplay is the mistake that bites most often. data-bs-ride="carousel" starts cycling on load (the value true waits for the first interaction), and Bootstrap’s carousel documentation points to WCAG 2.2 criterion 2.2.2 (Pause, Stop, Hide) for autoplaying carousels. A viewer should not cycle by itself.

Skip the sync script and the carousel opens on whichever item carries the active class, not the thumbnail that was clicked. A carousel with no active slide renders nothing at all. And if two galleries share a page, each needs its own modal and carousel ids.

How Do You Build a Masonry Gallery in Bootstrap 5?

Bootstrap 5 has no masonry layout of its own. Add the Masonry library to the grid row, or approximate the effect with the CSS multi-column layout.

The change is documented. Bootstrap 5 removed .card-columns in favor of Masonry and moved the demo into its examples section (Bootstrap pull request #28922).

MethodJavaScriptItem orderConstraint
Masonry libraryYesShortest column firstNeeds image sizes at layout time
CSS multi-column layoutNoDown each columnVisual order differs from DOM order
CSS grid-lanesNoFills the emptiest laneSafari 26.4 only, as of October 2026

Bootstrap’s own Masonry example loads masonry-layout 4.2.2 from a CDN and puts data-masonry='{"percentPosition": true }' on the row. The columns keep their Bootstrap classes, and Masonry only repositions them.

The library measures each item before it places it, and an image that has not loaded has no measurable size (Metafizzy, Masonry’s maker). You see it as overlapping items. The Masonry FAQ names imagesLoaded as the fix, which runs the layout once the images report in.

Width and height attributes on each img also give an item a measurable height before the file arrives, so they belong in a masonry grid too.

CSS multi-column layout needs no script. Set column-count or column-width on a wrapper and let the items flow. Content runs down the first column and then into the next (MDN), so the order a keyboard or screen reader user follows no longer matches what the eye reads across.

Support is not the problem, since multi-column layout has been available across browsers since March 2017 (MDN). Add break-inside: avoid to each item so a card does not split across two columns, and see this guide to CSS masonry techniques for the pure-CSS variants.

The newest option is display: grid-lanes, which packs items into whichever lane has the most free space. Safari 26.4, released on 2026-03-24, is the only browser listed as supporting it (Web Platform DX feature explorer). Wrap it in @supports and keep a normal grid underneath.

For a Bootstrap gallery, the Masonry library is still the practical pick. CSS columns work when reading order does not matter, and grid-lanes is an enhancement for now, not a base layer.

How to Filter a Bootstrap Gallery by Category

Tag each gallery item with a data-category attribute, wire category buttons to show or hide matching items, and let the row reflow. Add Isotope only when you also want animated reflow, sorting or combined filters.

The plain version puts data-category="nature" on each col and adds one button per category plus an All button, grouped in a btn-group. A short loop over the items toggles d-none whenever the category does not match.

The grid is built on flexbox, so hiding a col with d-none closes the gap by itself. Build the controls from Bootstrap’s button classes and mark the selected one with active.

With Masonry in the mix, call its layout method after every toggle, because the library positions items itself.

Isotope filters and sorts with animated reflow, and its masonry layout mode is built in. Its license page lists GPLv3 as the open source license and points commercial sites, themes and applications to a paid commercial license (checked October 2026).

That license cost is the main reason to start with the plain approach. A category filter over a few dozen items is a short loop.

Filtering does break the viewer, though. The carousel inside the modal still holds every slide, so if you filter the grid to one category, open an image and press next, the carousel steps into images the visitor filtered out.

Rebuild the carousel items from the visible thumbnails whenever the filter changes, then renumber data-index so each thumbnail still points at its own slide.

Hidden is not removed, either. Eager images in a filtered-out category still download, so keep loading=”lazy” on every thumbnail outside the first row.

How to Make a Bootstrap Gallery Load Fast

Most of the speed comes from lazy loading below the first row, explicit image dimensions, responsive sources in modern formats, and separate thumbnail and full-size files.

Lazy Loading Without Hurting LCP

In HTTP Archive data analyzed by Google’s web.dev team, pages using browser-level lazy loading had a median 75th percentile LCP of 3,546 ms, against 2,922 ms for pages without it.

That is correlation, and the article says so. It also calls lazy loading an effective way to cut unneeded image bytes while warning that overuse hurts performance, which is the tension a gallery has to manage.

Leave the first row eager, with no loading attribute. If one image is the largest above the fold, give it fetchpriority=”high” and never lazy-load it. Everything else gets loading=”lazy”, the full-size slides in the modal included.

Shopify’s theme performance best practices put “never lazy-load the LCP image” among the essential, high-impact rules for themes. A gallery that puts loading=”lazy” on every thumbnail breaks it.

Layout Shift and Reserved Space

Reserve the space or the page jumps.

web.dev’s layout shift guidance says to put width and height attributes on every image and video, or to reserve the space with CSS aspect-ratio (updated February 2025). Google’s Core Web Vitals thresholds rate a CLS of 0.1 or less as good.

The ratio helper already reserves each thumbnail’s box, so the attributes matter most on carousel slides and on any image outside a ratio wrapper.

A neutral background on the ratio box, or skeleton screens for larger sets, gives the grid something to show while files arrive.

Responsive Sources and Modern Formats

<picture>
  <source type="image/avif" srcset="thumb-480.avif 480w, thumb-800.avif 800w" sizes="(min-width: 992px) 25vw, (min-width: 768px) 33vw, 50vw">
    <source type="image/webp" srcset="thumb-480.webp 480w, thumb-800.webp 800w" sizes="(min-width: 992px) 25vw, (min-width: 768px) 33vw, 50vw">
      <img src="thumb-480.jpg" srcset="thumb-480.jpg 480w, thumb-800.jpg 800w" sizes="(min-width: 992px) 25vw, (min-width: 768px) 33vw, 50vw" width="800" height="600" class="img-fluid" loading="lazy" alt="Short description">
    </picture>

The sizes values follow the row-cols example: 50vw on phones, 33vw from md, 25vw from lg. Bootstrap’s docs say to put img-* classes on the img, not on the picture element, and the img should keep a JPEG fallback for browsers that skip both sources.

Serve full-size files only to the viewer, so 40 thumbnails never pull 40 large photos.

How to Make a Bootstrap Gallery Accessible

Write alt text that describes each image’s content, build thumbnails as buttons or links, and test the viewer with a keyboard alone. Focus moves into the modal on open, stays inside, and returns to the clicked thumbnail on close.

Basic web accessibility work on a gallery starts with alt text, which is where most sites fail. WebAIM’s 2026 Million report found missing alt text on 53.1% of the top million home pages, and 10.8% of images that did have alt text carried questionable or repetitive text, such as a file name or a copy of the adjacent text.

For a gallery, that means describing what the image shows, not that it is an image. Don’t repeat the figcaption word for word, since screen readers read both. Use alt=”” only for purely decorative images, and give the full-size slide the same alt as its thumbnail.

On focus and naming, the W3C’s WAI-ARIA Authoring Practices say a modal dialog contains its Tab sequence, returns focus to the invoking element on close, and needs a name from aria-labelledby or aria-label.

Bootstrap’s modal script adds the dialog role and moves focus itself (Bootstrap documentation). That works only if thumbnails are real buttons, because a bare img with a click handler gives focus nothing to return to.

The carousel pattern in the same guide groups each slide with aria-roledescription=”slide” and a label such as “3 of 12”, so screen reader users know where they are. The stock Bootstrap carousel markup carries no slide labels, so add them once the gallery grows past a handful of images.

Use ARIA to fill gaps, not to replace native elements. WebAIM found that home pages with ARIA present averaged 59.1 errors against 42 for pages without it, which is correlation, but it matches the advice to prefer button and a elements over divs with roles.

Then run a keyboard test.

  • Tab to a thumbnail, press Enter, and check that focus lands inside the modal
  • Press Tab repeatedly and confirm focus stays inside until the modal closes
  • Press Escape and confirm focus returns to the same thumbnail
  • Run NVDA or VoiceOver over one open slide and confirm the alt text and position are announced

When a Bootstrap Gallery Is the Wrong Tool

The native setup gets awkward in a few places, and each one has a workaround.

Large image sets hit a limit first. Lighthouse warns when the body holds more than about 800 nodes and reports an error above about 1,400 (Chrome for Developers).

In the markup built in this article, every image costs six elements: a col, a ratio box, a button and an img for the thumbnail, plus a carousel-item and an img for the slide. That puts the 800-node warning near 133 images, before the header and footer count.

Chrome’s guidance is to create nodes only when they are needed. Load thumbnails in batches with an Ajax request, and build carousel slides when the modal opens.

Product galleries are a different animal. A store page switches one large main image from a thumbnail strip and needs zoom, which is not the same pattern as a grid that opens a viewer. Start from a library built for that job instead of bending the grid.

Version mismatches cause the quietest failures. Bootstrap 4 triggers used data-toggle and data-target, while Bootstrap 5 uses data-bs-toggle and data-bs-target (compare the 4.0 and 5.3 modal docs). A plugin written for the old attributes opens nothing on Bootstrap 5, and Bootstrap 5 itself no longer needs jQuery, so a jQuery-based plugin adds a dependency you would otherwise skip.

The grid also fixes column widths, so it cannot produce Flickr-style rows where every image has the same height and the row fills the full width exactly. A justified-layout script, or a flex layout driven by each image’s aspect ratio, does that job.

One more case. If the site does not already load Bootstrap, pulling in a framework for one component is the heavier choice, and a plain CSS gallery covers a grid with hover effects on its own.

If two or more of these describe your project, skip the native modal and plan around a dedicated gallery library from the start.

Bootstrap Gallery FAQ

Is jQuery required for a Bootstrap 5 gallery?

No. Bootstrap 5 dropped its jQuery dependency, and the bundle file includes Popper, so the grid, modal and carousel run without it.

The only script a modal-and-carousel gallery needs is the short plain-JavaScript sync handler from the build steps.

Can I reuse Bootstrap 4 gallery code in Bootstrap 5?

Not unchanged. Bootstrap 5 renamed .embed-responsive to .ratio, replaced .no-gutters with .g-0, and removed .card-columns.

Scan a Bootstrap 4 snippet for those three classes first, then check its modal and carousel attributes.

Can a Bootstrap gallery show videos as well as images?

Yes. Put the video embed in a modal and wrap the iframe in the ratio helper, for example ratio ratio-16×9, so it keeps its proportions.

For a mixed set, a lightbox library such as GLightbox opens images, YouTube, Vimeo and self-hosted video from one gallery.

How do I center an incomplete last row?

Add justify-content-center to the row. Flexbox then centers the leftover thumbnails instead of leaving them against the left edge.

Full rows look the same, so the class is safe to keep on every gallery row.

Where can I find free Bootstrap gallery templates?

FreeFrontend lists 20+ Bootstrap galleries with live demos, and MDB publishes gallery examples built on Bootstrap 5. Some MDB components need the paid MDB Pro package, so check before copying.

Confirm the Bootstrap version and license of any template first, since many results target Bootstrap 3 or 4.

How do I fill the gallery from JSON or a CMS?

Loop over the records and render one thumbnail and one carousel item per image, with the same index on both so the viewer opens the right slide.

Store the alt text as a field in the data, since each image needs its own description.

What to Build First in a Bootstrap Gallery

Start with the grid, thumbnail dimensions and alt text, then add the native modal and carousel viewer, and bring in a lightbox library last. Layout markup is expensive to change later, while the viewer swaps out cheaply.

  1. Grid with ratio boxes, width and height, alt text
  2. Modal with carousel and sync script
  3. Lightbox library, only when zoom or video appears

That order puts the markup first because filtering, masonry and any new viewer all read from it.

The trade-off is one rewrite. When zoom or video arrives, the viewer block (one modal and one script) gets replaced, while the grid and thumbnails stay untouched.

This sequence assumes Bootstrap 5.3 (checked October 2026) and changes once Bootstrap announces an end-of-life date for version 5.