interactive-dashboard-builder

Self-contained HTML dashboards with Chart.js, filters, and professional styling. Provides complete patterns for KPI cards, line/bar/doughnut charts, sortable data tables, and dropdown or date-range filters that update all visualizations instantly Includes pre-built CSS system with responsive grid layouts, color variables, and print-friendly styling that works without external frameworks Embeds all data and logic directly in a single HTML file; no server or build step required Recommends pre-aggregating data server-side for datasets over 1,000 rows to maintain performance and interactivity

INSTALLATION
npx skills add https://github.com/anthropics/knowledge-work-plugins --skill interactive-dashboard-builder
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

Interactive Dashboard Builder Skill

Patterns and techniques for building self-contained HTML/JS dashboards with Chart.js, filters, interactivity, and professional styling.

HTML/JS Dashboard Patterns

Base Template

Every dashboard follows this structure:

<!DOCTYPE html>

<html lang="en">

<head>

    <meta charset="UTF-8">

    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <title>Dashboard Title</title>

    <script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1" integrity="sha384-jb8JQMbMoBUzgWatfe6COACi2ljcDdZQ2OxczGA3bGNeWe+6DChMTBJemed7ZnvJ" crossorigin="anonymous"></script>

    <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0" integrity="sha384-cVMg8E3QFwTvGCDuK+ET4PD341jF3W8nO1auiXfuZNQkzbUUiBGLsIQUE+b1mxws" crossorigin="anonymous"></script>

    <style>

        /* Dashboard styles go here */

    </style>

</head>

<body>

    <div class="dashboard-container">

        <header class="dashboard-header">

            <h1>Dashboard Title</h1>

            <div class="filters">

                <!-- Filter controls -->

            </div>

        </header>

        <section class="kpi-row">

            <!-- KPI cards -->

        </section>

        <section class="chart-row">

            <!-- Chart containers -->

        </section>

        <section class="table-section">

            <!-- Data table -->

        </section>

        <footer class="dashboard-footer">

            <span>Data as of: <span id="data-date"></span></span>

        </footer>

    </div>

    <script>

        // Embedded data

        const DATA = [];

        // Dashboard logic

        class Dashboard {

            constructor(data) {

                this.rawData = data;

                this.filteredData = data;

                this.charts = {};

                this.init();

            }

            init() {

                this.setupFilters();

                this.renderKPIs();

                this.renderCharts();

                this.renderTable();

            }

            applyFilters() {

                // Filter logic

                this.filteredData = this.rawData.filter(row => {

                    // Apply each active filter

                    return true; // placeholder

                });

                this.renderKPIs();

                this.updateCharts();

                this.renderTable();

            }

            // ... methods for each section

        }

        const dashboard = new Dashboard(DATA);

    </script>

</body>

</html>

KPI Card Pattern

<div class="kpi-card">

    <div class="kpi-label">Total Revenue</div>

    <div class="kpi-value" id="kpi-revenue">$0</div>

    <div class="kpi-change positive" id="kpi-revenue-change">+0%</div>

</div>
function renderKPI(elementId, value, previousValue, format = 'number') {

    const el = document.getElementById(elementId);

    const changeEl = document.getElementById(elementId + '-change');

    // Format the value

    el.textContent = formatValue(value, format);

    // Calculate and display change

    if (previousValue &#x26;&#x26; previousValue !== 0) {

        const pctChange = ((value - previousValue) / previousValue) * 100;

        const sign = pctChange >= 0 ? '+' : '';

        changeEl.textContent = `${sign}${pctChange.toFixed(1)}% vs prior period`;

        changeEl.className = `kpi-change ${pctChange >= 0 ? 'positive' : 'negative'}`;

    }

}

function formatValue(value, format) {

    switch (format) {

        case 'currency':

            if (value >= 1e6) return `$${(value / 1e6).toFixed(1)}M`;

            if (value >= 1e3) return `$${(value / 1e3).toFixed(1)}K`;

            return `$${value.toFixed(0)}`;

        case 'percent':

            return `${value.toFixed(1)}%`;

        case 'number':

            if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;

            if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;

            return value.toLocaleString();

        default:

            return value.toString();

    }

}

Chart Container Pattern

<div class="chart-container">

    <h3 class="chart-title">Monthly Revenue Trend</h3>

    <canvas id="revenue-chart"></canvas>

</div>

Chart.js Integration

Line Chart

function createLineChart(canvasId, labels, datasets) {

    const ctx = document.getElementById(canvasId).getContext('2d');

    return new Chart(ctx, {

        type: 'line',

        data: {

            labels: labels,

            datasets: datasets.map((ds, i) => ({

                label: ds.label,

                data: ds.data,

                borderColor: COLORS[i % COLORS.length],

                backgroundColor: COLORS[i % COLORS.length] + '20',

                borderWidth: 2,

                fill: ds.fill || false,

                tension: 0.3,

                pointRadius: 3,

                pointHoverRadius: 6,

            }))

        },

        options: {

            responsive: true,

            maintainAspectRatio: false,

            interaction: {

                mode: 'index',

                intersect: false,

            },

            plugins: {

                legend: {

                    position: 'top',

                    labels: { usePointStyle: true, padding: 20 }

                },

                tooltip: {

                    callbacks: {

                        label: function(context) {

                            return `${context.dataset.label}: ${formatValue(context.parsed.y, 'currency')}`;

                        }

                    }

                }

            },

            scales: {

                x: {

                    grid: { display: false }

                },

                y: {

                    beginAtZero: true,

                    ticks: {

                        callback: function(value) {

                            return formatValue(value, 'currency');

                        }

                    }

                }

            }

        }

    });

}

Bar Chart

function createBarChart(canvasId, labels, data, options = {}) {

    const ctx = document.getElementById(canvasId).getContext('2d');

    const isHorizontal = options.horizontal || labels.length > 8;

    return new Chart(ctx, {

        type: 'bar',

        data: {

            labels: labels,

            datasets: [{

                label: options.label || 'Value',

                data: data,

                backgroundColor: options.colors || COLORS.map(c => c + 'CC'),

                borderColor: options.colors || COLORS,

                borderWidth: 1,

                borderRadius: 4,

            }]

        },

        options: {

            responsive: true,

            maintainAspectRatio: false,

            indexAxis: isHorizontal ? 'y' : 'x',

            plugins: {

                legend: { display: false },

                tooltip: {

                    callbacks: {

                        label: function(context) {

                            return formatValue(context.parsed[isHorizontal ? 'x' : 'y'], options.format || 'number');

                        }

                    }

                }

            },

            scales: {

                x: {

                    beginAtZero: true,

                    grid: { display: isHorizontal },

                    ticks: isHorizontal ? {

                        callback: function(value) {

                            return formatValue(value, options.format || 'number');

                        }

                    } : {}

                },

                y: {

                    beginAtZero: !isHorizontal,

                    grid: { display: !isHorizontal },

                    ticks: !isHorizontal ? {

                        callback: function(value) {

                            return formatValue(value, options.format || 'number');

                        }

                    } : {}

                }

            }

        }

    });

}

Doughnut Chart

function createDoughnutChart(canvasId, labels, data) {

    const ctx = document.getElementById(canvasId).getContext('2d');

    return new Chart(ctx, {

        type: 'doughnut',

        data: {

            labels: labels,

            datasets: [{

                data: data,

                backgroundColor: COLORS.map(c => c + 'CC'),

                borderColor: '#ffffff',

                borderWidth: 2,

            }]

        },

        options: {

            responsive: true,

            maintainAspectRatio: false,

            cutout: '60%',

            plugins: {

                legend: {

                    position: 'right',

                    labels: { usePointStyle: true, padding: 15 }

                },

                tooltip: {

                    callbacks: {

                        label: function(context) {

                            const total = context.dataset.data.reduce((a, b) => a + b, 0);

                            const pct = ((context.parsed / total) * 100).toFixed(1);

                            return `${context.label}: ${formatValue(context.parsed, 'number')} (${pct}%)`;

                        }

                    }

                }

            }

        }

    });

}

Updating Charts on Filter Change

function updateChart(chart, newLabels, newData) {

    chart.data.labels = newLabels;

    if (Array.isArray(newData[0])) {

        // Multiple datasets

        newData.forEach((data, i) => {

            chart.data.datasets[i].data = data;

        });

    } else {

        chart.data.datasets[0].data = newData;

    }

    chart.update('none'); // 'none' disables animation for instant update

}

Filter and Interactivity Implementation

Dropdown Filter

<div class="filter-group">

    <label for="filter-region">Region</label>

    <select id="filter-region" onchange="dashboard.applyFilters()">

        <option value="all">All Regions</option>

    </select>

</div>
function populateFilter(selectId, data, field) {

    const select = document.getElementById(selectId);

    const values = [...new Set(data.map(d => d[field]))].sort();

    // Keep the "All" option, add unique values

    values.forEach(val => {

        const option = document.createElement('option');

        option.value = val;

        option.textContent = val;

        select.appendChild(option);

    });

}

function getFilterValue(selectId) {

    const val = document.getElementById(selectId).value;

    return val === 'all' ? null : val;

}

Date Range Filter

<div class="filter-group">

    <label>Date Range</label>

    <input type="date" id="filter-date-start" onchange="dashboard.applyFilters()">

    <span>to</span>

    <input type="date" id="filter-date-end" onchange="dashboard.applyFilters()">

</div>
function filterByDateRange(data, dateField, startDate, endDate) {

    return data.filter(row => {

        const rowDate = new Date(row[dateField]);

        if (startDate &#x26;&#x26; rowDate < new Date(startDate)) return false;

        if (endDate &#x26;&#x26; rowDate > new Date(endDate)) return false;

        return true;

    });

}

Combined Filter Logic

applyFilters() {

    const region = getFilterValue('filter-region');

    const category = getFilterValue('filter-category');

    const startDate = document.getElementById('filter-date-start').value;

    const endDate = document.getElementById('filter-date-end').value;

    this.filteredData = this.rawData.filter(row => {

        if (region &#x26;&#x26; row.region !== region) return false;

        if (category &#x26;&#x26; row.category !== category) return false;

        if (startDate &#x26;&#x26; row.date < startDate) return false;

        if (endDate &#x26;&#x26; row.date > endDate) return false;

        return true;

    });

    this.renderKPIs();

    this.updateCharts();

    this.renderTable();

}

Sortable Table

function renderTable(containerId, data, columns) {

    const container = document.getElementById(containerId);

    let sortCol = null;

    let sortDir = 'desc';

    function render(sortedData) {

        let html = '<table class="data-table">';

        // Header

        html += '<thead><tr>';

        columns.forEach(col => {

            const arrow = sortCol === col.field

                ? (sortDir === 'asc' ? ' ▲' : ' ▼')

                : '';

            html += `<th onclick="sortTable('${col.field}')" style="cursor:pointer">${col.label}${arrow}</th>`;

        });

        html += '</tr></thead>';

        // Body

        html += '<tbody>';

        sortedData.forEach(row => {

            html += '<tr>';

            columns.forEach(col => {

                const value = col.format ? formatValue(row[col.field], col.format) : row[col.field];

                html += `<td>${value}</td>`;

            });

            html += '</tr>';

        });

        html += '</tbody></table>';

        container.innerHTML = html;

    }

    window.sortTable = function(field) {

        if (sortCol === field) {

            sortDir = sortDir === 'asc' ? 'desc' : 'asc';

        } else {

            sortCol = field;

            sortDir = 'desc';

        }

        const sorted = [...data].sort((a, b) => {

            const aVal = a[field], bVal = b[field];

            const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;

            return sortDir === 'asc' ? cmp : -cmp;

        });

        render(sorted);

    };

    render(data);

}

CSS Styling for Dashboards

Color System

:root {

    /* Background layers */

    --bg-primary: #f8f9fa;

    --bg-card: #ffffff;

    --bg-header: #1a1a2e;

    /* Text */

    --text-primary: #212529;

    --text-secondary: #6c757d;

    --text-on-dark: #ffffff;

    /* Accent colors for data */

    --color-1: #4C72B0;

    --color-2: #DD8452;

    --color-3: #55A868;

    --color-4: #C44E52;

    --color-5: #8172B3;

    --color-6: #937860;

    /* Status colors */

    --positive: #28a745;

    --negative: #dc3545;

    --neutral: #6c757d;

    /* Spacing */

    --gap: 16px;

    --radius: 8px;

}

Layout

* {

    margin: 0;

    padding: 0;

    box-sizing: border-box;

}

body {

    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;

    background: var(--bg-primary);

    color: var(--text-primary);

    line-height: 1.5;

}

.dashboard-container {

    max-width: 1400px;

    margin: 0 auto;

    padding: var(--gap);

}

.dashboard-header {

    background: var(--bg-header);

    color: var(--text-on-dark);

    padding: 20px 24px;

    border-radius: var(--radius);

    margin-bottom: var(--gap);

    display: flex;

    justify-content: space-between;

    align-items: center;

    flex-wrap: wrap;

    gap: 12px;

}

.dashboard-header h1 {

    font-size: 20px;

    font-weight: 600;

}

KPI Cards

.kpi-row {

    display: grid;

    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));

    gap: var(--gap);

    margin-bottom: var(--gap);

}

.kpi-card {

    background: var(--bg-card);

    border-radius: var(--radius);

    padding: 20px 24px;

    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);

}

.kpi-label {

    font-size: 13px;

    color: var(--text-secondary);

    text-transform: uppercase;

    letter-spacing: 0.5px;

    margin-bottom: 4px;

}

.kpi-value {

    font-size: 28px;

    font-weight: 700;

    color: var(--text-primary);

    margin-bottom: 4px;

}

.kpi-change {

    font-size: 13px;

    font-weight: 500;

}

.kpi-change.positive { color: var(--positive); }

.kpi-change.negative { color: var(--negative); }

Chart Containers

.chart-row {

    display: grid;

    grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));

    gap: var(--gap);

    margin-bottom: var(--gap);

}

.chart-container {

    background: var(--bg-card);

    border-radius: var(--radius);

    padding: 20px 24px;

    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);

}

.chart-container h3 {

    font-size: 14px;

    font-weight: 600;

    color: var(--text-primary);

    margin-bottom: 16px;

}

.chart-container canvas {

    max-height: 300px;

}

Filters

.filters {

    display: flex;

    gap: 12px;

    align-items: center;

    flex-wrap: wrap;

}

.filter-group {

    display: flex;

    align-items: center;

    gap: 6px;

}

.filter-group label {

    font-size: 12px;

    color: rgba(255, 255, 255, 0.7);

}

.filter-group select,

.filter-group input[type="date"] {

    padding: 6px 10px;

    border: 1px solid rgba(255, 255, 255, 0.2);

    border-radius: 4px;

    background: rgba(255, 255, 255, 0.1);

    color: var(--text-on-dark);

    font-size: 13px;

}

.filter-group select option {

    background: var(--bg-header);

    color: var(--text-on-dark);

}

Data Table

.table-section {

    background: var(--bg-card);

    border-radius: var(--radius);

    padding: 20px 24px;

    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);

    overflow-x: auto;

}

.data-table {

    width: 100%;

    border-collapse: collapse;

    font-size: 13px;

}

.data-table thead th {

    text-align: left;

    padding: 10px 12px;

    border-bottom: 2px solid #dee2e6;

    color: var(--text-secondary);

    font-weight: 600;

    font-size: 12px;

    text-transform: uppercase;

    letter-spacing: 0.5px;

    white-space: nowrap;

    user-select: none;

}

.data-table thead th:hover {

    color: var(--text-primary);

    background: #f8f9fa;

}

.data-table tbody td {

    padding: 10px 12px;

    border-bottom: 1px solid #f0f0f0;

}

.data-table tbody tr:hover {

    background: #f8f9fa;

}

.data-table tbody tr:last-child td {

    border-bottom: none;

}

Responsive Design

@media (max-width: 768px) {

    .dashboard-header {

        flex-direction: column;

        align-items: flex-start;

    }

    .kpi-row {

        grid-template-columns: repeat(2, 1fr);

    }

    .chart-row {

        grid-template-columns: 1fr;

    }

    .filters {

        flex-direction: column;

        align-items: flex-start;

    }

}

@media print {

    body { background: white; }

    .dashboard-container { max-width: none; }

    .filters { display: none; }

    .chart-container { break-inside: avoid; }

    .kpi-card { border: 1px solid #dee2e6; box-shadow: none; }

}

Performance Considerations for Large Datasets

Data Size Guidelines

Data Size

Approach

<1,000 rows

Embed directly in HTML. Full interactivity.

1,000 - 10,000 rows

Embed in HTML. May need to pre-aggregate for charts.

10,000 - 100,000 rows

Pre-aggregate server-side. Embed only aggregated data.

>100,000 rows

Not suitable for client-side dashboard. Use a BI tool or paginate.

Pre-Aggregation Pattern

Instead of embedding raw data and aggregating in the browser:

// DON'T: embed 50,000 raw rows

const RAW_DATA = [/* 50,000 rows */];

// DO: pre-aggregate before embedding

const CHART_DATA = {

    monthly_revenue: [

        { month: '2024-01', revenue: 150000, orders: 1200 },

        { month: '2024-02', revenue: 165000, orders: 1350 },

        // ... 12 rows instead of 50,000

    ],

    top_products: [

        { product: 'Widget A', revenue: 45000 },

        // ... 10 rows

    ],

    kpis: {

        total_revenue: 1980000,

        total_orders: 15600,

        avg_order_value: 127,

    }

};

Chart Performance

  • Limit line charts to <500 data points per series (downsample if needed)
  • Limit bar charts to <50 categories
  • For scatter plots, cap at 1,000 points (use sampling for larger datasets)
  • Disable animations for dashboards with many charts: animation: false in Chart.js options
  • Use Chart.update('none') instead of Chart.update() for filter-triggered updates

DOM Performance

  • Limit data tables to 100-200 visible rows. Add pagination for more.
  • Use requestAnimationFrame for coordinated chart updates
  • Avoid rebuilding the entire DOM on filter change -- update only changed elements
// Efficient table pagination

function renderTablePage(data, page, pageSize = 50) {

    const start = page * pageSize;

    const end = Math.min(start + pageSize, data.length);

    const pageData = data.slice(start, end);

    // Render only pageData

    // Show pagination controls: "Showing 1-50 of 2,340"

}
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card