Project: An Admin Dashboard Layout

Data tables, a persistent sidebar, status indicators and a dialog. The interface where dense information has to stay navigable.

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.

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

  1. Validate.
  2. Tab through the whole interface. Can you reach and scroll the table?
  3. Open the dialog with the keyboard, confirm focus is trapped and Escape closes it.
  4. Confirm focus returns to the delete button after the dialog closes.
  5. List the buttons with a screen reader; are the delete buttons distinguishable?
  6. Emulate colour blindness; are the statuses still readable?
  7. 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

  1. Build the layout and operate every control with the keyboard alone.
  2. Give each row action button a unique accessible name and check the button list.
  3. Implement the confirmation dialog and confirm focus returns correctly.
  4. Make the table scrollable by keyboard and announce what is being scrolled.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Software Engineer Notes Management System Administrator

Continue reading

All HTML notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.