Project: An Admin Dashboard Layout
Data tables, a persistent sidebar, status indicators and a dialog. The interface where dense information has to stay navigable.
-
HTML Basics
- What is HTML: The Structure Layer of Every Web Page
- HTML Document Structure: DOCTYPE, html, head and body
- Elements, Tags and Attributes: The Vocabulary of HTML
- HTML Comments: Notes That Ship With Your Code
- Block Level and Inline Elements
- Writing and Running Your First HTML Page
- How a Browser Turns Markup Into a Page
- Text and Formatting
- Links and Navigation
- Images and Media
- Lists
- Tables
-
Forms
- Form Structure: form, action and method
- Input Types: Text, Email, Number, Date and the Rest
- Labels: The Most Important Element in a Form
- Checkboxes, Radio Buttons and Grouping
- select, option, optgroup and datalist
- textarea, File Uploads and Hidden Fields
- Buttons: submit, reset and button
- Built In Form Validation
- GET or POST: What Happens When a Form Is Submitted
- Semantic HTML
- HTML5 Features
- Head and Metadata
- HTML with CSS
- HTML with JavaScript
- Accessibility
-
HTML SEO
- How Google Works: Crawling, Indexing and Ranking
- SEO Friendly HTML Structure
- Titles and Descriptions That Earn Clicks
- Headings and Content Structure for Search
- Internal Linking and Anchor Text
- robots.txt and XML Sitemaps
- Canonical URLs and Duplicate Content
- Structured Data and JSON-LD
- Image SEO
- Core Web Vitals and Mobile Friendliness
- DevTools and Debugging
- Editor Productivity
- HTML Best Practices
- HTML Projects
- Advanced Projects
- Practice and Exams
The brief
Build the shell of an admin interface: a collapsible sidebar, a summary row, a sortable data table with row actions, and a confirmation dialog.
The layout
<body class="app">
<a href="#main-content" class="skip-link">Skip to main content</a>
<header class="app-header">
<button type="button" class="sidebar-toggle"
aria-expanded="true" aria-controls="app-sidebar">
<span aria-hidden="true"></span> Menu
</button>
<a href="/admin" class="logo"><img src="/logo.svg" alt="Riverside Admin" width="120" height="28"></a>
<search>
<form action="/admin/search" method="get" role="search">
<label for="admin-search" class="visually-hidden">Search records</label>
<input type="search" id="admin-search" name="q">
</form>
</search>
<nav aria-label="Account">
<ul>
<li><a href="/admin/profile">Meera Iyer</a></li>
<li><a href="/logout">Sign out</a></li>
</ul>
</nav>
</header>
<nav id="app-sidebar" aria-label="Sections" class="sidebar">
<ul>
<li><a href="/admin" aria-current="page">Overview</a></li>
<li><a href="/admin/applications">Applications</a></li>
<li><a href="/admin/students">Students</a></li>
<li><a href="/admin/settings">Settings</a></li>
</ul>
</nav>
<main id="main-content" tabindex="-1">
<h1>Overview</h1>
<section aria-labelledby="summary-heading">
<h2 id="summary-heading">This week</h2>
<dl class="stats">
<div class="stat"><dt>New applications</dt><dd>142</dd></div>
<div class="stat"><dt>Awaiting review</dt><dd>38</dd></div>
<div class="stat"><dt>Accepted</dt><dd>96</dd></div>
<div class="stat"><dt>Storage used</dt><dd>
<meter value="7.4" min="0" max="10" high="8" optimum="0">7.4 of 10 GB</meter>
7.4 of 10 GB
</dd></div>
</dl>
</section>
<section aria-labelledby="recent-heading">
<h2 id="recent-heading">Recent applications</h2>
<div class="table-scroll" tabindex="0" role="region" aria-labelledby="recent-heading">
<table>
<caption class="visually-hidden">Recent applications, newest first</caption>
<thead>
<tr>
<th scope="col">
<a href="?sort=id" aria-label="Sort by reference">Reference</a>
</th>
<th scope="col">
<a href="?sort=name" aria-label="Sort by name">Name</a>
</th>
<th scope="col">Course</th>
<th scope="col">
<a href="?sort=date" aria-sort="descending">Submitted</a>
</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><a href="/admin/applications/A-1043">A-1043</a></th>
<td>Arun Deshpande</td>
<td>Design</td>
<td><time datetime="2026-08-11T09:14">11 Aug, 09:14</time></td>
<td>
<span class="status status--pending">
<svg aria-hidden="true" width="12" height="12"><use href="#icon-clock"/></svg>
Awaiting review
</span>
</td>
<td>
<a href="/admin/applications/A-1043">Review</a>
<button type="button" data-delete="A-1043">
Delete<span class="visually-hidden"> application A-1043</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<dialog id="confirm-delete" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete this application?</h2>
<p id="confirm-detail"></p>
<p>This cannot be undone.</p>
<form method="dialog">
<button type="submit" value="cancel" autofocus>Keep it</button>
<button type="submit" value="delete" class="danger">Delete</button>
</form>
</dialog>
<p id="app-status" role="status" aria-live="polite" class="visually-hidden"></p>
</body>The decisions
Row action buttons have unique names
<button type="button" data-delete="A-1043">
Delete<span class="visually-hidden"> application A-1043</span>
</button>A table of forty rows each containing a button labelled Delete is useless in a screen reader link or button list - forty identical entries. The hidden text makes each name unique while the visible label stays short.
The table scroll container is focusable
A wide table in an overflow-x container cannot be scrolled by keyboard unless the container is a tab stop. tabindex="0" with role="region" and a label fixes it and announces what is being scrolled.
Status is not colour alone
Each status has an icon and text as well as a colour, so it survives a monochrome display, a colour vision difference and a printed copy.
Sort links are links
Sorting changes what page you are looking at and produces a bookmarkable URL, so it is navigation. aria-sort on the active column tells a screen reader the current order.
Row identifiers are row headers
The reference is th scope="row", so every cell in that row is announced with the application it belongs to.
The dialog
const dialog = document.getElementById("confirm-delete");
const detail = document.getElementById("confirm-detail");
const status = document.getElementById("app-status");
let pendingId = null;
document.querySelector("tbody").addEventListener("click", (event) => {
const button = event.target.closest("button[data-delete]");
if (!button) return;
pendingId = button.dataset.delete;
detail.textContent = `Application ${pendingId} will be removed permanently.`;
dialog.showModal();
});
dialog.addEventListener("close", async () => {
if (dialog.returnValue !== "delete") return;
await deleteApplication(pendingId);
document.querySelector(`[data-row="${pendingId}"]`)?.remove();
status.textContent = `Application ${pendingId} deleted.`;
});showModal() gives focus trapping, an inert background, Escape handling and focus restoration without writing any of it. autofocus is on the safe option, so pressing Enter by reflex does nothing destructive. The result is announced through a live region.
The grid layout
.app {
display: grid;
grid-template-columns: 15rem minmax(0, 1fr);
grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"sidebar main";
min-height: 100vh;
}
.app-header { grid-area: header; position: sticky; top: 0; z-index: 10; }
.sidebar { grid-area: sidebar; }
main { grid-area: main; padding: 1.5rem; }
@media (max-width: 56rem) {
.app {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "header" "main";
}
.sidebar {
position: fixed;
inset: 3.5rem 0 0 0;
z-index: 20;
background: #fff;
}
.sidebar[hidden] { display: none; }
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 1rem;
}
.stat { border: 1px solid #cbd5e1; border-radius: 10px; padding: 1rem; }
.stat dt { font-size: 0.9rem; color: #475569; }
.stat dd { margin: 0; font-size: 1.75rem; font-weight: 600; }
.table-scroll { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; min-width: 48rem; }
thead th { position: sticky; top: 0; background: #f1f5f9; }
td.numeric { text-align: right; font-variant-numeric: tabular-nums; }Checking your work
- Validate.
- Tab through the whole interface. Can you reach and scroll the table?
- Open the dialog with the keyboard, confirm focus is trapped and Escape closes it.
- Confirm focus returns to the delete button after the dialog closes.
- List the buttons with a screen reader; are the delete buttons distinguishable?
- Emulate colour blindness; are the statuses still readable?
- Check the layout at 320 pixels.
Extensions
- Add bulk selection with a select all checkbox using the indeterminate state.
- Add a filter bar that updates the URL.
- Add a toast notification region for background operations.
- Store the sidebar collapsed state and apply it before first paint.
- Add keyboard shortcuts, ignoring key presses inside form fields.
Practice
- Build the layout and operate every control with the keyboard alone.
- Give each row action button a unique accessible name and check the button list.
- Implement the confirmation dialog and confirm focus returns correctly.
- Make the table scrollable by keyboard and announce what is being scrolled.