EthicsComputerCourses
Join training +91 90532 10052

Project: Admin Dashboard with Design Tokens

Tailwind CSS · Lesson 38 of 38 Project 11 min read Updated

Landing pages sell a product; dashboards are where people do their work. Most front-end jobs in India involve admin panels of some kind: school ERPs, clinic software, shop inventory, logistics tracking. In this project you will build the main screen of Vidya School Admin, used by the principal of a school in Rohtak to check students, attendance and fee collection.

The point of this project is not only the look. You will build it the way a product team would: semantic design tokens with a dark mode, one reusable panel style, cards that adapt with container queries, data rendered from arrays with safe text, an accessible table, and a clear path to a production build. Every step uses a lesson from this course, linked as you go.

Step 1: plan the tokens before the layout

A dashboard has many panels, badges and states, so start with semantic tokens (see Design Tokens & Design Systems). Name colours by purpose:

TokenUsed for
canvasThe page background behind panels
surfacePanels, sidebar and header
ink, mutedMain text and secondary text
lineBorders and table dividers
primary, on-primary, primary-softActive navigation, bars, hover backgrounds
success-soft / on-success-soft and similarStatus badge pairs: paid, partial, overdue

The utilities point at plain CSS variables with @theme inline, so dark mode only has to change the variables, never the HTML. This is the same pattern as Theming Multi-Brand Apps, with a mode instead of a brand:

CSS
@custom-variant dark (&:where([data-mode="dark"], [data-mode="dark"] *));

@theme inline {
  --color-canvas: var(--ui-canvas);
  --color-surface: var(--ui-surface);
  --color-ink: var(--ui-ink);
  --color-primary: var(--ui-primary);
  --color-on-primary: var(--ui-on-primary);
  /* ...muted, line, primary-soft and the three badge pairs */
}

:root {
  --ui-canvas: var(--color-slate-100);
  --ui-surface: var(--color-white);
  --ui-ink: var(--color-slate-900);
  --ui-primary: var(--color-indigo-700);
  --ui-on-primary: var(--color-white);
}

[data-mode="dark"] {
  --ui-canvas: var(--color-slate-950);
  --ui-surface: var(--color-slate-900);
  --ui-ink: var(--color-slate-100);
  --ui-primary: var(--color-indigo-300);
  --ui-on-primary: var(--color-slate-950);
}

Notice that in dark mode primary becomes a light indigo and on-primary becomes dark. A dark indigo button on a near-black page would be hard to see, so both halves of the pair flip together.

Add one type role for the big numbers on the cards, using a fluid size from Fluid Type & Responsive Design at Scale:

CSS
@theme {
  --text-kpi: clamp(1.5rem, 1.25rem + 1vw, 2rem);
  --text-kpi--line-height: 1.1;
  --text-kpi--font-weight: 700;
}

Step 2: one panel style

Every box on the dashboard (stat cards, the fee chart, the payments table) shares the same corners, border, background and padding. In a React or Blade project this would be a Panel component (see Reusable Components). In a single HTML page, a small custom utility does the same job and still works with variants:

CSS
@utility panel {
  border-radius: var(--radius-xl);
  border: 1px solid var(--color-line);
  background-color: var(--color-surface);
  padding: --spacing(5);
}

Because it reads --color-line and --color-surface, the panel switches to dark mode by itself. --spacing(5) keeps it on the spacing scale.

Step 3: the app shell

Dashboards use a shell: a sidebar for navigation, a header for search and settings, and a main area. On large screens the sidebar sits in a fixed-width grid column; on phones it is hidden behind a menu button.

HTML
<div class="min-h-dvh lg:grid lg:grid-cols-[15rem_1fr]">
  <aside id="sidebar" class="hidden border-line bg-surface p-4 lg:block lg:border-e">
    <!-- logo and navigation -->
  </aside>
  <div class="min-w-0">
    <header class="sticky top-0 z-10 flex items-center gap-3 border-b border-line bg-surface/90 px-4 py-3 backdrop-blur">
      <!-- menu button, search, dark mode toggle -->
    </header>
    <main id="main" class="space-y-6 p-4 sm:p-6">
      <!-- page content -->
    </main>
  </div>
</div>

Three details matter. min-h-dvh uses the dynamic viewport height, which stays correct when a phone's address bar appears and disappears. min-w-0 on the content column lets wide children, like the table, shrink and scroll instead of stretching the whole grid. bg-surface/90 with backdrop-blur gives the sticky header a frosted look over scrolling content; the opacity modifier works on a token colour just like on a palette colour.

The navigation marks the current page with aria-current="page", and the same attribute drives the active style, so the visual state and the screen-reader state can never disagree:

HTML
<a href="#" aria-current="page" class="block rounded-lg px-3 py-2 font-medium hover:bg-primary-soft focus-visible:outline-2 focus-visible:outline-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary">Dashboard</a>

Step 4: stat cards with container queries

The four stat cards appear in a grid that has one column on phones, two from sm and four from xl. That means a card can be wide at one screen size and narrow at a larger one. A screen breakpoint cannot know this, but a container query can (see Container Queries). Each card is a container, and its inner layout switches to a row only when the card is at least @2xs (18rem) wide. Run this small version and compare the two cards:

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Container query stat card</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
  <style type="text/tailwindcss">
    @theme {
      --text-kpi: clamp(1.5rem, 1.25rem + 1vw, 2rem);
      --text-kpi--line-height: 1.1;
      --text-kpi--font-weight: 700;
    }
  </style>
</head>
<body class="flex flex-wrap items-start gap-4 bg-slate-100 p-6">
  <div class="@container w-56 rounded-xl border border-slate-200 bg-white p-5">
    <div class="flex flex-col gap-1 @2xs:flex-row @2xs:items-end @2xs:justify-between">
      <div><p class="text-sm text-slate-600">Attendance today</p><p class="text-kpi tabular-nums">94%</p></div>
      <p class="text-sm text-slate-600">1,173 present</p>
    </div>
  </div>
  <div class="@container w-96 rounded-xl border border-slate-200 bg-white p-5">
    <div class="flex flex-col gap-1 @2xs:flex-row @2xs:items-end @2xs:justify-between">
      <div><p class="text-sm text-slate-600">Attendance today</p><p class="text-kpi tabular-nums">94%</p></div>
      <p class="text-sm text-slate-600">1,173 present</p>
    </div>
  </div>
</body>
</html>

The markup is identical; only the card width differs. In the finished dashboard, the cards use the row layout at 1024px (two wide columns) and the stacked layout at 1280px (four narrow columns), which a screen breakpoint would get backwards. tabular-nums gives every digit the same width, so numbers line up and do not jump when they change.

Step 5: render data safely

Real dashboards get their numbers from an API. Here the data lives in arrays at the bottom of the page, and a small helper builds elements. Two habits from Reusable Components keep it safe and working:

JavaScript
const statusClasses = {
  paid: 'bg-success-soft text-on-success-soft',
  partial: 'bg-warning-soft text-on-warning-soft',
  overdue: 'bg-danger-soft text-on-danger-soft',
};

const el = (tag, className, text) => {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text !== undefined) node.textContent = text;
  return node;
};

First, the status map contains complete class names, so a real Tailwind build finds them. Second, every piece of data goes in through textContent, so a student name can never inject HTML. Amounts are formatted with toLocaleString('en-IN'), which gives Indian digit grouping such as ₹18,500 and ₹1,00,000.

Step 6: bars and the table

The fee chart is a list of bars whose width comes from data. As in Tailwind with React, Next.js & Vue, the class w-(--pct) is fixed and a CSS variable carries the number:

JavaScript
const bar = el('div', 'h-2 w-(--pct) rounded-full bg-primary motion-safe:transition-[width]');
bar.style.setProperty('--pct', f.pct + '%');

motion-safe: animates width changes only for users who have not asked their system to reduce motion. Each bar also shows its percentage as text, so the information does not depend on seeing the bar.

The payments table uses real table elements with scope="col" headers, right-aligned amounts and dividers from the line token. On phones it is wider than the screen, so it sits in a wrapper that scrolls sideways instead of breaking the page:

HTML
<div class="mt-4 overflow-x-auto">
  <table class="w-full min-w-[32rem] text-left text-sm">
    <!-- thead with scope="col" headers, tbody rendered from data -->
  </table>
</div>

Step 7: dark mode and the accessibility pass

The toggle button sets data-mode on <html>, uses aria-pressed to announce its state, and remembers the choice. Storage can be blocked in private windows, so reading and writing are wrapped in try; if nothing is saved, the page follows the system setting:

JavaScript
const setMode = (mode) => {
  document.documentElement.dataset.mode = mode;
  modeButton.setAttribute('aria-pressed', String(mode === 'dark'));
  try { localStorage.setItem('mode', mode); } catch {}
};

Then check the list from Accessibility with Tailwind: a skip link (sr-only focus:not-sr-only) as the first element; focus-visible: outlines on every link, button and input; a hidden <label> for the search box; sr-only text on the icon-only menu button with aria-expanded; headings for every section (some visually hidden with sr-only); and contrast checked for every token pair in both modes.

The complete page

Here is the whole dashboard. Run it, then try these: resize the result from phone to desktop width, open the menu on a narrow screen, switch to dark mode and reload (the choice is remembered when storage is allowed), and press Tab from the top to see the skip link and focus styles.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Vidya School Admin</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
  <style type="text/tailwindcss">
    @custom-variant dark (&:where([data-mode="dark"], [data-mode="dark"] *));

    @theme inline {
      --color-canvas: var(--ui-canvas);
      --color-surface: var(--ui-surface);
      --color-ink: var(--ui-ink);
      --color-muted: var(--ui-muted);
      --color-line: var(--ui-line);
      --color-primary: var(--ui-primary);
      --color-on-primary: var(--ui-on-primary);
      --color-primary-soft: var(--ui-primary-soft);
      --color-success-soft: var(--ui-success-soft);
      --color-on-success-soft: var(--ui-on-success-soft);
      --color-warning-soft: var(--ui-warning-soft);
      --color-on-warning-soft: var(--ui-on-warning-soft);
      --color-danger-soft: var(--ui-danger-soft);
      --color-on-danger-soft: var(--ui-on-danger-soft);
    }

    @theme {
      --text-kpi: clamp(1.5rem, 1.25rem + 1vw, 2rem);
      --text-kpi--line-height: 1.1;
      --text-kpi--font-weight: 700;
    }

    :root {
      --ui-canvas: var(--color-slate-100);
      --ui-surface: var(--color-white);
      --ui-ink: var(--color-slate-900);
      --ui-muted: var(--color-slate-600);
      --ui-line: var(--color-slate-200);
      --ui-primary: var(--color-indigo-700);
      --ui-on-primary: var(--color-white);
      --ui-primary-soft: var(--color-indigo-50);
      --ui-success-soft: var(--color-green-100);
      --ui-on-success-soft: var(--color-green-800);
      --ui-warning-soft: var(--color-amber-100);
      --ui-on-warning-soft: var(--color-amber-900);
      --ui-danger-soft: var(--color-red-100);
      --ui-on-danger-soft: var(--color-red-800);
    }

    [data-mode="dark"] {
      --ui-canvas: var(--color-slate-950);
      --ui-surface: var(--color-slate-900);
      --ui-ink: var(--color-slate-100);
      --ui-muted: var(--color-slate-400);
      --ui-line: var(--color-slate-700);
      --ui-primary: var(--color-indigo-300);
      --ui-on-primary: var(--color-slate-950);
      --ui-primary-soft: oklch(30% 0.08 277);
      --ui-success-soft: oklch(32% 0.07 150);
      --ui-on-success-soft: var(--color-green-100);
      --ui-warning-soft: oklch(34% 0.07 75);
      --ui-on-warning-soft: var(--color-amber-100);
      --ui-danger-soft: oklch(32% 0.09 25);
      --ui-on-danger-soft: var(--color-red-100);
    }

    @utility panel {
      border-radius: var(--radius-xl);
      border: 1px solid var(--color-line);
      background-color: var(--color-surface);
      padding: --spacing(5);
    }
  </style>
</head>
<body class="bg-canvas text-ink">
  <a href="#main" class="sr-only rounded-md bg-primary text-on-primary focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-50 focus:px-4 focus:py-2">Skip to main content</a>

  <div class="min-h-dvh lg:grid lg:grid-cols-[15rem_1fr]">
    <aside id="sidebar" class="hidden border-line bg-surface p-4 lg:block lg:border-e">
      <p class="px-3 py-2 text-lg font-bold text-primary">Vidya School</p>
      <nav aria-label="Main" class="mt-4">
        <ul class="space-y-1">
          <li><a href="#" aria-current="page" class="block rounded-lg px-3 py-2 font-medium hover:bg-primary-soft focus-visible:outline-2 focus-visible:outline-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary">Dashboard</a></li>
          <li><a href="#" class="block rounded-lg px-3 py-2 font-medium hover:bg-primary-soft focus-visible:outline-2 focus-visible:outline-primary">Students</a></li>
          <li><a href="#" class="block rounded-lg px-3 py-2 font-medium hover:bg-primary-soft focus-visible:outline-2 focus-visible:outline-primary">Fees</a></li>
          <li><a href="#" class="block rounded-lg px-3 py-2 font-medium hover:bg-primary-soft focus-visible:outline-2 focus-visible:outline-primary">Attendance</a></li>
        </ul>
      </nav>
    </aside>

    <div class="min-w-0">
      <header class="sticky top-0 z-10 flex items-center gap-3 border-b border-line bg-surface/90 px-4 py-3 backdrop-blur">
        <button id="menu" aria-expanded="false" aria-controls="sidebar" class="rounded-lg p-2 hover:bg-primary-soft focus-visible:outline-2 focus-visible:outline-primary lg:hidden">
          <span aria-hidden="true">☰</span><span class="sr-only">Menu</span>
        </button>
        <label for="search" class="sr-only">Search students</label>
        <input id="search" type="search" placeholder="Search students" class="w-full max-w-xs rounded-lg border border-line bg-canvas px-3 py-2 text-sm placeholder:text-muted focus-visible:outline-2 focus-visible:outline-primary">
        <button id="mode" aria-pressed="false" class="ms-auto shrink-0 rounded-lg border border-line px-3 py-2 text-sm font-medium hover:bg-primary-soft focus-visible:outline-2 focus-visible:outline-primary">Dark mode</button>
      </header>

      <main id="main" class="space-y-6 p-4 sm:p-6">
        <div>
          <h1 class="text-2xl font-bold text-balance">Good morning, Mrs. Ananya Verma</h1>
          <p class="text-muted">Rohtak campus, session 2026-27</p>
        </div>

        <section aria-labelledby="stats-title">
          <h2 id="stats-title" class="sr-only">Key numbers</h2>
          <ul id="stats" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"></ul>
        </section>

        <div class="grid gap-6 xl:grid-cols-[2fr_3fr]">
          <section aria-labelledby="fees-title" class="panel">
            <h2 id="fees-title" class="font-semibold">Fees collected by class</h2>
            <ul id="fees" class="mt-4 space-y-3"></ul>
          </section>

          <section aria-labelledby="pay-title" class="panel min-w-0">
            <h2 id="pay-title" class="font-semibold">Recent payments</h2>
            <div class="mt-4 overflow-x-auto">
              <table class="w-full min-w-[32rem] text-left text-sm">
                <thead class="text-muted">
                  <tr class="border-b border-line">
                    <th scope="col" class="py-2 pe-3 font-medium">Student</th>
                    <th scope="col" class="py-2 pe-3 font-medium">Class</th>
                    <th scope="col" class="py-2 pe-3 text-right font-medium">Amount</th>
                    <th scope="col" class="py-2 font-medium">Status</th>
                  </tr>
                </thead>
                <tbody id="payments" class="divide-y divide-line"></tbody>
              </table>
            </div>
          </section>
        </div>
      </main>
    </div>
  </div>

  <script>
    const stats = [
      { label: 'Students', value: '1,248', note: '+32 this month' },
      { label: 'Attendance today', value: '94%', note: '1,173 present' },
      { label: 'Fees collected', value: '₹38.6 lakh', note: '81% of target' },
      { label: 'Pending dues', value: '₹9.1 lakh', note: '214 students' },
    ];
    const fees = [
      { name: 'Class 9', pct: 88 },
      { name: 'Class 10', pct: 92 },
      { name: 'Class 11', pct: 74 },
      { name: 'Class 12', pct: 67 },
    ];
    const payments = [
      { student: 'Aarav Malik', cls: '10-B', amount: 18500, status: 'paid' },
      { student: 'Meera Nair', cls: '12-A', amount: 21000, status: 'partial' },
      { student: 'Farhan Qureshi', cls: '9-C', amount: 16000, status: 'paid' },
      { student: 'Isha Goyal', cls: '11-A', amount: 19500, status: 'overdue' },
    ];
    const statusClasses = {
      paid: 'bg-success-soft text-on-success-soft',
      partial: 'bg-warning-soft text-on-warning-soft',
      overdue: 'bg-danger-soft text-on-danger-soft',
    };

    const el = (tag, className, text) => {
      const node = document.createElement(tag);
      if (className) node.className = className;
      if (text !== undefined) node.textContent = text;
      return node;
    };

    for (const s of stats) {
      const card = el('li', 'panel @container');
      const inner = el('div', 'flex flex-col gap-1 @2xs:flex-row @2xs:items-end @2xs:justify-between');
      const left = el('div');
      left.append(el('p', 'text-sm text-muted', s.label), el('p', 'text-kpi tabular-nums', s.value));
      inner.append(left, el('p', 'text-sm text-muted', s.note));
      card.append(inner);
      document.getElementById('stats').append(card);
    }

    for (const f of fees) {
      const item = el('li');
      const row = el('div', 'flex justify-between text-sm');
      row.append(el('span', '', f.name), el('span', 'font-medium tabular-nums', f.pct + '%'));
      const track = el('div', 'mt-1 h-2 rounded-full bg-primary-soft');
      const bar = el('div', 'h-2 w-(--pct) rounded-full bg-primary motion-safe:transition-[width]');
      bar.style.setProperty('--pct', f.pct + '%');
      track.append(bar);
      item.append(row, track);
      document.getElementById('fees').append(item);
    }

    for (const p of payments) {
      const tr = el('tr');
      tr.append(
        el('td', 'py-2 pe-3 font-medium', p.student),
        el('td', 'py-2 pe-3 text-muted', p.cls),
        el('td', 'py-2 pe-3 text-right tabular-nums', '₹' + p.amount.toLocaleString('en-IN')),
      );
      const cell = el('td', 'py-2');
      cell.append(el('span', 'rounded-full px-2 py-0.5 text-xs font-medium capitalize ' + statusClasses[p.status], p.status));
      tr.append(cell);
      document.getElementById('payments').append(tr);
    }

    const menu = document.getElementById('menu');
    menu.addEventListener('click', () => {
      const open = menu.getAttribute('aria-expanded') === 'true';
      menu.setAttribute('aria-expanded', String(!open));
      document.getElementById('sidebar').classList.toggle('hidden', open);
    });

    const modeButton = document.getElementById('mode');
    const setMode = (mode) => {
      document.documentElement.dataset.mode = mode;
      modeButton.setAttribute('aria-pressed', String(mode === 'dark'));
      try { localStorage.setItem('mode', mode); } catch {}
    };
    let saved = null;
    try { saved = localStorage.getItem('mode'); } catch {}
    setMode(saved ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
    modeButton.addEventListener('click', () => {
      setMode(document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark');
    });
  </script>
</body>
</html>

Step 8: from prototype to product

To ship this as a real product, move it into a build (see Installing Tailwind CSS v4) and organise it as a team would:

  1. Split the stylesheet as in CSS-First Configuration in Depth: tokens.css for the @theme blocks and mode variables, utilities.css for panel, and one app.css that imports them after @import "tailwindcss";.
  2. Turn the repeated pieces (stat card, panel, status badge, nav link) into components in your framework, as shown in Tailwind with React, Next.js & Vue. Give them props such as status="paid" rather than accepting free class strings; where they must accept a className, merge it with tailwind-merge.
  3. Add prettier-plugin-tailwindcss with tailwindStylesheet pointing at app.css, as in Organising Classes & the Prettier Plugin.
  4. Set data-mode in a tiny inline script in <head>, before the CSS paints, so dark-mode users never see a flash of the light theme (the technique is in Dark Mode).
  5. Build, then test at 320px, at 200% zoom, with the keyboard only, and in both modes.
CSS
/* src/styles/app.css */
@import "tailwindcss";
@import "./tokens.css";
@import "./utilities.css";

Ideas to extend the project

  • Add a "Students" page with a filterable table, and keep the filter state in the URL.
  • Add a second brand for another school in the same group by adding a [data-brand="..."] token set.
  • Replace the hand-made menu toggle with a headless disclosure or a <dialog>-based drawer, as discussed in Headless UI, Radix & shadcn/ui.
  • Add a print stylesheet with the print: variant so the principal can print the fee report.

Key points

  • Plan semantic tokens first; dark mode then only changes variables, not markup.
  • Flip both halves of a colour pair in dark mode, and check contrast for every pair.
  • An app shell uses a grid with a fixed sidebar column, min-w-0 on the content column and a sticky header.
  • Container queries let the same card choose its layout from its own width.
  • Render data with textContent, complete class names in maps, and CSS variables for numeric values such as bar widths.
  • aria-current, aria-pressed and aria-expanded both describe state for screen readers and drive Tailwind variants.

Exercise

Extend the dashboard with a fifth stat card, "Teachers present", showing "58 of 61", and a new status waived for the payments table that uses a neutral colour pair. Add the tokens --ui-neutral-soft and --ui-on-neutral-soft for both modes, map them in @theme inline, add the status to the class map, and add a payment row for "Rohan Saini, 12-B, ₹0, waived".

Show solution

Add the two variables to both mode blocks and map them:

CSS
@theme inline {
  --color-neutral-soft: var(--ui-neutral-soft);
  --color-on-neutral-soft: var(--ui-on-neutral-soft);
}

:root {
  --ui-neutral-soft: var(--color-slate-200);
  --ui-on-neutral-soft: var(--color-slate-800);
}

[data-mode="dark"] {
  --ui-neutral-soft: var(--color-slate-700);
  --ui-on-neutral-soft: var(--color-slate-100);
}

Then extend the data and the map:

JavaScript
stats.push({ label: 'Teachers present', value: '58 of 61', note: '3 on leave' });

statusClasses.waived = 'bg-neutral-soft text-on-neutral-soft';

payments.push({ student: 'Rohan Saini', cls: '12-B', amount: 0, status: 'waived' });

Place the pushes before the loops that render the data. The new card reuses the same container-query layout, and the badge gets its colours from the new token pair in both modes. With five cards, the xl:grid-cols-4 grid wraps the fifth card to a new row; change it to xl:grid-cols-5 if you prefer one row.

Quick Quiz

1. Why does the dashboard map utilities to variables with @theme inline and change --ui-* values for dark mode?

2. Why do the stat cards use container queries instead of lg: breakpoints?

3. What does min-w-0 on the content column prevent?

4. How does the fee chart set each bar's width from data?

Learn Tailwind CSS 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