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: '[email protected]',
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
| Action | Mechanism |
|---|---|
| Click message | Marks it read, populates reader pane, highlights row |
| Star toggle | email.starred = !email.starred, re-render that row |
| Archive | Remove from current folder array, push to archived list |
| Delete | Remove from data array, refresh list |
| Reply | Open compose modal prefilled with To, Subject (Re: …), body quote |
| Mark all as read | Iterate 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()ondragoveris 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.
Spaceto pick, arrows to move,Spaceagain to drop) - Announce moves via an
aria-liveregion - 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>.
Search
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 case | Pick |
|---|---|
| Email-like list with reader pane | Adapt inbox.html |
| Workflow stages with drag between them | Adapt kanban.html |
| Sortable table with search | Adapt data-table.html |
| All three feel “too much” for your app | Pick 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
- Architecture — the script load order each demo plugs into
- Pug + SCSS pipeline — how to add a new page like these
- Theming — every component reads from the same token cascade