List Groups & Placeholders
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.
<!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:
| Class | Effect |
|---|---|
active | Highlights the current or selected item. Pair it with aria-current="true" so the state is not shown by colour alone |
list-group-numbered | Adds numbers using a CSS counter, so they line up with Bootstrap's padding |
list-group-flush | Removes the outer borders and rounded corners, so the list sits edge to edge inside a card |
list-group-horizontal | Lays 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.
<!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".
<!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.
<!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
btnclasses to list items. They clash with the list-group styles. Uselist-group-item-actioninstead. - Showing selection only with colour. The blue
activerow means nothing to a screen reader; addaria-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-groupwithlist-group-itemchildren builds bordered lists; use<ul>or<ol>for real lists.activeplusaria-current,list-group-numbered,list-group-flushandlist-group-horizontal-{bp}cover most layouts.- Use
list-group-item-actionon<a>or<button>rows, andstretched-linkon labels for big tap areas. data-bs-toggle="list"with atab-contentwrapper turns a list group into accessible tabs.placeholderbars sized withcol-*orw-*, animated withplaceholder-gloworplaceholder-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
<!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?
list-group-item-action is designed for <a> and <button> items; btn classes should not be used.
2. What does adding stretched-link to a checkbox label inside a list item do?
The label's click area is stretched over its positioned parent, so tapping anywhere on the row toggles the box.
3. How do you control the width of a placeholder bar?
Placeholders take their width from column classes, width utilities or an inline style.
4. Why should a skeleton have aria-hidden="true"?
The bars are decoration; announce the loading state with a live region instead.
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
