Project: Admin Dashboard
Time to put the whole course together. In this project you will build Kirana Admin, a small admin dashboard for Meera, who runs a grocery delivery business with customers in Jaipur, Pune, Kochi and Lucknow. Dashboards like this are among the most common Bootstrap jobs: school fee panels, clinic appointment screens, shop back offices and internal company tools all follow the same pattern.
You will build it in six steps, each using components from earlier lessons, and finish with the complete, working page.
What you will build
| Feature | Bootstrap parts | Lesson |
|---|---|---|
| Dark top bar with brand, theme switch and account menu | Navbar, dropdown, tooltip, Bootstrap Icons | Navbar |
| Sidebar that is a column on laptops and a drawer on phones | offcanvas-lg, nav pills | Offcanvas |
| Four summary cards and a target progress bar | Grid, cards, badges, progress | Cards |
| Recent orders table with live search | Table, table-responsive, badges | Tables |
| "New order" form in a pop-up | Modal, floating labels, input group, validation | Modals |
| Confirmation message | Toast | Tooltips, Popovers & Toasts |
| Light and dark theme that is remembered | data-bs-theme, localStorage | Colours & Dark Mode |
All data lives in the page, so the project runs in the Try-it editor. At the end you will see how to connect it to a real server.
Step 1: plan the layout
Before writing code, sketch the screen in boxes. On a laptop:
+-----------------------------------------------------------+
| [=] Kirana Admin [theme] [Meera v] | navbar (sticky)
+------------+----------------------------------------------+
| Dashboard | Dashboard [+ New order] |
| Orders | [Sales] [Orders] [Low stock] [Target ====] | stat cards
| Products | +----------------------------------------+ |
| Customers | | Recent orders [search...] | | table card
| | | #1003 Rohan Jaipur ₹2,450 ... | |
+------------+----------------------------------------------+
col-lg-2 col-lg-10On a phone, the sidebar disappears behind a menu button [=], the stat cards stack in one column, and the table scrolls sideways. Planning this first tells you exactly which classes you need: a container-fluid with a row, a col-lg-2 sidebar and a col-lg-10 main area.
Step 2: the page skeleton
Start with the navbar, the sidebar and an empty main area. The sidebar uses offcanvas-lg: below 992px it is a hidden drawer opened by the menu button, and from 992px it is shown as a normal column (the menu button hides itself with d-lg-none).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kirana Admin: skeleton</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.2/font/bootstrap-icons.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar bg-dark sticky-top" data-bs-theme="dark" aria-label="Top bar">
<div class="container-fluid">
<button class="btn btn-outline-light btn-sm d-lg-none me-2" type="button" data-bs-toggle="offcanvas"
data-bs-target="#sidebar" aria-controls="sidebar" aria-label="Open menu">
<i class="bi bi-list" aria-hidden="true"></i>
</button>
<a class="navbar-brand me-auto" href="#main"><i class="bi bi-shop me-1" aria-hidden="true"></i>Kirana Admin</a>
</div>
</nav>
<div class="container-fluid">
<div class="row">
<aside class="col-lg-2 p-0 border-end bg-body-tertiary">
<div class="offcanvas-lg offcanvas-start bg-body-tertiary" tabindex="-1" id="sidebar" aria-labelledby="sidebarTitle">
<div class="offcanvas-header">
<h2 class="offcanvas-title h5" id="sidebarTitle">Menu</h2>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#sidebar" aria-label="Close"></button>
</div>
<div class="offcanvas-body p-0 pt-lg-3">
<nav class="w-100" aria-label="Sections">
<ul class="nav nav-pills flex-column px-3 gap-1">
<li class="nav-item"><a class="nav-link active" aria-current="page" href="#main"><i class="bi bi-speedometer2 me-2" aria-hidden="true"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="#orders"><i class="bi bi-receipt me-2" aria-hidden="true"></i>Orders</a></li>
<li class="nav-item"><a class="nav-link" href="#products"><i class="bi bi-box-seam me-2" aria-hidden="true"></i>Products</a></li>
</ul>
</nav>
</div>
</div>
</aside>
<main class="col-lg-10 px-md-4 py-3" id="main" tabindex="-1">
<h1 class="h3">Dashboard</h1>
<p>The result panel is narrower than 992px, so the sidebar is a drawer: click the menu button.</p>
</main>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>Two details are easy to miss. The close button inside a responsive offcanvas needs data-bs-target="#sidebar", because the panel's class is offcanvas-lg, not offcanvas. And the sidebar links are wrapped in their own <nav> with a different aria-label from the top bar, so screen-reader users can tell the two navigation areas apart.
Step 3: stat cards and a progress bar
Inside <main>, add a header row with the page title and a "New order" button, then a grid of four cards. row-cols-1 row-cols-sm-2 row-cols-xl-4 gives one card per row on phones, two on tablets and four on wide screens, and h-100 keeps them the same height.
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Dashboard</h1>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#orderModal">
<i class="bi bi-plus-lg me-1" aria-hidden="true"></i>New order
</button>
</div>
<div class="row row-cols-1 row-cols-sm-2 row-cols-xl-4 g-3 mb-4">
<div class="col">
<div class="card h-100"><div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-currency-rupee fs-2 text-primary" aria-hidden="true"></i>
<div><p class="small text-body-secondary mb-0">Today's sales</p><p class="fs-4 fw-semibold mb-0" id="statSales">₹18,400</p></div>
</div></div>
</div>
<!-- ...two more cards in the same pattern... -->
<div class="col">
<div class="card h-100"><div class="card-body">
<p class="small text-body-secondary mb-1">Monthly target: ₹5,00,000</p>
<div class="progress" role="progressbar" aria-label="Monthly target reached" aria-valuenow="64" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar bg-success" style="width: 64%">64%</div>
</div>
</div></div>
</div>
</div>The numbers that will change later get an id (statSales, statOrders) so JavaScript can update them. The "Low stock" card uses text-warning-emphasis for its icon instead of text-warning, which keeps enough contrast in both themes.
Step 4: the orders table with live search
Put the table in a card, with the search box in the card header. The table-responsive wrapper lets the table scroll sideways on phones, and mb-0 removes the gap at the bottom of the card.
<div class="card" id="orders">
<div class="card-header d-flex flex-wrap gap-2 justify-content-between align-items-center">
<h2 class="h6 mb-0">Recent orders</h2>
<input type="search" class="form-control form-control-sm w-auto" id="search" placeholder="Search orders" aria-label="Search orders">
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr><th scope="col">Order</th><th scope="col">Customer</th><th scope="col">City</th><th scope="col" class="text-end">Amount</th><th scope="col">Status</th></tr>
</thead>
<tbody id="ordersBody">
<tr><td>#1003</td><td>Rohan Gupta</td><td>Jaipur</td><td class="text-end">₹2,450</td><td><span class="badge text-bg-success">Delivered</span></td></tr>
<!-- more rows -->
</tbody>
</table>
</div>
<p class="card-body py-2 mb-0 small text-body-secondary" id="noResults" hidden>No orders match your search.</p>
</div>The search code hides every row that does not contain the typed text, and shows a message when nothing matches:
const search = document.getElementById("search");
const body = document.getElementById("ordersBody");
search.addEventListener("input", () => {
const term = search.value.trim().toLowerCase();
let shown = 0;
for (const row of body.rows) {
row.hidden = !row.textContent.toLowerCase().includes(term);
if (!row.hidden) shown++;
}
document.getElementById("noResults").hidden = shown > 0;
});The input event fires on every keystroke, so results update as Meera types "pune" or "#1002".
Step 5: the "New order" modal with validation
The modal's modal-content is a <form> with novalidate, so the Bootstrap validation styles replace the browser's bubbles. It combines three lessons: floating labels for the name and city, an input group for the rupee amount, and invalid-feedback messages.
<div class="modal fade" id="orderModal" tabindex="-1" aria-labelledby="orderModalTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" id="orderForm" novalidate>
<div class="modal-header">
<h2 class="modal-title fs-5" id="orderModalTitle">New order</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="form-floating mb-3">
<input type="text" class="form-control" id="customer" placeholder="Customer" required minlength="3">
<label for="customer">Customer name</label>
<div class="invalid-feedback">Enter at least 3 letters.</div>
</div>
<!-- city select (form-floating) and the ₹ amount input group go here -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save order</button>
</div>
</form>
</div>
</div>The submit handler checks the form, and only when it is valid builds a new table row, updates the stats, closes the modal and shows a toast:
const rupees = (n) => "₹" + n.toLocaleString("en-IN");
form.addEventListener("submit", (event) => {
event.preventDefault();
if (!form.checkValidity()) {
form.classList.add("was-validated");
form.querySelector(":invalid").focus();
return;
}
const row = body.insertRow(0); // new row at the top
row.insertCell().textContent = "#" + nextId; // textContent, never innerHTML, for user input
// ...customer, city, amount and a "New" badge...
sales += amount;
document.getElementById("statSales").textContent = rupees(sales);
bootstrap.Modal.getInstance(modalEl).hide();
bootstrap.Toast.getOrCreateInstance(document.getElementById("savedToast")).show();
});toLocaleString("en-IN") formats numbers the Indian way, with lakh and crore grouping: 125000 becomes 1,25,000. Every value typed by the user goes into the page with textContent, so a customer name like <script> is shown as text and can never run as code.
When the modal opens again, the show.bs.modal event resets the form and removes was-validated, so old red borders do not reappear.
Step 6: toast, tooltip and dark mode
The toast sits in a fixed toast-container in the bottom-right corner with role="status", so screen readers announce "Order saved" without moving focus. The theme button in the navbar has a tooltip, because it shows only an icon, and an aria-label for screen readers. The theme itself is set by a tiny script in the <head>, before the page is drawn, exactly as in Colours & Dark Mode:
const savedTheme = localStorage.getItem("theme");
const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.setAttribute("data-bs-theme", savedTheme ?? (prefersDark ? "dark" : "light"));Because the dashboard uses bg-body-tertiary, text-body-secondary, cards and tables (which all follow the colour mode), the whole page switches theme correctly without a single extra line of CSS.
The complete dashboard
Here is the finished page. Run it and try everything: open the menu drawer, search for "pune", add an order with an empty form to see the validation, add a real order, switch the theme, and run it again to see that the theme is remembered. Drag the result panel wider than 992px to see the sidebar become a normal column.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kirana Admin</title>
<script>
const savedTheme = localStorage.getItem("theme");
const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.setAttribute("data-bs-theme", savedTheme ?? (prefersDark ? "dark" : "light"));
</script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.2/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
/* On large screens, let the sidebar column fill the height below the navbar */
@media (min-width: 992px) {
.sidebar-col { min-height: calc(100vh - 56px); }
}
</style>
</head>
<body>
<a class="visually-hidden-focusable position-absolute top-0 start-0 m-2 p-2 bg-body border rounded z-3" href="#main">Skip to main content</a>
<!-- 1. Top navbar -->
<nav class="navbar bg-dark sticky-top" data-bs-theme="dark" aria-label="Top bar">
<div class="container-fluid">
<button class="btn btn-outline-light btn-sm d-lg-none me-2" type="button" data-bs-toggle="offcanvas"
data-bs-target="#sidebar" aria-controls="sidebar" aria-label="Open menu">
<i class="bi bi-list" aria-hidden="true"></i>
</button>
<a class="navbar-brand me-auto" href="#main"><i class="bi bi-shop me-1" aria-hidden="true"></i>Kirana Admin</a>
<button type="button" class="btn btn-outline-light btn-sm me-2" id="themeBtn"
data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="Switch light/dark theme" aria-label="Switch light/dark theme">
<i class="bi bi-circle-half" aria-hidden="true"></i>
</button>
<div class="dropdown">
<button class="btn btn-outline-light btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle me-1" aria-hidden="true"></i>Meera
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#profile">Profile</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#logout">Log out</a></li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid">
<div class="row">
<!-- 2. Sidebar: a column on large screens, a drawer on small ones -->
<aside class="col-lg-2 p-0 border-end bg-body-tertiary sidebar-col">
<div class="offcanvas-lg offcanvas-start bg-body-tertiary" tabindex="-1" id="sidebar" aria-labelledby="sidebarTitle">
<div class="offcanvas-header">
<h2 class="offcanvas-title h5" id="sidebarTitle">Menu</h2>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#sidebar" aria-label="Close"></button>
</div>
<div class="offcanvas-body p-0 pt-lg-3">
<nav class="w-100" aria-label="Sections">
<ul class="nav nav-pills flex-column px-3 gap-1">
<li class="nav-item"><a class="nav-link active" aria-current="page" href="#main"><i class="bi bi-speedometer2 me-2" aria-hidden="true"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="#orders"><i class="bi bi-receipt me-2" aria-hidden="true"></i>Orders</a></li>
<li class="nav-item"><a class="nav-link" href="#products"><i class="bi bi-box-seam me-2" aria-hidden="true"></i>Products</a></li>
<li class="nav-item"><a class="nav-link" href="#customers"><i class="bi bi-people me-2" aria-hidden="true"></i>Customers</a></li>
</ul>
</nav>
</div>
</div>
</aside>
<!-- 3. Main content -->
<main class="col-lg-10 px-md-4 py-3" id="main" tabindex="-1">
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Dashboard</h1>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#orderModal">
<i class="bi bi-plus-lg me-1" aria-hidden="true"></i>New order
</button>
</div>
<!-- Stat cards -->
<div class="row row-cols-1 row-cols-sm-2 row-cols-xl-4 g-3 mb-4">
<div class="col">
<div class="card h-100"><div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-currency-rupee fs-2 text-primary" aria-hidden="true"></i>
<div><p class="small text-body-secondary mb-0">Today's sales</p><p class="fs-4 fw-semibold mb-0" id="statSales">₹18,400</p></div>
</div></div>
</div>
<div class="col">
<div class="card h-100"><div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-bag-check fs-2 text-success" aria-hidden="true"></i>
<div><p class="small text-body-secondary mb-0">Orders today</p><p class="fs-4 fw-semibold mb-0" id="statOrders">3</p></div>
</div></div>
</div>
<div class="col">
<div class="card h-100"><div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-exclamation-triangle fs-2 text-warning-emphasis" aria-hidden="true"></i>
<div><p class="small text-body-secondary mb-0">Low stock items</p><p class="fs-4 fw-semibold mb-0">5 <span class="badge text-bg-warning align-middle fs-6">Reorder</span></p></div>
</div></div>
</div>
<div class="col">
<div class="card h-100"><div class="card-body">
<p class="small text-body-secondary mb-1">Monthly target: ₹5,00,000</p>
<div class="progress" role="progressbar" aria-label="Monthly target reached" aria-valuenow="64" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar bg-success" style="width: 64%">64%</div>
</div>
</div></div>
</div>
</div>
<!-- Orders table -->
<div class="card" id="orders">
<div class="card-header d-flex flex-wrap gap-2 justify-content-between align-items-center">
<h2 class="h6 mb-0">Recent orders</h2>
<input type="search" class="form-control form-control-sm w-auto" id="search" placeholder="Search orders" aria-label="Search orders">
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr><th scope="col">Order</th><th scope="col">Customer</th><th scope="col">City</th><th scope="col" class="text-end">Amount</th><th scope="col">Status</th></tr>
</thead>
<tbody id="ordersBody">
<tr><td>#1003</td><td>Rohan Gupta</td><td>Jaipur</td><td class="text-end">₹2,450</td><td><span class="badge text-bg-success">Delivered</span></td></tr>
<tr><td>#1002</td><td>Isha Verma</td><td>Pune</td><td class="text-end">₹9,800</td><td><span class="badge text-bg-warning">Packed</span></td></tr>
<tr><td>#1001</td><td>Farhan Ali</td><td>Lucknow</td><td class="text-end">₹6,150</td><td><span class="badge text-bg-secondary">New</span></td></tr>
</tbody>
</table>
</div>
<p class="card-body py-2 mb-0 small text-body-secondary" id="noResults" hidden>No orders match your search.</p>
</div>
</main>
</div>
</div>
<!-- 4. New order modal -->
<div class="modal fade" id="orderModal" tabindex="-1" aria-labelledby="orderModalTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" id="orderForm" novalidate>
<div class="modal-header">
<h2 class="modal-title fs-5" id="orderModalTitle">New order</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="form-floating mb-3">
<input type="text" class="form-control" id="customer" placeholder="Customer" required minlength="3" aria-describedby="customerFeedback">
<label for="customer">Customer name</label>
<div class="invalid-feedback" id="customerFeedback">Enter at least 3 letters.</div>
</div>
<div class="form-floating mb-3">
<select class="form-select" id="city" required aria-describedby="cityFeedback">
<option value="">Choose...</option>
<option>Jaipur</option>
<option>Kochi</option>
<option>Lucknow</option>
<option>Pune</option>
</select>
<label for="city">City</label>
<div class="invalid-feedback" id="cityFeedback">Choose a city.</div>
</div>
<label for="amount" class="form-label">Amount</label>
<div class="input-group has-validation">
<span class="input-group-text">₹</span>
<input type="number" class="form-control" id="amount" required min="1" max="100000" aria-describedby="amountFeedback">
<div class="invalid-feedback" id="amountFeedback">Enter an amount from ₹1 to ₹1,00,000.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save order</button>
</div>
</form>
</div>
</div>
<!-- 5. Toast -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div class="toast" id="savedToast" role="status" aria-live="polite" aria-atomic="true">
<div class="toast-header">
<i class="bi bi-check-circle-fill text-success me-2" aria-hidden="true"></i>
<strong class="me-auto">Order saved</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body" id="toastText"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Tooltips are opt-in
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => new bootstrap.Tooltip(el));
// Theme toggle
document.getElementById("themeBtn").addEventListener("click", () => {
const root = document.documentElement;
const next = root.getAttribute("data-bs-theme") === "dark" ? "light" : "dark";
root.setAttribute("data-bs-theme", next);
localStorage.setItem("theme", next);
});
// Search: hide rows that do not contain the text
const search = document.getElementById("search");
const body = document.getElementById("ordersBody");
search.addEventListener("input", () => {
const term = search.value.trim().toLowerCase();
let shown = 0;
for (const row of body.rows) {
row.hidden = !row.textContent.toLowerCase().includes(term);
if (!row.hidden) shown++;
}
document.getElementById("noResults").hidden = shown > 0;
});
// New order: validate, add a row, update the stats, show a toast
const rupees = (n) => "₹" + n.toLocaleString("en-IN");
let nextId = 1004;
let sales = 18400;
let orders = 3;
const form = document.getElementById("orderForm");
const modalEl = document.getElementById("orderModal");
form.addEventListener("submit", (event) => {
event.preventDefault();
if (!form.checkValidity()) {
form.classList.add("was-validated");
form.querySelector(":invalid").focus();
return;
}
const customer = document.getElementById("customer").value.trim();
const city = document.getElementById("city").value;
const amount = Number(document.getElementById("amount").value);
const row = body.insertRow(0);
for (const text of ["#" + nextId, customer, city]) {
row.insertCell().textContent = text; // textContent: user input is never treated as HTML
}
const amountCell = row.insertCell();
amountCell.className = "text-end";
amountCell.textContent = rupees(amount);
const badge = document.createElement("span");
badge.className = "badge text-bg-secondary";
badge.textContent = "New";
row.insertCell().append(badge);
sales += amount;
orders += 1;
document.getElementById("statSales").textContent = rupees(sales);
document.getElementById("statOrders").textContent = orders;
document.getElementById("toastText").textContent = `Order #${nextId} for ${customer} (${rupees(amount)}) was added.`;
nextId += 1;
bootstrap.Modal.getInstance(modalEl).hide();
bootstrap.Toast.getOrCreateInstance(document.getElementById("savedToast")).show();
});
// Start with a clean form every time the modal opens
modalEl.addEventListener("show.bs.modal", () => {
form.reset();
form.classList.remove("was-validated");
});
modalEl.addEventListener("shown.bs.modal", () => document.getElementById("customer").focus());
</script>
</body>
</html>How the pieces fit together
Look at how little custom code there is. The layout, colours, spacing, responsiveness, modal, drawer, dropdown, focus handling and dark mode all come from Bootstrap classes and data attributes. The only custom CSS is one rule that lets the sidebar column fill the screen height on large screens. Your own JavaScript only does what is specific to this app:
- State lives in three variables:
nextId,salesandorders. Each new order updates them and then updates the page. - DOM building uses
insertRow(),insertCell()andcreateElement()withtextContent, which is both safe and readable. - Bootstrap's JavaScript API is used only where the page itself must act:
Modal.getInstance().hide()after saving,Toast.getOrCreateInstance().show(), and creating the tooltip. - Accessibility is built in: a skip link, labelled navigation areas,
aria-labelon icon-only buttons,aria-hiddenon decorative icons, a labelled progress bar and a polite live region for the toast.
Taking it further
In a real business the orders would come from a database. Here is how the project grows:
- Load data from a server. Replace the hard-coded rows with a
fetch("/api/orders")call to a PHP or Node.js back end that reads from MySQL, and build the rows from the JSON. - Save new orders. Send the form data with
fetch(..., { method: "POST" }), and only add the row and show the toast when the server answers with success. Show a red toast if it fails. - Validate on the server too, because browser checks can be bypassed.
- Paginate long lists with Bootstrap's pagination component and
LIMIT/OFFSETon the server. - Customise the theme with Sass so the brand colour matches Meera's logo, and import only the components you use.
Common mistakes
- Sidebar content hidden on phones with no way to open it. The
d-lg-nonemenu button must target the same id as theoffcanvas-lgpanel. - Building rows with
innerHTMLand user input. This allows script injection. UsetextContent. - Forgetting to reset the form. Without the
show.bs.modalreset, the last order's values and red borders appear the next time. - Fixed-colour panels. A
style="background:#fff"panel stays white in dark mode. Usebg-bodyorbg-body-tertiary.
Key points
- Plan the layout in boxes first; it tells you which grid columns and components you need.
offcanvas-lgturns one sidebar into a column on laptops and a drawer on phones.- Cards with
row-cols-*andh-100, tables in cards withtable-responsive, and a modal form with validation cover most admin screens. - Keep your own JavaScript small: state variables, safe DOM updates with
textContent, and Bootstrap's API for modals, toasts and tooltips. - Use colour-mode-aware classes so dark mode works everywhere automatically.
Exercise
Extend the dashboard. Add a "Status" select to the New order modal with the options New, Packed and Delivered, and use it for the new row's badge: text-bg-secondary for New, text-bg-warning for Packed and text-bg-success for Delivered. Also make the "Orders today" card show a small green "+1" badge for two seconds whenever an order is added.
Show solution
Add the select inside modal-body, after the amount group:
<div class="form-floating mt-3">
<select class="form-select" id="status">
<option>New</option>
<option>Packed</option>
<option>Delivered</option>
</select>
<label for="status">Status</label>
</div>Then, in the submit handler, replace the fixed "New" badge code and add the "+1" badge:
const status = document.getElementById("status").value;
const colours = { New: "text-bg-secondary", Packed: "text-bg-warning", Delivered: "text-bg-success" };
const badge = document.createElement("span");
badge.className = "badge " + colours[status];
badge.textContent = status;
row.insertCell().append(badge);
const plus = document.createElement("span");
plus.className = "badge text-bg-success ms-2 fs-6 align-middle";
plus.textContent = "+1";
document.getElementById("statOrders").append(plus);
setTimeout(() => plus.remove(), 2000);An object maps each status to its badge class, so adding a new status later means adding one line. Because statOrders is updated with textContent before the "+1" badge is appended, the badge sits next to the number and setTimeout removes it after two seconds.
Quick Quiz
1. Which class makes the sidebar a normal column on large screens and a drawer on small ones?
Responsive offcanvas classes switch between drawer and inline content at their breakpoint.
2. Why does the project add table rows with textContent instead of innerHTML?
Inserting user input with innerHTML can allow script injection (XSS).
3. What does (125000).toLocaleString("en-IN") return?
The Indian locale groups digits in lakhs and crores.
4. Why does the modal reset the form in the show.bs.modal event?
Resetting the form and removing was-validated gives the user a clean form every time.
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
