A KPI dashboard template made with AI, shared as a link

One web page with your KPIs, their targets and a status on each tile. You paste the numbers in and send a link. It is not connected to your data, so you update it by hand.

A KPI dashboard template, in this guide, is one web page: a tile for each KPI with the actual number, the target and a status in words.

You ask an AI chat to write it, paste your numbers in, and share the page as a link.

One thing first: the numbers are typed in. The page does not read your spreadsheet and does not refresh itself. For a weekly or monthly review that is a fair trade, and it is why this takes minutes.

The AI part is easy. The sharing part is where people stall. One post on X puts it plainly: “A lot of my output from Claude is in HTML. But is surprisingly awkward to share HTML files.” A link removes the awkward part.

Here is a finished page. The company and every number are made up.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KPI dashboard - Larkspur Supply Co.</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 16px; font: 16px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: #1d2330; background: #f4f5f7; }
  header { margin-bottom: 12px; }
  h1 { margin: 0 0 4px; font-size: 20px; }
  .meta { margin: 0; font-size: 14px; color: #4b5563; }
  .sample { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 99px; background: #fde68a; color: #78350f; font-size: 12px; font-weight: 700; }
  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; }
  .tile { background: #fff; border: 1px solid #e1e4ea; border-radius: 10px; padding: 12px 14px; }
  .name { font-size: 14px; font-weight: 600; color: #374151; }
  .value { margin: 2px 0 6px; font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; }
  .unit { font-size: 15px; font-weight: 500; color: #4b5563; }
  .track { position: relative; height: 8px; border-radius: 4px; background: #e5e7eb; margin: 8px 0 10px; }
  .fill { height: 100%; border-radius: 4px; }
  .tick { position: absolute; top: -4px; width: 2px; height: 16px; background: #1d2330; }
  .row { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; color: #4b5563; }
  .pill { display: inline-block; margin-top: 8px; padding: 2px 9px; border-radius: 99px; font-size: 13px; font-weight: 700; }
  .ok { background: #dcfce7; color: #14532d; } .ok-fill { background: #16a34a; }
  .watch { background: #fef3c7; color: #78350f; } .watch-fill { background: #d97706; }
  .behind { background: #fee2e2; color: #7f1d1d; } .behind-fill { background: #dc2626; }
  footer { margin-top: 12px; font-size: 13px; color: #4b5563; }
</style>
</head>
<body>
<header>
  <h1>Sales and service KPIs <span class="sample">SAMPLE DATA</span></h1>
  <p class="meta" id="meta"></p>
</header>
<div class="grid" id="grid"></div>
<footer>
  On track: at or better than target. Watch: within 5% of target. Behind: further off than that.
  The numbers were typed in by hand. This page does not read a spreadsheet.
</footer>

<script>
  // Every number on the page lives here. Next month, change this block only.
  const DATA = {
    team: 'Larkspur Supply Co.',
    through: '30 Sep 2026',
    kpis: [
      { name: 'Revenue',                  unit: '$k', actual: 412, last: 388, target: 400, better: 'higher', dec: 0 },
      { name: 'New customers',            unit: '',   actual: 58,  last: 49,  target: 60,  better: 'higher', dec: 0 },
      { name: 'Repeat order rate',        unit: '%',  actual: 34,  last: 36,  target: 40,  better: 'higher', dec: 0 },
      { name: 'Average delivery time',    unit: 'days', actual: 3.1, last: 3.4, target: 3.0, better: 'lower', dec: 1 },
      { name: 'Tickets closed in 24 h',   unit: '%',  actual: 91,  last: 88,  target: 90,  better: 'higher', dec: 0 },
      { name: 'Return rate',              unit: '%',  actual: 4.2, last: 3.9, target: 3.5, better: 'lower', dec: 1 }
    ]
  };
  const WATCH_BAND = 0.05; // within 5% of target counts as Watch

  function status(k) {
    if (k.better === 'higher') {
      if (k.actual >= k.target) return 'ok';
      return k.actual >= k.target * (1 - WATCH_BAND) ? 'watch' : 'behind';
    }
    if (k.actual <= k.target) return 'ok';
    return k.actual <= k.target * (1 + WATCH_BAND) ? 'watch' : 'behind';
  }
  const LABEL = { ok: 'On track', watch: 'Watch', behind: 'Behind' };

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

  document.getElementById('meta').textContent = DATA.team + ' - figures through ' + DATA.through;

  const grid = document.getElementById('grid');
  DATA.kpis.forEach((k) => {
    const s = status(k);
    const max = Math.max(k.actual, k.target) * 1.15;
    const diff = k.actual - k.last;
    const sign = diff > 0 ? '+' : diff < 0 ? '-' : '';
    const tile = el('div', 'tile');
    tile.appendChild(el('div', 'name', k.name));

    const v = el('div', 'value', k.actual.toFixed(k.dec));
    if (k.unit) v.appendChild(el('span', 'unit', ' ' + k.unit));
    tile.appendChild(v);

    const track = el('div', 'track');
    const fill = el('div', 'fill ' + s + '-fill');
    fill.style.width = (k.actual / max * 100) + '%';
    const tick = el('div', 'tick');
    tick.style.left = (k.target / max * 100) + '%';
    track.append(fill, tick);
    tile.appendChild(track);

    const row = el('div', 'row');
    row.append(el('span', '', 'Target ' + k.target.toFixed(k.dec)),
               el('span', '', sign + Math.abs(diff).toFixed(k.dec) + ' vs last month'));
    tile.appendChild(row);
    tile.appendChild(el('span', 'pill ' + s, LABEL[s]));
    grid.appendChild(tile);
  });
</script>
</body>
</html>
Six KPI tiles from one block of numbers. Each shows actual, target, change since last month and a status in words.

Each tile has the KPI name, this month's number, the target, the change since last month, and a status. The status is written out, so it does not depend on colour. The date at the top says which month the figures cover.

What goes on each tile

Keep a tile to what a reader needs to answer one question: are we where we said we would be?

  • Name: short, in the words your team already uses.
  • Actual: this period's number, with its unit.
  • Target: the number you agreed, and whether higher or lower is better.
  • Last period: so a reader sees the direction, not only the level.
  • Status: On track, Watch or Behind, with the rule written at the bottom of the page.

Put the date the figures cover at the top. A dashboard with no date looks current long after it is not. The weekly update version uses the same idea for a status report.

Decide the status rule before you ask the AI. In the example, On track means at or better than target, Watch means within 5% of it, and Behind means further off.

Your team may want other bands. What matters is that the rule is on the page.

Make it with AI

Open the AI chat you already use, such as ChatGPT, Claude or Gemini. Paste the prompt, then your own figures under it.

Make a single-file HTML page: a KPI dashboard for
[team name], [month year].
Use only the numbers I give below. Do not invent, round
or estimate any figure. If something is missing, show "n/a".

Put every number in one DATA block at the top of the
script, so I can change them without touching the layout.
For each KPI show: name, actual, last period, target, and
whether higher or lower is better.

Show a status on each tile, in words as well as colour:
- On track: at or better than target
- Watch: within 5% of target
- Behind: further off than that
Write this rule at the bottom of the page.

Show "Figures through [date]" at the top, and a line saying
the numbers were typed in by hand.
One column on a phone, tiles side by side on a laptop.
Text 16px or larger. No external images, fonts or scripts.
Return only the HTML.

My numbers (KPI | actual | last period | target | higher
or lower is better):
[paste your rows here]

Check the result before you copy it:

  • Every figure matches your sheet. Check all of them, not a sample, because AI can change a digit.
  • Each KPI has the right direction. A cost or a return rate is easy to flip.
  • Two statuses agree with your rule when you work them out by hand.
  • The team name and date are right, and no square-bracket placeholder is left.
  • In a narrow window the tiles stack into one column.

If your figures are confidential, check your company's rules before pasting them into an AI chat. If something is off, say so in the same chat and paste the new version. A longer prompt for a general HTML dashboard covers more layout rules.

Open a NOS document and paste the HTML in. The page renders there with its script running, so the tiles and statuses show straight away. Look at it, then choose Create share link and copy the address.

From your figures to one address: ask, check, paste, share.
From your figures to one address: ask, check, paste, share.

Anyone with the address can open the page in a browser, on a phone too, with no account. They can read it but not edit it. A shared page has no password.

Next month, change the numbers in the code. The link stays the same, so a message, a calendar invite or a slide that already holds it now shows the new figures. HTML to link walks through it.

What a reader sees after tapping the link on a phone.
What a reader sees after tapping the link on a phone.

Paste the link wherever your readers already look: a chat message, the invite for the review meeting, an email or a slide. For a printed pack, turn it into a code with a QR code link.

Typed in, not live

This is the limit to say out loud when you send it. A page in NOS cannot fetch data from other sites, so it cannot pull from your spreadsheet. Its numbers are whatever you typed into the code.

Left: the page shows the numbers you typed. Right: it is not connected to your sheet.
Left: the page shows the numbers you typed. Right: it is not connected to your sheet.

In practice the routine is short. Update your sheet as usual, copy the new figures into the DATA block, and the link everyone has shows them. The date at the top tells readers how fresh they are.

Which way to share depends on what readers need to do with the numbers.

HTML page as a link Google Sheets link Power BI report PDF or screenshot
Who can open it Anyone with the link, no account Anyone with the link, if you set access that way People you share with, under Power BI's license rules Whoever gets the file
Where the numbers live Typed into the page In the sheet's cells In the report's data Frozen when exported
After you change a number Edit the page, same link Edit the sheet, same link See Microsoft's help Send a new file

Google's Share files from Google Drive page says that anyone who has the link can use your file without signing in to a Google Account, once you pick that setting.

Microsoft's Share a filtered Power BI report page says that with sharing, you and your recipients need a Power BI Pro license, or the content needs to be in a Premium capacity. Both are as of October 2026.

A spreadsheet or a BI tool fits readers who want to filter or work in the data. A page fits readers who want one clear screen on a phone. For the case against sending the workbook itself, see sharing spreadsheet data without the file.

Keep three months on one page

Readers often ask what last month looked like. You can keep several months in the same block of numbers and let buttons choose which one to show.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Warehouse scorecard - three months</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 16px; font: 16px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: #1d2330; background: #f4f5f7; }
  h1 { margin: 0 0 4px; font-size: 20px; }
  .sample { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 99px; background: #fde68a; color: #78350f; font-size: 12px; font-weight: 700; }
  .meta { margin: 0 0 10px; font-size: 14px; color: #4b5563; }
  .tabs { display: flex; gap: 6px; margin-bottom: 10px; }
  .tabs button { font: inherit; font-size: 15px; padding: 6px 14px; border: 1px solid #cbd0d8; border-radius: 8px; background: #fff; color: #1d2330; cursor: pointer; }
  .tabs button[aria-pressed="true"] { background: #1d4ed8; border-color: #1d4ed8; color: #fff; }
  table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e1e4ea; border-radius: 10px; overflow: hidden; }
  th, td { padding: 9px 10px; text-align: left; border-bottom: 1px solid #eef0f3; font-size: 15px; vertical-align: top; }
  th { font-size: 13px; color: #4b5563; background: #fafbfc; }
  tr:last-child td { border-bottom: 0; }
  .num { text-align: right; font-variant-numeric: tabular-nums; }
  .small { display: block; font-size: 12px; color: #4b5563; font-weight: 400; }
  .pill { display: inline-block; padding: 2px 8px; border-radius: 99px; font-size: 13px; font-weight: 700; white-space: nowrap; }
  .ok { background: #dcfce7; color: #14532d; }
  .watch { background: #fef3c7; color: #78350f; }
  .behind { background: #fee2e2; color: #7f1d1d; }
  footer { margin-top: 10px; font-size: 13px; color: #4b5563; }
</style>
</head>
<body>
<h1>Warehouse scorecard <span class="sample">SAMPLE DATA</span></h1>
<p class="meta">Larkspur Supply Co. - typed in by hand, three months kept on this page</p>
<div class="tabs" id="tabs"></div>
<table>
  <thead><tr><th>KPI</th><th class="num">Actual</th><th class="num">Target</th><th>Status</th></tr></thead>
  <tbody id="body"></tbody>
</table>
<footer>On track: at or better than target. Watch: within 5%. Behind: further off than that.</footer>

<script>
  // All numbers are written here. Add next month as one more entry in "months".
  const KPIS = [
    { name: 'Orders shipped',               target: 1250, better: 'higher', dec: 0 },
    { name: 'Shipped on time (%)',          target: 96,   better: 'higher', dec: 0 },
    { name: 'Pick errors per 1,000 orders', target: 5.0,  better: 'lower',  dec: 1 },
    { name: 'Stock-outs (items)',           target: 10,   better: 'lower',  dec: 0 },
    { name: 'Overtime hours',               target: 150,  better: 'lower',  dec: 0 }
  ];
  const MONTHS = [
    { label: 'Jul 2026', values: [1180, 94, 6.1, 14, 160] },
    { label: 'Aug 2026', values: [1240, 95, 5.4, 11, 142] },
    { label: 'Sep 2026', values: [1315, 93, 4.8, 12, 171] }
  ];
  const BAND = 0.05;
  const LABEL = { ok: 'On track', watch: 'Watch', behind: 'Behind' };

  function status(k, actual) {
    if (k.better === 'higher') {
      if (actual >= k.target) return 'ok';
      return actual >= k.target * (1 - BAND) ? 'watch' : 'behind';
    }
    if (actual <= k.target) return 'ok';
    return actual <= k.target * (1 + BAND) ? 'watch' : 'behind';
  }

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

  const tabs = document.getElementById('tabs');
  const body = document.getElementById('body');

  function show(i) {
    [...tabs.children].forEach((b, n) => b.setAttribute('aria-pressed', String(n === i)));
    body.textContent = '';
    KPIS.forEach((k, n) => {
      const actual = MONTHS[i].values[n];
      const tr = el('tr');
      tr.appendChild(el('td', '', k.name));

      const a = el('td', 'num', actual.toFixed(k.dec));
      let note = 'first month';
      if (i > 0) {
        const d = actual - MONTHS[i - 1].values[n];
        note = (d > 0 ? '+' : d < 0 ? '-' : '') + Math.abs(d).toFixed(k.dec) + ' vs ' + MONTHS[i - 1].label.slice(0, 3);
      }
      a.appendChild(el('span', 'small', note));
      tr.appendChild(a);

      tr.appendChild(el('td', 'num', k.target.toFixed(k.dec)));
      const s = status(k, actual);
      const td = el('td');
      td.appendChild(el('span', 'pill ' + s, LABEL[s]));
      tr.appendChild(td);
      body.appendChild(tr);
    });
  }

  MONTHS.forEach((m, i) => {
    const b = el('button', '', m.label);
    b.type = 'button';
    b.addEventListener('click', () => show(i));
    tabs.appendChild(b);
  });
  show(MONTHS.length - 1);
</script>
</body>
</html>
Tap a month. The actual, the change since the month before and the status all change. Every number is written in the page.

You do not need buttons for a first version. Add them when people start asking. Each period, add one more month to the block instead of replacing the old ones.

When it does not work

Symptom Cause Fix
A status looks wrong The AI flipped higher or lower is better, or used its own rule Put the rule in the prompt and check two tiles by hand
A number differs from your sheet The AI changed a digit or rounded it Compare every figure, and ask for your numbers exactly
Tiles are empty The reply was cut off when you copied it Copy the whole reply, down to the closing tags
You asked it to read your Google Sheet and nothing loads A page cannot fetch data from other sites Copy the numbers into the page
Readers still see last month You updated the sheet but not the page Edit the numbers in the page. The same link shows them
A logo or picture is missing The AI used an outside image, which is blocked Ask for shapes and colours only
The page looks tiny on a phone The AI left out the phone settings Ask for one column and text of 16px or larger

If the page will not open on a phone at all, opening an HTML file on a phone covers the usual causes.

Questions people ask

Does the dashboard update by itself?

No. The numbers are written into the page. A NOS page cannot fetch data from other sites, so it cannot read a Google Sheet or a database. To update it, you change the numbers in the code, and the same link shows the new ones.

Why not just share my spreadsheet?

You can, and for people who work in the cells it is the right choice. Google says that when access is set to Anyone with the link, people can use the file without signing in. A page suits readers who want one clear screen on a phone, not the cells.

How many KPIs should one page have?

Six is a layout choice, not a rule. Fewer tiles get read faster. If you need more, make one page per team instead of one long page, and keep the same layout on each.

Who can open the link?

Anyone who has it, in a browser, with no account. They can read the page but not edit it. A shared page has no password, and a link can be forwarded, so only put numbers on it that you are happy for any holder of the link to see.

Can I trust the status colours the AI chose?

Only after you check them. AI can flip which direction is good for a cost or a return rate. Write the rule in your prompt, then work out two tiles by hand and compare.

Keep reading