EthicsComputerCourses
Join training +91 90532 10052

List Groups & Placeholders

Bootstrap · Lesson 15 of 40 Beginner 7 min read Updated

Almost every app shows lists: notifications, settings menus, chat contacts, order items, a school notice board. Bootstrap's list group turns a plain list into a neat, bordered stack of rows that can hold text, badges, links, buttons, checkboxes or even switch between tab panels. Placeholders solve a different problem: while data is still loading, they draw grey bars in the shape of the content, so the page does not jump around when the real text arrives.

In this lesson you will build list groups for real screens, make whole rows clickable in an accessible way, use a list group as tabs, and build a loading skeleton that swaps to real data.

The basic list group

A list group is a container with the class list-group and children with the class list-group-item. Use a real <ul> or <ol> when the content is a list, because screen readers then announce "list, 4 items", which helps users understand the page.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Notice board</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container py-4" style="max-width: 560px">
    <h1 class="h5">Rohtak Public School: notices</h1>
    <ul class="list-group mb-4">
      <li class="list-group-item active" aria-current="true">Annual sports day on 14 November</li>
      <li class="list-group-item d-flex justify-content-between align-items-center">
        Fee deadline for Term 2
        <span class="badge text-bg-danger rounded-pill">2 days</span>
      </li>
      <li class="list-group-item d-flex justify-content-between align-items-center">
        Science exhibition entries
        <span class="badge text-bg-primary rounded-pill">18</span>
      </li>
      <li class="list-group-item text-body-secondary">Old notices are archived</li>
    </ul>

    <h2 class="h6">Exam timetable (numbered)</h2>
    <ol class="list-group list-group-numbered mb-4">
      <li class="list-group-item">Mathematics, Monday</li>
      <li class="list-group-item">English, Wednesday</li>
      <li class="list-group-item">Physics, Friday</li>
    </ol>

    <div class="card">
      <div class="card-header">Class monitors</div>
      <ul class="list-group list-group-flush">
        <li class="list-group-item">Class 9: Ananya</li>
        <li class="list-group-item">Class 10: Farhan</li>
      </ul>
    </div>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

What each class does:

ClassEffect
activeHighlights the current or selected item. Pair it with aria-current="true" so the state is not shown by colour alone
list-group-numberedAdds numbers using a CSS counter, so they line up with Bootstrap's padding
list-group-flushRemoves the outer borders and rounded corners, so the list sits edge to edge inside a card
list-group-horizontalLays items in a row; list-group-horizontal-md does so only from the md breakpoint up
list-group-item-success (and other colours)Tints a single row

Items are flex-friendly, so the common "text on the left, badge on the right" row is just d-flex justify-content-between align-items-center, which you learned in Display & Flex Utilities. Note that list-group-flush and the horizontal classes cannot be combined.

Clickable rows and checkbox lists

When each row leads somewhere, make the items links or buttons and add list-group-item-action. That class gives a hover and focus style that tells users "this is clickable". Use <a> when the row navigates to another page, and <button type="button"> when it does something on the same page. Do not add btn classes to these rows.

For a checklist, put a form-check-input and its <label> inside each item. Adding stretched-link to the label stretches its click area over the whole row, so tapping anywhere on the row ticks the box. That matters on phones, where a 16px checkbox is hard to hit.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Action rows and checklist</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container py-4" style="max-width: 560px">
    <h1 class="h5">Kabir's account</h1>
    <div class="list-group mb-4">
      <a href="#orders" class="list-group-item list-group-item-action">My orders</a>
      <a href="#address" class="list-group-item list-group-item-action">Saved addresses (Pune, Kochi)</a>
      <button type="button" class="list-group-item list-group-item-action list-group-item-warning" id="kyc">
        Complete your KYC to raise the wallet limit
      </button>
      <button type="button" class="list-group-item list-group-item-action" disabled>Gift cards (coming soon)</button>
    </div>

    <h2 class="h6" id="packTitle">Packing list for the Jaipur trip</h2>
    <ul class="list-group" aria-labelledby="packTitle">
      <li class="list-group-item">
        <input class="form-check-input me-2" type="checkbox" id="p1">
        <label class="form-check-label stretched-link" for="p1">Train tickets (PNR printout)</label>
      </li>
      <li class="list-group-item">
        <input class="form-check-input me-2" type="checkbox" id="p2" checked>
        <label class="form-check-label stretched-link" for="p2">Phone charger</label>
      </li>
      <li class="list-group-item">
        <input class="form-check-input me-2" type="checkbox" id="p3">
        <label class="form-check-label stretched-link" for="p3">Sunscreen</label>
      </li>
    </ul>
    <p class="mt-2 small" id="packed" aria-live="polite"></p>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    const boxes = document.querySelectorAll('input[type="checkbox"]');
    const packed = document.getElementById("packed");
    function update() {
      const done = [...boxes].filter((b) => b.checked).length;
      packed.textContent = `${done} of ${boxes.length} packed`;
    }
    boxes.forEach((b) => b.addEventListener("change", update));
    update();
    document.getElementById("kyc").addEventListener("click", () => {
      packed.textContent = "Opening KYC form...";
    });
  </script>
</body>
</html>

A disabled <button> uses the real disabled attribute, so it cannot be clicked or focused. Links have no disabled attribute: adding the disabled class only changes the look, so also add aria-disabled="true" and remove the href if the link must not work.

List groups as tabs

A list group can also act as the menu for tab panels, which is a common layout for settings screens and FAQ pages. Add data-bs-toggle="list" to each item and point its href at a panel inside a tab-content wrapper. Bootstrap's Tab plugin (included in the bundle) then switches the active class on both the item and the panel. Add the tab roles so assistive technology announces "tab 1 of 3".

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>List group tabs</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container py-4">
    <h1 class="h5 mb-3">Kochi Metro: help centre</h1>
    <div class="row g-3">
      <div class="col-md-4">
        <div class="list-group" id="helpTabs" role="tablist">
          <a class="list-group-item list-group-item-action active" id="t-card" data-bs-toggle="list" href="#p-card" role="tab" aria-controls="p-card">Travel card</a>
          <a class="list-group-item list-group-item-action" id="t-qr" data-bs-toggle="list" href="#p-qr" role="tab" aria-controls="p-qr">QR tickets</a>
          <a class="list-group-item list-group-item-action" id="t-lost" data-bs-toggle="list" href="#p-lost" role="tab" aria-controls="p-lost">Lost and found</a>
        </div>
      </div>
      <div class="col-md-8">
        <div class="tab-content border rounded p-3">
          <div class="tab-pane fade show active" id="p-card" role="tabpanel" aria-labelledby="t-card">Recharge your card at any station counter or online.</div>
          <div class="tab-pane fade" id="p-qr" role="tabpanel" aria-labelledby="t-qr">QR tickets are valid for one journey on the day you buy them.</div>
          <div class="tab-pane fade" id="p-lost" role="tabpanel" aria-labelledby="t-lost">Report lost items at the customer care desk.</div>
        </div>
        <p class="small text-body-secondary mt-2" id="log">Showing: Travel card</p>
      </div>
    </div>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    document.querySelectorAll('#helpTabs [data-bs-toggle="list"]').forEach((tab) => {
      tab.addEventListener("shown.bs.tab", (event) => {
        document.getElementById("log").textContent = "Showing: " + event.target.textContent;
      });
    });
  </script>
</body>
</html>

The events are the same as for nav tabs (see Navs, Tabs & Pagination): show.bs.tab and shown.bs.tab fire on the new item, and event.relatedTarget is the previous one. From code, bootstrap.Tab.getOrCreateInstance(item).show() opens a tab.

Placeholders: loading skeletons

When a page fetches data, there is a gap of half a second or more before the content arrives. A blank space feels broken, and a spinner gives no hint of what is coming. A skeleton screen shows grey bars in the shape of the final content instead. In Bootstrap, any element with the class placeholder becomes such a bar. You set its width with a grid class such as col-6 or a utility such as w-75, and its height follows the text size of its parent.

Two classes on the parent add movement: placeholder-glow makes the bars pulse, and placeholder-wave sends a shine across them. placeholder-lg, placeholder-sm and placeholder-xs change the height, and a bg-* class changes the colour. A placeholder button is a btn with disabled placeholder and a width class.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Loading skeleton</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container py-4" style="max-width: 420px">
    <p class="visually-hidden" id="status" aria-live="polite">Loading product</p>
    <div class="card" id="product" aria-busy="true">
      <div class="card-body">
        <div id="skeleton" class="placeholder-glow" aria-hidden="true">
          <h2 class="card-title h5"><span class="placeholder col-7"></span></h2>
          <p class="card-text">
            <span class="placeholder col-10"></span>
            <span class="placeholder col-6"></span>
          </p>
          <span class="btn btn-primary disabled placeholder col-5"></span>
        </div>
        <div id="real" hidden>
          <h2 class="card-title h5">Handloom cotton saree</h2>
          <p class="card-text">Woven in Kerala. Price: ₹1,299 with free delivery to Kochi.</p>
          <button type="button" class="btn btn-primary">Add to cart</button>
        </div>
      </div>
    </div>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    // Pretend the data takes 1.5 seconds to arrive from the server
    setTimeout(() => {
      document.getElementById("skeleton").remove();
      document.getElementById("real").hidden = false;
      document.getElementById("product").setAttribute("aria-busy", "false");
      document.getElementById("status").textContent = "Product loaded";
    }, 1500);
  </script>
</body>
</html>

Placeholders are pure CSS: Bootstrap does not show or hide them for you. The script above does three things you should copy: it hides the bars from screen readers with aria-hidden="true" (they carry no meaning), marks the card with aria-busy while it is loading, and announces "Product loaded" through a polite live region so blind users know the content is ready. See Live Regions for more on announcing changes.

Older code

Bootstrap 3 had a similar list-group, but used badge without colour classes and the list-group-item-heading class, which no longer exists. Bootstrap 4 used badge-primary badge-pill inside rows; in Bootstrap 5 that is text-bg-primary rounded-pill. Placeholders arrived in Bootstrap 5.1, so older projects often contain hand-made skeleton CSS or a plain "Loading..." text.

Common mistakes

  • Adding btn classes to list items. They clash with the list-group styles. Use list-group-item-action instead.
  • Showing selection only with colour. The blue active row means nothing to a screen reader; add aria-current="true".
  • Adding other buttons to a row that has a stretched-link. The stretched click area sits on top of them, so they become hard or impossible to click. Keep one action per row.
  • Leaving placeholders visible to screen readers. Users hear empty items. Add aria-hidden="true" to the skeleton.
  • Forgetting to remove the skeleton when loading fails. Show an error message instead of bars that pulse forever.

Key points

  • list-group with list-group-item children builds bordered lists; use <ul> or <ol> for real lists.
  • active plus aria-current, list-group-numbered, list-group-flush and list-group-horizontal-{bp} cover most layouts.
  • Use list-group-item-action on <a> or <button> rows, and stretched-link on labels for big tap areas.
  • data-bs-toggle="list" with a tab-content wrapper turns a list group into accessible tabs.
  • placeholder bars sized with col-* or w-*, animated with placeholder-glow or placeholder-wave, show where content will appear.
  • Hide skeletons with aria-hidden, and announce when the real content has loaded.

Exercise

Build a "Contacts" screen for a small Lucknow tailoring shop. Show a list group of three customers (Meera, Rohan and Isha) as buttons with list-group-item-action, each with a pill badge on the right showing pending orders. While the page "loads" for one second, show a placeholder version of the list with placeholder-wave instead, then swap in the real list and announce "Contacts loaded" to screen readers.

Show solution
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Contacts</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container py-4" style="max-width: 480px">
    <h1 class="h5">Contacts</h1>
    <p class="visually-hidden" id="status" aria-live="polite">Loading contacts</p>
    <ul class="list-group placeholder-wave" id="skeleton" aria-hidden="true">
      <li class="list-group-item"><span class="placeholder col-6"></span></li>
      <li class="list-group-item"><span class="placeholder col-5"></span></li>
      <li class="list-group-item"><span class="placeholder col-7"></span></li>
    </ul>
    <div class="list-group" id="list" hidden>
      <button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">Meera <span class="badge text-bg-primary rounded-pill">3</span></button>
      <button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">Rohan <span class="badge text-bg-primary rounded-pill">1</span></button>
      <button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">Isha <span class="badge text-bg-primary rounded-pill">2</span></button>
    </div>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    setTimeout(() => {
      document.getElementById("skeleton").remove();
      document.getElementById("list").hidden = false;
      document.getElementById("status").textContent = "Contacts loaded";
    }, 1000);
  </script>
</body>
</html>

The skeleton has the same three rows as the real list, so nothing jumps, and the live region tells screen reader users when the swap happens.

Quick Quiz

1. Which class gives list-group rows that are links or buttons a hover and focus style?

2. What does adding stretched-link to a checkbox label inside a list item do?

3. How do you control the width of a placeholder bar?

4. Why should a skeleton have aria-hidden="true"?

Learn Bootstrap with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call