C CoolAdmin v3.3.0

Interactive components

CoolAdmin's three live demo apps — Inbox (12-message split-pane reader), Kanban (HTML5 drag-and-drop), and Data Table (sort + search + pagination, no library). Each is a single page with an inline script — copy the pattern to build your own.

Last updated May 22, 2026

CoolAdmin has three interactive demos that aren’t screenshots — they’re working interfaces with real data and real interactions. The implementation pattern is the same across all three: one HTML file + one inline <script> holding the data and the wiring, no shared module or external state.

That makes each demo self-contained and easy to copy. It also means changes to one don’t affect the others — if you delete the inbox, the kanban keeps working.

Inbox — split-pane mail reader

inbox.html is a working email client with 12 messages organized into folders. Click a message to open it in the right pane, mark it read/unread, star it, archive, delete, or reply.

Data shape

The 12 messages are an inline array at the top of the page’s <script>:

const EMAILS = [
  {
    id: 'em1',
    avatar: 'images/icon/avatar-01.jpg',
    sender: 'John Doe',
    email:  'john@acme.co',
    label:  'work',                        // 'work' | 'alert' | 'personal' | 'promo' | 'social' | ''
    subject: 'Q2 roadmap is ready for review',
    preview: "I've added the engineering and design tracks…",
    time:    '12 min',
    date:    'Today, 2:14 PM',
    unread:  true,
    starred: true,
    attachments: [
      { name: 'q2-roadmap.pdf',  size: '2.4 MB', type: 'pdf' },
      { name: 'eng-tracks.xlsx', size: '180 KB', type: 'xls' }
    ],
    body: '<p>Hey,</p><p>…</p>'           // raw HTML for the reader pane
  },
  // …11 more
];

The body field is trusted HTML — it gets innerHTML’d into the reader. The seed data is hand-curated; if you wire to a real backend, sanitize incoming HTML.

Interactions

ActionMechanism
Click messageMarks it read, populates reader pane, highlights row
Star toggleemail.starred = !email.starred, re-render that row
ArchiveRemove from current folder array, push to archived list
DeleteRemove from data array, refresh list
ReplyOpen compose modal prefilled with To, Subject (Re: …), body quote
Mark all as readIterate the visible folder, set unread = false everywhere

All mutations update the in-memory EMAILS array and then re-render the list. No persistence — refresh the page, you’re back to the seed.

Pane layout

Two-column layout managed by .inbox-pane (right pane) and .inbox-list (left pane). On mobile, the panes stack vertically — the layout switches at 768px via _inbox-pane.scss. Selecting a message on mobile shows the reader; a back arrow returns to the list.

Wiring to a real backend

Replace the seed array with a fetch:

async function init() {
  const EMAILS = await fetch('/api/messages').then(r => r.json());
  renderList(EMAILS);
}

For mutations (star, archive, delete, send reply), pair each in-memory update with a corresponding API call:

function toggleStar(email) {
  email.starred = !email.starred;
  renderList(EMAILS);
  fetch(`/api/messages/${email.id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ starred: email.starred })
  });
}

Kanban — HTML5 drag-and-drop board

kanban.html is a working kanban board with four columns (Backlog, In progress, In review, Done), draggable cards, and an “Add card” button per column. Native HTML5 drag-and-drop — no library.

The drag mechanic

Three event listeners on each card and each column:

function setupCard(card) {
  card.addEventListener('dragstart', () => {
    draggedCard = card;
    card.classList.add('is-dragging');
  });
  card.addEventListener('dragend', () => {
    card.classList.remove('is-dragging');
    draggedCard = null;
  });
}

function setupColumn(list) {
  list.addEventListener('dragover', (e) => {
    e.preventDefault();                                  // required to allow drop
    list.classList.add('is-drop-target');
    const after = nextCardAfter(list, e.clientY);
    if (!draggedCard) return;
    if (after == null) list.appendChild(draggedCard);
    else list.insertBefore(draggedCard, after);
  });
  list.addEventListener('dragleave', (e) => {
    if (!list.contains(e.relatedTarget)) list.classList.remove('is-drop-target');
  });
  list.addEventListener('drop', () => {
    list.classList.remove('is-drop-target');
    refreshCounts();
    if (window.toast) window.toast.info('Card moved');
  });
}

Three details worth noting:

  • e.preventDefault() on dragover is required by the HTML5 drag spec to allow a drop. Without it the drop never fires.
  • Live reordering on dragover — the card is moved in the DOM as the user hovers, so the user sees where it’ll land before releasing.
  • nextCardAfter(list, clientY) computes the insertion point by finding the first card whose center is below the cursor. The closest one above the cursor becomes the previous sibling.

Adding a card

The ”+ Add” button at the bottom of each column prompts for a title via window.prompt(), then creates a new card element and wires its drag handlers:

btn.addEventListener('click', () => {
  const title = prompt('Card title:');
  if (!title) return;
  const card = document.createElement('article');
  card.className = 'kanban-card';
  card.draggable = true;
  card.innerHTML = '<div class="kanban-card__labels">…</div><p class="kanban-card__title"></p>…';
  card.querySelector('.kanban-card__title').textContent = title;
  list.appendChild(card);
  setupCard(card);
  refreshCount(col);
  window.toast.success('Card added');
});

For a real app you’d want a proper modal (the prompt() UX is rough), label selection, due-date picker, and assignee picker.

Persistence

The board has no persistence — refresh the page, you’re back to the seed cards. To persist:

const STORAGE_KEY = 'cooladmin.kanban';

function save() {
  const state = [...board.querySelectorAll('.kanban-col')].map((col) => ({
    status: col.dataset.status,
    cards:  [...col.querySelectorAll('.kanban-card')].map((card) => ({
      title:  card.querySelector('.kanban-card__title').textContent,
      labels: [...card.querySelectorAll('.kanban-card__label')].map((l) => l.textContent)
    }))
  }));
  try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (_) {}
}

// Hook into the drop and add handlers:
list.addEventListener('drop', () => { /* …existing… */; save(); });
btn.addEventListener('click', () => { /* …existing… */; save(); });

For backend persistence, swap localStorage for fetch('/api/board', { method: 'PUT', body: JSON.stringify(state) }).

Accessibility note

HTML5 drag-and-drop is not keyboard-accessible by default. The native draggable="true" only fires on mouse and touch — users on keyboards (or assistive tech) can’t move cards. To make the board fully accessible:

  • Add keyboard handlers for picking up, moving, and dropping cards (e.g. Space to pick, arrows to move, Space again to drop)
  • Announce moves via an aria-live region
  • Provide a button-based “Move to column…” menu as a non-drag fallback

The current implementation ships the drag-only path. Libraries like @dnd-kit solve accessibility comprehensively; CoolAdmin prefers zero dependencies at the cost of the accessibility gap.

Data Table — sort, search, pagination, no library

data-table.html is a working table with column sorting, text search, and pagination — all written in vanilla JS, no DataTables.net or similar.

The implementation is ~70 lines of code at the bottom of the page, operating on a static <tbody>:

<table id="dt-table">
  <thead>
    <tr>
      <th data-sort="name">Customer</th>
      <th data-sort="email">Email</th>
      <th data-sort="plan">Plan</th>
      <th data-sort="amount" class="num">MRR</th>
      <th data-sort="status">Status</th>
      <th data-sort="signup">Signed up</th>
    </tr>
  </thead>
  <tbody><!-- 50 rows of demo data --></tbody>
</table>

The data-sort attribute on each <th> declares which row property the column sorts by.

Sort

Click a header → reads data-sort, sorts the in-memory rows array, re-renders the visible page:

const headers = document.querySelectorAll('#dt-table th[data-sort]');
headers.forEach((th) => {
  th.addEventListener('click', () => {
    const key = th.dataset.sort;
    if (state.sortKey === key) {
      state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
    } else {
      state.sortKey = key;
      state.sortDir = 'asc';
    }
    sortRows();
    renderPage();
  });
});

Repeated clicks toggle ascending/descending; clicking a different column resets to ascending. The active column gets a visual indicator (arrow) via a class on the <th>.

A <input> above the table filters rows by substring match across every column:

const search = document.getElementById('dt-search');
search.addEventListener('input', () => {
  state.query = search.value.trim().toLowerCase();
  state.page = 1;
  renderPage();
});

Resets pagination to page 1 on every keystroke. If no rows match, renders an empty-state component.

Pagination

Page size is configurable but defaults to 10. The footer renders a numeric pager + “showing X of Y” info:

function renderPager() {
  const totalPages = Math.ceil(state.filtered.length / state.pageSize);
  // …render Page 1, 2, 3, … with ellipsis for large counts
  // …prev / next buttons
}

Adapting to real data

The table reads its rows from a data-rows JSON blob baked into the HTML at build time. For a real backend, replace the initial state load:

async function init() {
  const r = await fetch('/api/customers');
  state.rows = await r.json();
  state.filtered = state.rows.slice();
  renderPage();
}

For very large datasets (thousands of rows), server-side sort + search + pagination is a better fit — keep the client-side renderer as a thin wrapper that fetches a single page on every navigation.

When to pick which

Use casePick
Email-like list with reader paneAdapt inbox.html
Workflow stages with drag between themAdapt kanban.html
Sortable table with searchAdapt data-table.html
All three feel “too much” for your appPick the one closest, strip what you don’t need

Each demo is one HTML file + one inline script. There’s no shared module or “kanban core” library — copying the file lets you scope changes without worrying about side effects on the other demos.

See also