Tailwind Charts: 16 Examples Without a Chart Library

Tailwind has no charting engine. A library such as Chart.js, ApexCharts or Recharts draws the data, and Tailwind styles whatever that library lets it touch: SVG marks accept fill and stroke utilities, but canvas marks accept none.

Below are 16 Tailwind chart examples built from utilities, inline SVG and a little vanilla JavaScript, followed by a guide to chart libraries, theme colors, dark mode and accessibility.

By Bogdan Sandu ยท Updated October 29, 2026

Every example is built with Tailwind CSS v4.3 utility classes. To try one quickly, add the Play CDN tags below to your page and paste the example's HTML, CSS and JS. The dark variant line makes dark: classes follow a .dark class instead of the system setting. For production, install Tailwind with Vite or the CLI.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

Bar and column charts from plain divs

No SVG and no library: flex rows, CSS grid, width and height set through inline styles, arbitrary values or CSS variables, and the numbers printed as real text next to every bar.

Horizontal bar chart with values as text

  • w-[63%]
  • h-2.5
  • tabular-nums
  • space-y-*
  • sr-only

Each bar is a rounded div inside a bg-slate-100 track, and arbitrary width classes such as w-[63%] scale it against the largest source. The count and the share sit in the label row as real text, so the bars are only a visual aid and stay aria-hidden. With values from a database you would set the width through a style attribute instead, because Tailwind only generates classes it can read in your files.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
  <div class="flex flex-wrap items-baseline justify-between gap-2">
    <h3 class="text-base font-semibold text-slate-900">Signups by source</h3>
    <p class="text-sm text-slate-600">October 2026, <span class="font-medium text-slate-900 tabular-nums">3,412</span> total</p>
  </div>
  <ul class="mt-5 space-y-4">
    <li>
      <div class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">Organic search</span><span class="text-slate-600 tabular-nums"><span class="font-semibold text-slate-900">1,296</span> (38%)</span></div>
      <div class="mt-1.5 h-2.5 rounded-full bg-slate-100" aria-hidden="true"><div class="h-full w-full rounded-full bg-teal-700"></div></div>
    </li>
    <li>
      <div class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">Referrals</span><span class="text-slate-600 tabular-nums"><span class="font-semibold text-slate-900">819</span> (24%)</span></div>
      <div class="mt-1.5 h-2.5 rounded-full bg-slate-100" aria-hidden="true"><div class="h-full w-[63%] rounded-full bg-teal-700"></div></div>
    </li>
    <li>
      <div class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">Paid social</span><span class="text-slate-600 tabular-nums"><span class="font-semibold text-slate-900">614</span> (18%)</span></div>
      <div class="mt-1.5 h-2.5 rounded-full bg-slate-100" aria-hidden="true"><div class="h-full w-[47%] rounded-full bg-teal-700"></div></div>
    </li>
    <li>
      <div class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">Newsletter</span><span class="text-slate-600 tabular-nums"><span class="font-semibold text-slate-900">409</span> (12%)</span></div>
      <div class="mt-1.5 h-2.5 rounded-full bg-slate-100" aria-hidden="true"><div class="h-full w-[32%] rounded-full bg-teal-700"></div></div>
    </li>
    <li>
      <div class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">Direct</span><span class="text-slate-600 tabular-nums"><span class="font-semibold text-slate-900">274</span> (8%)</span></div>
      <div class="mt-1.5 h-2.5 rounded-full bg-slate-100" aria-hidden="true"><div class="h-full w-[21%] rounded-full bg-teal-700"></div></div>
    </li>
  </ul>
  <p class="mt-5 border-t border-slate-100 pt-4 text-sm text-slate-600">Organic search brought in more signups than paid social and the newsletter combined.</p>
</div>

Column chart on a CSS grid with hover and focus tooltips

  • grid-cols-6
  • h-(--h)
  • group-hover:
  • group-focus-within:
  • group-data-dismissed:

Hover a column or tab to it. Escape hides the tooltip.

Each column is a grid cell that sets one CSS variable, --h, and both the bar (h-(--h)) and its tooltip (bottom-[calc(var(--h)+0.375rem)]) read it, so the data lives in one place. The tooltip appears on group-hover: and group-focus-within:, every bar is focusable with a full aria-label, and the gridlines are absolutely positioned borders at quarter heights. The JS only adds a data-dismissed attribute on Escape so the tooltip can be hidden without moving the pointer, as WCAG 1.4.13 asks.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-stone-200 sm:p-6">
  <h3 class="text-base font-semibold text-stone-900">Cabin bookings</h3>
  <p class="text-sm text-stone-600">April to September 2026, all three lakeside cabins</p>
  <div class="mt-6 grid grid-cols-[2rem_1fr] gap-x-2">
    <div class="relative h-56 text-right text-xs text-stone-600 tabular-nums" aria-hidden="true">
      <span class="absolute right-0 bottom-0 translate-y-1/2">0</span>
      <span class="absolute right-0 bottom-1/4 translate-y-1/2">100</span>
      <span class="absolute right-0 bottom-1/2 translate-y-1/2">200</span>
      <span class="absolute right-0 bottom-3/4 translate-y-1/2">300</span>
      <span class="absolute right-0 bottom-full translate-y-1/2">400</span>
    </div>
    <div class="relative h-56">
      <div class="absolute inset-x-0 bottom-0 border-t border-stone-300" aria-hidden="true"></div>
      <div class="absolute inset-x-0 bottom-1/4 border-t border-stone-200" aria-hidden="true"></div>
      <div class="absolute inset-x-0 bottom-1/2 border-t border-stone-200" aria-hidden="true"></div>
      <div class="absolute inset-x-0 bottom-3/4 border-t border-stone-200" aria-hidden="true"></div>
      <div class="absolute inset-x-0 bottom-full border-t border-stone-200" aria-hidden="true"></div>
      <ul class="relative grid h-full grid-cols-6 gap-2 sm:gap-5">
        <li class="group relative flex h-full flex-col items-center justify-end" style="--h:35.5%">
          <span class="mb-1 text-xs font-medium text-stone-700 tabular-nums group-hover:invisible group-focus-within:invisible" aria-hidden="true">142</span>
          <span tabindex="0" role="img" aria-label="April: 142 bookings, up 18% on March" class="tch-col block h-(--h) w-full max-w-12 rounded-t-md bg-orange-700 hover:bg-orange-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-900"></span>
          <span class="tch-tip pointer-events-none invisible absolute bottom-[calc(var(--h)+0.375rem)] left-0 z-10 w-max rounded-lg bg-stone-900 px-2.5 py-1.5 text-xs text-white opacity-0 shadow-lg group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100 group-data-dismissed:invisible motion-safe:transition-opacity" aria-hidden="true"><span class="block font-semibold">April</span>142 bookings, +18%</span>
        </li>
        <li class="group relative flex h-full flex-col items-center justify-end" style="--h:47%">
          <span class="mb-1 text-xs font-medium text-stone-700 tabular-nums group-hover:invisible group-focus-within:invisible" aria-hidden="true">188</span>
          <span tabindex="0" role="img" aria-label="May: 188 bookings, up 32% on April" class="tch-col block h-(--h) w-full max-w-12 rounded-t-md bg-orange-700 hover:bg-orange-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-900"></span>
          <span class="tch-tip pointer-events-none invisible absolute bottom-[calc(var(--h)+0.375rem)] left-1/2 z-10 w-max -translate-x-1/2 rounded-lg bg-stone-900 px-2.5 py-1.5 text-xs text-white opacity-0 shadow-lg group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100 group-data-dismissed:invisible motion-safe:transition-opacity" aria-hidden="true"><span class="block font-semibold">May</span>188 bookings, +32%</span>
        </li>
        <li class="group relative flex h-full flex-col items-center justify-end" style="--h:59%">
          <span class="mb-1 text-xs font-medium text-stone-700 tabular-nums group-hover:invisible group-focus-within:invisible" aria-hidden="true">236</span>
          <span tabindex="0" role="img" aria-label="June: 236 bookings, up 26% on May" class="tch-col block h-(--h) w-full max-w-12 rounded-t-md bg-orange-700 hover:bg-orange-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-900"></span>
          <span class="tch-tip pointer-events-none invisible absolute bottom-[calc(var(--h)+0.375rem)] left-1/2 z-10 w-max -translate-x-1/2 rounded-lg bg-stone-900 px-2.5 py-1.5 text-xs text-white opacity-0 shadow-lg group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100 group-data-dismissed:invisible motion-safe:transition-opacity" aria-hidden="true"><span class="block font-semibold">June</span>236 bookings, +26%</span>
        </li>
        <li class="group relative flex h-full flex-col items-center justify-end" style="--h:77.75%">
          <span class="mb-1 text-xs font-medium text-stone-700 tabular-nums group-hover:invisible group-focus-within:invisible" aria-hidden="true">311</span>
          <span tabindex="0" role="img" aria-label="July: 311 bookings, up 32% on June, the busiest month" class="tch-col block h-(--h) w-full max-w-12 rounded-t-md bg-orange-700 hover:bg-orange-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-900"></span>
          <span class="tch-tip pointer-events-none invisible absolute bottom-[calc(var(--h)+0.375rem)] left-1/2 z-10 w-max -translate-x-1/2 rounded-lg bg-stone-900 px-2.5 py-1.5 text-xs text-white opacity-0 shadow-lg group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100 group-data-dismissed:invisible motion-safe:transition-opacity" aria-hidden="true"><span class="block font-semibold">July, busiest</span>311 bookings, +32%</span>
        </li>
        <li class="group relative flex h-full flex-col items-center justify-end" style="--h:74.25%">
          <span class="mb-1 text-xs font-medium text-stone-700 tabular-nums group-hover:invisible group-focus-within:invisible" aria-hidden="true">297</span>
          <span tabindex="0" role="img" aria-label="August: 297 bookings, down 5% on July" class="tch-col block h-(--h) w-full max-w-12 rounded-t-md bg-orange-700 hover:bg-orange-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-900"></span>
          <span class="tch-tip pointer-events-none invisible absolute bottom-[calc(var(--h)+0.375rem)] left-1/2 z-10 w-max -translate-x-1/2 rounded-lg bg-stone-900 px-2.5 py-1.5 text-xs text-white opacity-0 shadow-lg group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100 group-data-dismissed:invisible motion-safe:transition-opacity" aria-hidden="true"><span class="block font-semibold">August</span>297 bookings, &minus;5%</span>
        </li>
        <li class="group relative flex h-full flex-col items-center justify-end" style="--h:51%">
          <span class="mb-1 text-xs font-medium text-stone-700 tabular-nums group-hover:invisible group-focus-within:invisible" aria-hidden="true">204</span>
          <span tabindex="0" role="img" aria-label="September: 204 bookings, down 31% on August" class="tch-col block h-(--h) w-full max-w-12 rounded-t-md bg-orange-700 hover:bg-orange-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-900"></span>
          <span class="tch-tip pointer-events-none invisible absolute right-0 bottom-[calc(var(--h)+0.375rem)] z-10 w-max rounded-lg bg-stone-900 px-2.5 py-1.5 text-xs text-white opacity-0 shadow-lg group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100 group-data-dismissed:invisible motion-safe:transition-opacity" aria-hidden="true"><span class="block font-semibold">September</span>204 bookings, &minus;31%</span>
        </li>
      </ul>
    </div>
    <div></div>
    <ul class="mt-2 grid grid-cols-6 gap-2 text-center text-xs text-stone-600 sm:gap-5" aria-hidden="true">
      <li>Apr</li><li>May</li><li>Jun</li><li>Jul</li><li>Aug</li><li>Sep</li>
    </ul>
  </div>
</div>

Stacked 100% bars with labelled segments

  • flex
  • gap-0.5
  • overflow-hidden
  • role="img"
  • aria-label

Each row is a flex container with overflow-hidden and rounded-lg, and every segment gets its share as an inline width. gap-0.5 leaves a white seam between segments, so neighbouring colors never have to reach 3:1 against each other, and the percentages printed inside the segments (the thin tablet share sits in the label row instead) mean the series never depend on color alone. Each row is one role="img" element whose aria-label reads the full split.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 sm:p-6">
  <h3 class="text-base font-semibold text-slate-900">Sessions by device</h3>
  <p class="text-sm text-slate-600">Share of storefront sessions per region, Q3 2026</p>
  <ul class="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-sm text-slate-700" aria-label="Legend">
    <li class="flex items-center gap-2"><span class="size-3 rounded-sm bg-slate-800" aria-hidden="true"></span>Desktop</li>
    <li class="flex items-center gap-2"><span class="size-3 rounded-sm bg-sky-700" aria-hidden="true"></span>Mobile</li>
    <li class="flex items-center gap-2"><span class="size-3 rounded-sm bg-orange-700" aria-hidden="true"></span>Tablet</li>
  </ul>
  <ul class="mt-5 space-y-4">
    <li>
      <p class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">North America</span><span class="text-xs text-slate-600 tabular-nums">Tablet 8%</span></p>
      <div role="img" aria-label="North America: desktop 54%, mobile 38%, tablet 8%" class="mt-1.5 flex h-8 gap-0.5 overflow-hidden rounded-lg text-xs font-semibold text-white tabular-nums">
        <span class="flex items-center justify-center bg-slate-800" style="width:54%">54%</span>
        <span class="flex items-center justify-center bg-sky-700" style="width:38%">38%</span>
        <span class="bg-orange-700" style="width:8%"></span>
      </div>
    </li>
    <li>
      <p class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">Europe</span><span class="text-xs text-slate-600 tabular-nums">Tablet 8%</span></p>
      <div role="img" aria-label="Europe: desktop 46%, mobile 46%, tablet 8%" class="mt-1.5 flex h-8 gap-0.5 overflow-hidden rounded-lg text-xs font-semibold text-white tabular-nums">
        <span class="flex items-center justify-center bg-slate-800" style="width:46%">46%</span>
        <span class="flex items-center justify-center bg-sky-700" style="width:46%">46%</span>
        <span class="bg-orange-700" style="width:8%"></span>
      </div>
    </li>
    <li>
      <p class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">Latin America</span><span class="text-xs text-slate-600 tabular-nums">Tablet 6%</span></p>
      <div role="img" aria-label="Latin America: desktop 31%, mobile 63%, tablet 6%" class="mt-1.5 flex h-8 gap-0.5 overflow-hidden rounded-lg text-xs font-semibold text-white tabular-nums">
        <span class="flex items-center justify-center bg-slate-800" style="width:31%">31%</span>
        <span class="flex items-center justify-center bg-sky-700" style="width:63%">63%</span>
        <span class="bg-orange-700" style="width:6%"></span>
      </div>
    </li>
    <li>
      <p class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">Asia Pacific</span><span class="text-xs text-slate-600 tabular-nums">Tablet 6%</span></p>
      <div role="img" aria-label="Asia Pacific: desktop 28%, mobile 66%, tablet 6%" class="mt-1.5 flex h-8 gap-0.5 overflow-hidden rounded-lg text-xs font-semibold text-white tabular-nums">
        <span class="flex items-center justify-center bg-slate-800" style="width:28%">28%</span>
        <span class="flex items-center justify-center bg-sky-700" style="width:66%">66%</span>
        <span class="bg-orange-700" style="width:6%"></span>
      </div>
    </li>
  </ul>
</div>

Grouped columns with legend toggles

  • aria-pressed
  • aria-[pressed=false]:
  • border-dashed
  • flex-col
  • hidden

Click a legend button to hide or show a series.

Three series per quarter sit side by side in a flex row, and each series has its own fill style as well as its own hue: solid, outlined and dashed, repeated in the legend swatches. The legend items are toggle buttons with aria-pressed, styled with aria-[pressed=false]:line-through, and the JS adds or removes hidden on that series so the remaining columns widen to fill the group. A live region reports how many series are showing.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 sm:p-6">
  <h3 class="text-base font-semibold text-slate-900">Revenue by plan</h3>
  <p class="text-sm text-slate-600">2025, in thousands of dollars</p>
  <div class="mt-4 flex flex-wrap gap-2" role="group" aria-label="Show or hide series">
    <button type="button" aria-pressed="true" data-series="s1" class="tch-gr-toggle inline-flex min-h-10 items-center gap-2 rounded-full px-3 text-sm font-medium text-slate-800 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[pressed=false]:text-slate-500 aria-[pressed=false]:line-through"><span class="size-3.5 rounded-sm bg-sky-700" aria-hidden="true"></span>Starter</button>
    <button type="button" aria-pressed="true" data-series="s2" class="tch-gr-toggle inline-flex min-h-10 items-center gap-2 rounded-full px-3 text-sm font-medium text-slate-800 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[pressed=false]:text-slate-500 aria-[pressed=false]:line-through"><span class="size-3.5 rounded-sm border-2 border-fuchsia-700 bg-fuchsia-100" aria-hidden="true"></span>Team</button>
    <button type="button" aria-pressed="true" data-series="s3" class="tch-gr-toggle inline-flex min-h-10 items-center gap-2 rounded-full px-3 text-sm font-medium text-slate-800 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[pressed=false]:text-slate-500 aria-[pressed=false]:line-through"><span class="size-3.5 rounded-sm border-2 border-dashed border-amber-700 bg-amber-50" aria-hidden="true"></span>Enterprise</button>
  </div>
  <p id="tch-gr-status" class="sr-only" aria-live="polite"></p>
  <div class="mt-5 grid grid-cols-[2rem_1fr] gap-x-2">
    <div class="relative h-52 text-right text-xs text-slate-600 tabular-nums" aria-hidden="true">
      <span class="absolute right-0 bottom-0 translate-y-1/2">0</span>
      <span class="absolute right-0 bottom-1/2 translate-y-1/2">50</span>
      <span class="absolute right-0 bottom-full translate-y-1/2">100</span>
    </div>
    <div class="relative h-52" role="img" aria-label="Grouped column chart. Team leads every quarter and ends at 90 thousand in Q4. Enterprise grows fastest, from 38 to 88 thousand. Starter rises from 42 to 57 thousand.">
      <div class="absolute inset-x-0 bottom-0 border-t border-slate-300"></div>
      <div class="absolute inset-x-0 bottom-1/2 border-t border-slate-200"></div>
      <div class="absolute inset-x-0 bottom-full border-t border-slate-200"></div>
      <ul class="relative grid h-full grid-cols-4 gap-3 sm:gap-8">
        <li class="flex h-full items-end justify-center gap-1">
          <div class="tch-gr-s1 flex h-full w-full max-w-8 flex-col items-center justify-end"><span class="mb-1 text-[11px] text-slate-700 tabular-nums">42</span><span class="block h-[42%] w-full rounded-t bg-sky-700"></span></div>
          <div class="tch-gr-s2 flex h-full w-full max-w-8 flex-col items-center justify-end"><span class="mb-1 text-[11px] text-slate-700 tabular-nums">64</span><span class="block h-[64%] w-full rounded-t border-2 border-b-0 border-fuchsia-700 bg-fuchsia-100"></span></div>
          <div class="tch-gr-s3 flex h-full w-full max-w-8 flex-col items-center justify-end"><span class="mb-1 text-[11px] text-slate-700 tabular-nums">38</span><span class="block h-[38%] w-full rounded-t border-2 border-b-0 border-dashed border-amber-700 bg-amber-50"></span></div>
        </li>
        <li class="flex h-full items-end justify-center gap-1">
          <div class="tch-gr-s1 flex h-full w-full max-w-8 flex-col items-center justify-end"><span class="mb-1 text-[11px] text-slate-700 tabular-nums">48</span><span class="block h-[48%] w-full rounded-t bg-sky-700"></span></div>
          <div class="tch-gr-s2 flex h-full w-full max-w-8 flex-col items-center justify-end"><span class="mb-1 text-[11px] text-slate-700 tabular-nums">71</span><span class="block h-[71%] w-full rounded-t border-2 border-b-0 border-fuchsia-700 bg-fuchsia-100"></span></div>
          <div class="tch-gr-s3 flex h-full w-full max-w-8 flex-col items-center justify-end"><span class="mb-1 text-[11px] text-slate-700 tabular-nums">55</span><span class="block h-[55%] w-full rounded-t border-2 border-b-0 border-dashed border-amber-700 bg-amber-50"></span></div>
        </li>
        <li class="flex h-full items-end justify-center gap-1">
          <div class="tch-gr-s1 flex h-full w-full max-w-8 flex-col items-center justify-end"><span class="mb-1 text-[11px] text-slate-700 tabular-nums">51</span><span class="block h-[51%] w-full rounded-t bg-sky-700"></span></div>
          <div class="tch-gr-s2 flex h-full w-full max-w-8 flex-col items-center justify-end"><span class="mb-1 text-[11px] text-slate-700 tabular-nums">83</span><span class="block h-[83%] w-full rounded-t border-2 border-b-0 border-fuchsia-700 bg-fuchsia-100"></span></div>
          <div class="tch-gr-s3 flex h-full w-full max-w-8 flex-col items-center justify-end"><span class="mb-1 text-[11px] text-slate-700 tabular-nums">61</span><span class="block h-[61%] w-full rounded-t border-2 border-b-0 border-dashed border-amber-700 bg-amber-50"></span></div>
        </li>
        <li class="flex h-full items-end justify-center gap-1">
          <div class="tch-gr-s1 flex h-full w-full max-w-8 flex-col items-center justify-end"><span class="mb-1 text-[11px] text-slate-700 tabular-nums">57</span><span class="block h-[57%] w-full rounded-t bg-sky-700"></span></div>
          <div class="tch-gr-s2 flex h-full w-full max-w-8 flex-col items-center justify-end"><span class="mb-1 text-[11px] text-slate-700 tabular-nums">90</span><span class="block h-[90%] w-full rounded-t border-2 border-b-0 border-fuchsia-700 bg-fuchsia-100"></span></div>
          <div class="tch-gr-s3 flex h-full w-full max-w-8 flex-col items-center justify-end"><span class="mb-1 text-[11px] text-slate-700 tabular-nums">88</span><span class="block h-[88%] w-full rounded-t border-2 border-b-0 border-dashed border-amber-700 bg-amber-50"></span></div>
        </li>
      </ul>
    </div>
    <div></div>
    <ul class="mt-2 grid grid-cols-4 gap-3 text-center text-xs text-slate-600 sm:gap-8" aria-hidden="true">
      <li>Q1</li><li>Q2</li><li>Q3</li><li>Q4</li>
    </ul>
  </div>
</div>

Waterfall chart for a profit bridge

  • bottom-(--b)
  • h-(--h)
  • bottom-[calc(...)]
  • border-dashed
  • sm:hidden

Every step is a floating bar: two CSS variables per column, --b for where it starts and --h for its height, drive bottom-(--b) and h-(--h), and the value label sits at bottom-[calc(var(--b)+var(--h)+0.25rem)]. Dashed connectors at the running total link each bar to the next. Totals, costs and gains differ by color, and the signed labels say the same thing in text, so the chart still reads in grayscale.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-4 shadow-sm ring-1 ring-slate-200 sm:p-6">
  <h3 class="text-base font-semibold text-slate-900">From revenue to profit</h3>
  <p class="text-sm text-slate-600">Q3 2026, in thousands of dollars</p>
  <div class="relative mt-6 h-56" role="img" aria-label="Waterfall chart. Revenue of 820 thousand dollars, minus 310 cost of goods, 245 payroll, 90 marketing and 35 refunds, plus 40 other income, leaves a net profit of 180 thousand.">
    <div class="absolute inset-x-0 bottom-0 border-t border-slate-300"></div>
    <ul class="relative grid h-full grid-cols-7">
      <li class="relative h-full" style="--b:0%;--h:91.11%;--e:91.11%">
        <span class="absolute inset-x-[15%] bottom-(--b) h-(--h) rounded-t bg-slate-800"></span>
        <span class="absolute inset-x-0 bottom-[calc(var(--b)+var(--h)+0.25rem)] text-center text-[11px] font-semibold text-slate-900 tabular-nums sm:text-xs">820</span>
        <span class="absolute bottom-(--e) left-[85%] w-[30%] border-t border-dashed border-slate-400"></span>
      </li>
      <li class="relative h-full" style="--b:56.67%;--h:34.44%;--e:56.67%">
        <span class="absolute inset-x-[15%] bottom-(--b) h-(--h) bg-rose-700"></span>
        <span class="absolute inset-x-0 bottom-[calc(var(--b)+var(--h)+0.25rem)] text-center text-[11px] font-semibold text-rose-800 tabular-nums sm:text-xs">&minus;310</span>
        <span class="absolute bottom-(--e) left-[85%] w-[30%] border-t border-dashed border-slate-400"></span>
      </li>
      <li class="relative h-full" style="--b:29.44%;--h:27.22%;--e:29.44%">
        <span class="absolute inset-x-[15%] bottom-(--b) h-(--h) bg-rose-700"></span>
        <span class="absolute inset-x-0 bottom-[calc(var(--b)+var(--h)+0.25rem)] text-center text-[11px] font-semibold text-rose-800 tabular-nums sm:text-xs">&minus;245</span>
        <span class="absolute bottom-(--e) left-[85%] w-[30%] border-t border-dashed border-slate-400"></span>
      </li>
      <li class="relative h-full" style="--b:19.44%;--h:10%;--e:19.44%">
        <span class="absolute inset-x-[15%] bottom-(--b) h-(--h) bg-rose-700"></span>
        <span class="absolute inset-x-0 bottom-[calc(var(--b)+var(--h)+0.25rem)] text-center text-[11px] font-semibold text-rose-800 tabular-nums sm:text-xs">&minus;90</span>
        <span class="absolute bottom-(--e) left-[85%] w-[30%] border-t border-dashed border-slate-400"></span>
      </li>
      <li class="relative h-full" style="--b:15.56%;--h:3.89%;--e:15.56%">
        <span class="absolute inset-x-[15%] bottom-(--b) h-(--h) bg-rose-700"></span>
        <span class="absolute inset-x-0 bottom-[calc(var(--b)+var(--h)+0.25rem)] text-center text-[11px] font-semibold text-rose-800 tabular-nums sm:text-xs">&minus;35</span>
        <span class="absolute bottom-(--e) left-[85%] w-[30%] border-t border-dashed border-slate-400"></span>
      </li>
      <li class="relative h-full" style="--b:15.56%;--h:4.44%;--e:20%">
        <span class="absolute inset-x-[15%] bottom-(--b) h-(--h) bg-emerald-700"></span>
        <span class="absolute inset-x-0 bottom-[calc(var(--b)+var(--h)+0.25rem)] text-center text-[11px] font-semibold text-emerald-800 tabular-nums sm:text-xs">+40</span>
        <span class="absolute bottom-(--e) left-[85%] w-[30%] border-t border-dashed border-slate-400"></span>
      </li>
      <li class="relative h-full" style="--b:0%;--h:20%">
        <span class="absolute inset-x-[15%] bottom-(--b) h-(--h) rounded-t bg-slate-800"></span>
        <span class="absolute inset-x-0 bottom-[calc(var(--b)+var(--h)+0.25rem)] text-center text-[11px] font-semibold text-slate-900 tabular-nums sm:text-xs">180</span>
      </li>
    </ul>
  </div>
  <ul class="mt-2 grid grid-cols-7 text-center text-[11px] leading-tight text-slate-600 sm:text-xs" aria-hidden="true">
    <li>Sales</li>
    <li>COGS</li>
    <li><span class="sm:hidden">Staff</span><span class="hidden sm:inline">Payroll</span></li>
    <li><span class="sm:hidden">Ads</span><span class="hidden sm:inline">Marketing</span></li>
    <li><span class="sm:hidden">Refund</span><span class="hidden sm:inline">Refunds</span></li>
    <li>Other</li>
    <li class="font-semibold text-slate-900">Net</li>
  </ul>
  <ul class="mt-5 flex flex-wrap gap-x-5 gap-y-2 border-t border-slate-100 pt-4 text-sm text-slate-700" aria-label="Legend">
    <li class="flex items-center gap-2"><span class="size-3 rounded-sm bg-slate-800" aria-hidden="true"></span>Total</li>
    <li class="flex items-center gap-2"><span class="size-3 rounded-sm bg-rose-700" aria-hidden="true"></span>Cost (&minus;)</li>
    <li class="flex items-center gap-2"><span class="size-3 rounded-sm bg-emerald-700" aria-hidden="true"></span>Gain (+)</li>
  </ul>
</div>

Lines, rings and dot plots in inline SVG

Hand-written SVG where every path and circle takes fill-* and stroke-* utilities: a line chart with a keyboard readout, a donut drawn with stroke-dasharray, a radial gauge and a dumbbell plot colored by prefixed @theme tokens.

Line chart in inline SVG with a keyboard readout

  • stroke-indigo-700
  • vector-effect
  • role="slider"
  • aria-valuetext
  • focus-visible:outline-*

Hover the chart, or tab to it and use the arrow keys, Home and End.

The SVG uses a 100 by 100 viewBox with preserveAspectRatio="none", so it stretches to any width, and vector-effect="non-scaling-stroke" keeps the stroke-indigo-700 and dashed stroke-amber-700 lines at their real pixel width. Axis labels, gridlines, the guide line and the dots are HTML positioned in percentages, so the text never scales with the SVG. The plot is a role="slider" over the twelve months: hover or the arrow keys move the readout, and aria-valuetext reads the same sentence a sighted user sees.

View code
<div class="w-full max-w-2xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 sm:p-6">
  <div class="flex flex-wrap items-start justify-between gap-3">
    <div>
      <h3 class="text-base font-semibold text-slate-900">Daily visitors</h3>
      <p class="text-sm text-slate-600">Average per day, in thousands</p>
    </div>
    <ul class="flex flex-wrap gap-x-5 gap-y-1 text-sm text-slate-700" aria-label="Legend">
      <li class="flex items-center gap-2"><svg viewBox="0 0 24 8" class="h-2 w-6" aria-hidden="true"><path d="M0 4h24" class="stroke-indigo-700" stroke-width="3"/></svg>Northwind</li>
      <li class="flex items-center gap-2"><svg viewBox="0 0 24 8" class="h-2 w-6" aria-hidden="true"><path d="M0 4h24" class="stroke-amber-700" stroke-width="3" stroke-dasharray="6 4"/></svg>Brightpath</li>
    </ul>
  </div>
  <p id="tch-ln-out" class="mt-4 min-h-5 text-sm font-medium text-slate-800 tabular-nums">Oct 2026: Northwind 9,100, Brightpath 6,900</p>
  <div class="mt-3 grid grid-cols-[2rem_1fr] gap-x-3">
    <div class="relative h-56 text-right text-xs text-slate-600 tabular-nums" aria-hidden="true">
      <span class="absolute right-0 bottom-0 translate-y-1/2">0</span>
      <span class="absolute right-0 bottom-1/4 translate-y-1/2">2.5</span>
      <span class="absolute right-0 bottom-1/2 translate-y-1/2">5</span>
      <span class="absolute right-0 bottom-3/4 translate-y-1/2">7.5</span>
      <span class="absolute right-0 bottom-full translate-y-1/2">10</span>
    </div>
    <div id="tch-ln-plot" tabindex="0" role="slider" aria-label="Month" aria-valuemin="0" aria-valuemax="11" aria-valuenow="11" aria-valuetext="Oct 2026: Northwind 9,100, Brightpath 6,900" class="relative h-56 cursor-crosshair touch-none rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-indigo-700">
      <div class="absolute inset-x-0 bottom-0 border-t border-slate-300" aria-hidden="true"></div>
      <div class="absolute inset-x-0 bottom-1/4 border-t border-slate-200" aria-hidden="true"></div>
      <div class="absolute inset-x-0 bottom-1/2 border-t border-slate-200" aria-hidden="true"></div>
      <div class="absolute inset-x-0 bottom-3/4 border-t border-slate-200" aria-hidden="true"></div>
      <div class="absolute inset-x-0 bottom-full border-t border-slate-200" aria-hidden="true"></div>
      <svg viewBox="0 0 100 100" preserveAspectRatio="none" class="absolute inset-0 size-full overflow-visible" aria-hidden="true">
        <path d="M0 69 L9.09 67 L18.18 61 L27.27 56 L36.36 58 L45.45 54 L54.55 47 L63.64 48 L72.73 44 L81.82 40 L90.91 33 L100 31" fill="none" vector-effect="non-scaling-stroke" class="stroke-amber-700" stroke-width="2.5" stroke-dasharray="7 5" stroke-linejoin="round"/>
        <path d="M0 58 L9.09 52 L18.18 49 L27.27 50 L36.36 41 L45.45 36 L54.55 39 L63.64 32 L72.73 26 L81.82 21 L90.91 17 L100 9" fill="none" vector-effect="non-scaling-stroke" class="stroke-indigo-700" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>
      </svg>
      <span id="tch-ln-guide" class="pointer-events-none absolute inset-y-0 w-px -translate-x-1/2 bg-slate-400" style="left:100%" aria-hidden="true"></span>
      <span id="tch-ln-dot-b" class="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-amber-700 ring-2 ring-white" style="left:100%;top:31%" aria-hidden="true"></span>
      <span id="tch-ln-dot-a" class="pointer-events-none absolute size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-indigo-700 ring-2 ring-white" style="left:100%;top:9%" aria-hidden="true"></span>
    </div>
    <div></div>
    <div class="relative mt-2 h-4 text-xs text-slate-600" aria-hidden="true">
      <span class="absolute left-0">Nov</span>
      <span class="absolute left-[18.18%] -translate-x-1/2">Jan</span>
      <span class="absolute left-[36.36%] -translate-x-1/2">Mar</span>
      <span class="absolute left-[54.55%] -translate-x-1/2">May</span>
      <span class="absolute left-[72.73%] -translate-x-1/2">Jul</span>
      <span class="absolute right-0">Oct</span>
    </div>
  </div>
</div>

Donut chart with stroke-dasharray and a legend list

  • stroke-dasharray
  • stroke-dashoffset
  • stroke-slate-800
  • fill-white
  • role="img"

The ring is five circles with a radius of 15.9155, which makes the circumference exactly 100, so stroke-dasharray="45 55" draws a 46% slice minus a one-unit gap and stroke-dashoffset moves each slice to where the previous one ended. Stroke colors are plain utilities (stroke-slate-800, stroke-blue-700 and so on), and each slice carries its percentage as SVG text with fill-white. The legend is a real list with amounts, and the SVG has role="img" with a one-sentence summary.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 sm:p-6">
  <h3 class="text-base font-semibold text-slate-900">Where the money went</h3>
  <p class="text-sm text-slate-600">Studio spending, October 2026</p>
  <div class="mt-5 flex flex-col items-center gap-6 sm:flex-row sm:items-center sm:gap-8">
    <div class="relative size-56 shrink-0">
      <svg viewBox="0 0 42 42" class="size-full" role="img" aria-label="Donut chart of 48,200 dollars in spending: payroll 46%, infrastructure 22%, marketing 17%, tools 9%, other 6%.">
        <g transform="rotate(-90 21 21)" fill="none" stroke-width="6">
          <circle cx="21" cy="21" r="15.9155" class="stroke-slate-800" stroke-dasharray="45 55" stroke-dashoffset="0"/>
          <circle cx="21" cy="21" r="15.9155" class="stroke-blue-700" stroke-dasharray="21 79" stroke-dashoffset="-46"/>
          <circle cx="21" cy="21" r="15.9155" class="stroke-violet-700" stroke-dasharray="16 84" stroke-dashoffset="-68"/>
          <circle cx="21" cy="21" r="15.9155" class="stroke-rose-700" stroke-dasharray="8 92" stroke-dashoffset="-85"/>
          <circle cx="21" cy="21" r="15.9155" class="stroke-emerald-800" stroke-dasharray="5 95" stroke-dashoffset="-94"/>
        </g>
        <g class="fill-white text-[2.3px] font-semibold" text-anchor="middle" dominant-baseline="central" aria-hidden="true">
          <text x="36.79" y="19.01">46%</text>
          <text x="14.22" y="35.4">22%</text>
          <text x="5.16" y="19.5">17%</text>
          <text x="11.25" y="8.42">9%</text>
          <text x="18.02" y="5.37">6%</text>
        </g>
      </svg>
      <div class="absolute inset-0 grid place-content-center text-center" aria-hidden="true">
        <span class="text-xs text-slate-600">Total</span>
        <span class="text-xl font-semibold text-slate-900 tabular-nums">$48.2k</span>
      </div>
    </div>
    <ul class="w-full divide-y divide-slate-100 text-sm">
      <li class="flex items-center gap-3 py-2"><span class="size-3 shrink-0 rounded-sm bg-slate-800" aria-hidden="true"></span><span class="flex-1 text-slate-800">Payroll</span><span class="text-slate-600 tabular-nums">46%</span><span class="w-16 text-right font-medium text-slate-900 tabular-nums">$22,172</span></li>
      <li class="flex items-center gap-3 py-2"><span class="size-3 shrink-0 rounded-sm bg-blue-700" aria-hidden="true"></span><span class="flex-1 text-slate-800">Infrastructure</span><span class="text-slate-600 tabular-nums">22%</span><span class="w-16 text-right font-medium text-slate-900 tabular-nums">$10,604</span></li>
      <li class="flex items-center gap-3 py-2"><span class="size-3 shrink-0 rounded-sm bg-violet-700" aria-hidden="true"></span><span class="flex-1 text-slate-800">Marketing</span><span class="text-slate-600 tabular-nums">17%</span><span class="w-16 text-right font-medium text-slate-900 tabular-nums">$8,194</span></li>
      <li class="flex items-center gap-3 py-2"><span class="size-3 shrink-0 rounded-sm bg-rose-700" aria-hidden="true"></span><span class="flex-1 text-slate-800">Tools</span><span class="text-slate-600 tabular-nums">9%</span><span class="w-16 text-right font-medium text-slate-900 tabular-nums">$4,338</span></li>
      <li class="flex items-center gap-3 py-2"><span class="size-3 shrink-0 rounded-sm bg-emerald-800" aria-hidden="true"></span><span class="flex-1 text-slate-800">Other</span><span class="text-slate-600 tabular-nums">6%</span><span class="w-16 text-right font-medium text-slate-900 tabular-nums">$2,892</span></li>
    </ul>
  </div>
</div>

Radial gauge with thresholds and a range input

  • stroke-emerald-400
  • accent-*
  • motion-safe:transition-[stroke-dasharray]
  • <output>
  • aria-valuetext

Drag the slider or use the arrow keys on it.

The gauge is two circles turned 135 degrees: the track uses a dasharray of 377 out of a 503 circumference to draw a 270 degree arc, and the value arc gets a dasharray of value times 3.77. The JS swaps full class names (stroke-emerald-400, stroke-amber-400, stroke-rose-400 and the matching accent-* and badge classes) at 70% and 90%, and the status word changes with the color, so the state never relies on color alone. The arc animates with motion-safe:transition-[stroke-dasharray].

View code
<div class="w-full max-w-sm rounded-2xl bg-slate-900 p-6 text-slate-100 ring-1 ring-white/10">
  <h3 class="text-base font-semibold text-white">Storage used</h3>
  <p class="text-sm text-slate-400">Design team workspace, 1 TB plan</p>
  <div class="relative mx-auto mt-4 size-56">
    <svg viewBox="0 0 200 200" class="size-full" aria-hidden="true">
      <circle cx="100" cy="100" r="80" fill="none" class="stroke-slate-700" stroke-width="16" stroke-linecap="round" stroke-dasharray="376.99 502.65" transform="rotate(135 100 100)"/>
      <circle id="tch-gg-arc" cx="100" cy="100" r="80" fill="none" class="stroke-emerald-400 motion-safe:transition-[stroke-dasharray] motion-safe:duration-300" stroke-width="16" stroke-linecap="round" stroke-dasharray="241.27 502.65" transform="rotate(135 100 100)"/>
    </svg>
    <div class="absolute inset-0 grid place-content-center justify-items-center text-center">
      <output id="tch-gg-val" for="tch-gg-range" class="text-4xl font-semibold text-white tabular-nums">64%</output>
      <span id="tch-gg-gb" class="text-sm text-slate-400 tabular-nums">640 of 1,000 GB</span>
      <span id="tch-gg-status" class="mt-2 rounded-full bg-emerald-400/15 px-2.5 py-0.5 text-xs font-semibold text-emerald-300">Healthy</span>
    </div>
    <span class="absolute top-[82%] left-[14%] text-xs text-slate-400" aria-hidden="true">0</span>
    <span class="absolute top-[82%] right-[8%] text-xs text-slate-400" aria-hidden="true">1 TB</span>
  </div>
  <label for="tch-gg-range" class="mt-2 block text-sm font-medium text-slate-200">Simulate usage</label>
  <input id="tch-gg-range" type="range" min="0" max="100" step="1" value="64" aria-valuetext="64%, 640 of 1,000 GB, healthy" class="mt-2 h-6 w-full cursor-pointer accent-emerald-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white">
  <p class="mt-3 flex justify-between text-xs text-slate-400"><span>Warning at 70%</span><span>Critical at 90%</span></p>
</div>

Dumbbell plot colored by @theme series tokens

  • @theme
  • stroke-tch-before
  • fill-tch-after
  • bg-tch-link
  • border-tch-after/60

Three prefixed tokens in @theme (--color-tch-before, --color-tch-after and --color-tch-link) become real utilities, so the same color is available as stroke-tch-before on an SVG ring, fill-tch-after on a diamond, bg-tch-link on the connector and border-tch-after/60 on the dashed target line. Change a token and every mark and legend swatch follows. The two series also differ in shape, a hollow circle against a filled diamond, and every row prints both values.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 sm:p-6">
  <h3 class="text-base font-semibold text-slate-900">Largest Contentful Paint by template</h3>
  <p class="text-sm text-slate-600">Median on mobile, before and after the September image fixes</p>
  <ul class="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-sm text-slate-700" aria-label="Legend">
    <li class="flex items-center gap-2"><svg viewBox="0 0 16 16" class="size-4" aria-hidden="true"><circle cx="8" cy="8" r="5.5" stroke-width="2.5" class="fill-white stroke-tch-before"/></svg>Before</li>
    <li class="flex items-center gap-2"><svg viewBox="0 0 16 16" class="size-4" aria-hidden="true"><rect x="3.5" y="3.5" width="9" height="9" transform="rotate(45 8 8)" class="fill-tch-after"/></svg>After</li>
    <li class="flex items-center gap-2"><span class="h-4 border-l-2 border-dashed border-tch-after/60" aria-hidden="true"></span>2.5 s target</li>
  </ul>
  <ul class="mt-5 space-y-3">
    <li class="grid grid-cols-[5.5rem_1fr] items-center gap-x-3 gap-y-0.5 sm:grid-cols-[6.5rem_1fr_6.5rem]">
      <span class="text-sm font-medium text-slate-800">Checkout</span>
      <div class="relative h-7" role="img" aria-label="Checkout: 6.1 seconds before, 2.8 seconds after">
        <span class="absolute inset-y-0 left-[35.71%] border-l-2 border-dashed border-tch-after/60"></span>
        <span class="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-tch-link" style="left:40%;width:47.14%"></span>
        <svg viewBox="0 0 16 16" class="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2" style="left:87.14%" aria-hidden="true"><circle cx="8" cy="8" r="5.5" stroke-width="2.5" class="fill-white stroke-tch-before"/></svg>
        <svg viewBox="0 0 16 16" class="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2" style="left:40%" aria-hidden="true"><rect x="3.5" y="3.5" width="9" height="9" transform="rotate(45 8 8)" class="fill-tch-after"/></svg>
      </div>
      <span class="col-start-2 text-xs text-slate-600 tabular-nums sm:col-start-auto sm:text-right sm:text-sm">6.1 s to <span class="font-semibold text-slate-900">2.8 s</span></span>
    </li>
    <li class="grid grid-cols-[5.5rem_1fr] items-center gap-x-3 gap-y-0.5 sm:grid-cols-[6.5rem_1fr_6.5rem]">
      <span class="text-sm font-medium text-slate-800">Search</span>
      <div class="relative h-7" role="img" aria-label="Search: 5.2 seconds before, 3.1 seconds after">
        <span class="absolute inset-y-0 left-[35.71%] border-l-2 border-dashed border-tch-after/60"></span>
        <span class="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-tch-link" style="left:44.29%;width:30%"></span>
        <svg viewBox="0 0 16 16" class="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2" style="left:74.29%" aria-hidden="true"><circle cx="8" cy="8" r="5.5" stroke-width="2.5" class="fill-white stroke-tch-before"/></svg>
        <svg viewBox="0 0 16 16" class="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2" style="left:44.29%" aria-hidden="true"><rect x="3.5" y="3.5" width="9" height="9" transform="rotate(45 8 8)" class="fill-tch-after"/></svg>
      </div>
      <span class="col-start-2 text-xs text-slate-600 tabular-nums sm:col-start-auto sm:text-right sm:text-sm">5.2 s to <span class="font-semibold text-slate-900">3.1 s</span></span>
    </li>
    <li class="grid grid-cols-[5.5rem_1fr] items-center gap-x-3 gap-y-0.5 sm:grid-cols-[6.5rem_1fr_6.5rem]">
      <span class="text-sm font-medium text-slate-800">Product</span>
      <div class="relative h-7" role="img" aria-label="Product: 4.6 seconds before, 2.2 seconds after">
        <span class="absolute inset-y-0 left-[35.71%] border-l-2 border-dashed border-tch-after/60"></span>
        <span class="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-tch-link" style="left:31.43%;width:34.28%"></span>
        <svg viewBox="0 0 16 16" class="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2" style="left:65.71%" aria-hidden="true"><circle cx="8" cy="8" r="5.5" stroke-width="2.5" class="fill-white stroke-tch-before"/></svg>
        <svg viewBox="0 0 16 16" class="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2" style="left:31.43%" aria-hidden="true"><rect x="3.5" y="3.5" width="9" height="9" transform="rotate(45 8 8)" class="fill-tch-after"/></svg>
      </div>
      <span class="col-start-2 text-xs text-slate-600 tabular-nums sm:col-start-auto sm:text-right sm:text-sm">4.6 s to <span class="font-semibold text-slate-900">2.2 s</span></span>
    </li>
    <li class="grid grid-cols-[5.5rem_1fr] items-center gap-x-3 gap-y-0.5 sm:grid-cols-[6.5rem_1fr_6.5rem]">
      <span class="text-sm font-medium text-slate-800">Home</span>
      <div class="relative h-7" role="img" aria-label="Home: 3.8 seconds before, 1.9 seconds after">
        <span class="absolute inset-y-0 left-[35.71%] border-l-2 border-dashed border-tch-after/60"></span>
        <span class="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-tch-link" style="left:27.14%;width:27.15%"></span>
        <svg viewBox="0 0 16 16" class="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2" style="left:54.29%" aria-hidden="true"><circle cx="8" cy="8" r="5.5" stroke-width="2.5" class="fill-white stroke-tch-before"/></svg>
        <svg viewBox="0 0 16 16" class="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2" style="left:27.14%" aria-hidden="true"><rect x="3.5" y="3.5" width="9" height="9" transform="rotate(45 8 8)" class="fill-tch-after"/></svg>
      </div>
      <span class="col-start-2 text-xs text-slate-600 tabular-nums sm:col-start-auto sm:text-right sm:text-sm">3.8 s to <span class="font-semibold text-slate-900">1.9 s</span></span>
    </li>
    <li class="grid grid-cols-[5.5rem_1fr] items-center gap-x-3 gap-y-0.5 sm:grid-cols-[6.5rem_1fr_6.5rem]">
      <span class="text-sm font-medium text-slate-800">Blog post</span>
      <div class="relative h-7" role="img" aria-label="Blog post: 2.9 seconds before, 1.6 seconds after">
        <span class="absolute inset-y-0 left-[35.71%] border-l-2 border-dashed border-tch-after/60"></span>
        <span class="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-tch-link" style="left:22.86%;width:18.57%"></span>
        <svg viewBox="0 0 16 16" class="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2" style="left:41.43%" aria-hidden="true"><circle cx="8" cy="8" r="5.5" stroke-width="2.5" class="fill-white stroke-tch-before"/></svg>
        <svg viewBox="0 0 16 16" class="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2" style="left:22.86%" aria-hidden="true"><rect x="3.5" y="3.5" width="9" height="9" transform="rotate(45 8 8)" class="fill-tch-after"/></svg>
      </div>
      <span class="col-start-2 text-xs text-slate-600 tabular-nums sm:col-start-auto sm:text-right sm:text-sm">2.9 s to <span class="font-semibold text-slate-900">1.6 s</span></span>
    </li>
  </ul>
  <div class="mt-2 grid grid-cols-[5.5rem_1fr] gap-x-3 sm:grid-cols-[6.5rem_1fr_6.5rem]" aria-hidden="true">
    <div></div>
    <div class="relative h-4 text-xs text-slate-600 tabular-nums">
      <span class="absolute left-0">0 s</span>
      <span class="absolute left-[35.71%] -translate-x-1/2">2.5 s</span>
      <span class="absolute right-0">7 s</span>
    </div>
  </div>
</div>

Compact and specialist charts

Charts that pack a lot into a small space: sparklines inside table cells, a heatmap that is a real table, a conversion funnel and bullet charts against targets.

Table with sparkline cells

  • table
  • hidden sm:table-cell
  • stroke-emerald-700
  • role="img"
  • tabular-nums

A normal table with a fixed 96 by 32 SVG in the trend column, so the polyline never stretches and the end dot stays round. Rising, falling and flat rows get stroke-emerald-700, stroke-rose-700 and stroke-slate-600, and the change cell repeats the direction with an arrow and a signed number. The views column hides under sm: with hidden sm:table-cell so the table fits a phone without scrolling.

View code
<div class="w-full max-w-2xl overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200">
  <div class="px-5 pt-5 sm:px-6">
    <h3 class="text-base font-semibold text-slate-900">Top pages</h3>
    <p class="text-sm text-slate-600">Last 30 days, daily views</p>
  </div>
  <table class="mt-4 w-full text-left text-sm">
    <thead class="border-y border-slate-200 bg-slate-50 text-xs font-semibold text-slate-600">
      <tr>
        <th scope="col" class="py-2.5 pr-2 pl-5 sm:pl-6">Page</th>
        <th scope="col" class="hidden px-2 py-2.5 text-right sm:table-cell">Views</th>
        <th scope="col" class="px-2 py-2.5">Trend</th>
        <th scope="col" class="py-2.5 pr-5 pl-2 text-right sm:pr-6">Change</th>
      </tr>
    </thead>
    <tbody class="divide-y divide-slate-100">
      <tr>
        <th scope="row" class="py-3 pr-2 pl-5 font-medium text-slate-900 sm:pl-6">Pricing</th>
        <td class="hidden px-2 py-3 text-right text-slate-700 tabular-nums sm:table-cell">12,480</td>
        <td class="px-2 py-3"><svg viewBox="0 0 96 32" class="h-8 w-20 sm:w-24" role="img" aria-label="Rising"><polyline points="4,28 12,26.1 20,27 28,23.2 36,20.3 44,22.2 52,18.4 60,14.6 68,15.5 76,11.7 84,6.9 92,4" fill="none" class="stroke-emerald-700" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/><circle cx="92" cy="4" r="2.5" class="fill-emerald-700"/></svg></td>
        <td class="py-3 pr-5 pl-2 text-right sm:pr-6"><span class="inline-flex items-center gap-1 rounded-md bg-emerald-50 px-1.5 py-0.5 text-xs font-semibold text-emerald-800 tabular-nums"><svg viewBox="0 0 12 12" class="size-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5"/></svg>+83%</span></td>
      </tr>
      <tr>
        <th scope="row" class="py-3 pr-2 pl-5 font-medium text-slate-900 sm:pl-6">Grid vs Flexbox</th>
        <td class="hidden px-2 py-3 text-right text-slate-700 tabular-nums sm:table-cell">8,740</td>
        <td class="px-2 py-3"><svg viewBox="0 0 96 32" class="h-8 w-20 sm:w-24" role="img" aria-label="Rising fast"><polyline points="4,28 12,27.1 20,24.3 28,21.1 36,22 44,18.3 52,15.1 60,16 68,11.8 76,9.1 84,6.8 92,4" fill="none" class="stroke-emerald-700" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/><circle cx="92" cy="4" r="2.5" class="fill-emerald-700"/></svg></td>
        <td class="py-3 pr-5 pl-2 text-right sm:pr-6"><span class="inline-flex items-center gap-1 rounded-md bg-emerald-50 px-1.5 py-0.5 text-xs font-semibold text-emerald-800 tabular-nums"><svg viewBox="0 0 12 12" class="size-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5"/></svg>+260%</span></td>
      </tr>
      <tr>
        <th scope="row" class="py-3 pr-2 pl-5 font-medium text-slate-900 sm:pl-6">Getting started</th>
        <td class="hidden px-2 py-3 text-right text-slate-700 tabular-nums sm:table-cell">9,210</td>
        <td class="px-2 py-3"><svg viewBox="0 0 96 32" class="h-8 w-20 sm:w-24" role="img" aria-label="Falling"><polyline points="4,4 12,7.7 20,5.8 28,9.5 36,13.2 44,11.4 52,15.1 60,18.8 68,16.9 76,22.5 84,26.2 92,28" fill="none" class="stroke-rose-700" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/><circle cx="92" cy="28" r="2.5" class="fill-rose-700"/></svg></td>
        <td class="py-3 pr-5 pl-2 text-right sm:pr-6"><span class="inline-flex items-center gap-1 rounded-md bg-rose-50 px-1.5 py-0.5 text-xs font-semibold text-rose-800 tabular-nums"><svg viewBox="0 0 12 12" class="size-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2v8M2.5 6.5 6 10l3.5-3.5"/></svg>&minus;26%</span></td>
      </tr>
      <tr>
        <th scope="row" class="py-3 pr-2 pl-5 font-medium text-slate-900 sm:pl-6">Changelog</th>
        <td class="hidden px-2 py-3 text-right text-slate-700 tabular-nums sm:table-cell">3,150</td>
        <td class="px-2 py-3"><svg viewBox="0 0 96 32" class="h-8 w-20 sm:w-24" role="img" aria-label="Steady"><polyline points="4,18.4 12,17.2 20,18.4 28,14.8 36,16 44,17.2 52,13.6 60,14.8 68,16 76,13.6 84,14.8 92,13.6" fill="none" class="stroke-slate-600" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/><circle cx="92" cy="13.6" r="2.5" class="fill-slate-600"/></svg></td>
        <td class="py-3 pr-5 pl-2 text-right sm:pr-6"><span class="inline-flex items-center gap-1 rounded-md bg-emerald-50 px-1.5 py-0.5 text-xs font-semibold text-emerald-800 tabular-nums"><svg viewBox="0 0 12 12" class="size-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5"/></svg>+22%</span></td>
      </tr>
      <tr>
        <th scope="row" class="py-3 pr-2 pl-5 font-medium text-slate-900 sm:pl-6">Careers</th>
        <td class="hidden px-2 py-3 text-right text-slate-700 tabular-nums sm:table-cell">2,060</td>
        <td class="px-2 py-3"><svg viewBox="0 0 96 32" class="h-8 w-20 sm:w-24" role="img" aria-label="Falling"><polyline points="4,4 12,6.2 20,9.5 28,8.4 36,14.9 44,17.1 52,18.2 60,20.4 68,19.3 76,23.6 84,25.8 92,28" fill="none" class="stroke-rose-700" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/><circle cx="92" cy="28" r="2.5" class="fill-rose-700"/></svg></td>
        <td class="py-3 pr-5 pl-2 text-right sm:pr-6"><span class="inline-flex items-center gap-1 rounded-md bg-rose-50 px-1.5 py-0.5 text-xs font-semibold text-rose-800 tabular-nums"><svg viewBox="0 0 12 12" class="size-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2v8M2.5 6.5 6 10l3.5-3.5"/></svg>&minus;55%</span></td>
      </tr>
    </tbody>
  </table>
</div>

Heatmap grid built as a real table

  • bg-cyan-*
  • table-fixed
  • th scope
  • <abbr>
  • tabular-nums

The heatmap is a table with row and column headers, so a screen reader announces the day and time for every number. Five steps of the cyan scale (50, 200, 400, 700, 900) carry the intensity, the text switches from cyan-950 to white on the dark steps to keep 4.5:1, and every cell prints its count, so the colors only speed up scanning. table-fixed keeps the six columns equal at any width.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-4 shadow-sm ring-1 ring-stone-200 sm:p-6">
  <h3 class="text-base font-semibold text-stone-900">When orders come in</h3>
  <p class="text-sm text-stone-600">Average orders per three-hour block, September 2026</p>
  <table class="mt-4 w-full table-fixed border-separate border-spacing-0">
    <caption class="sr-only">Average orders by weekday and three-hour block starting at the time shown. Saturday from 6 pm is the busiest block with 92 orders.</caption>
    <thead>
      <tr class="text-[11px] font-medium text-stone-600">
        <td class="w-9 sm:w-12"></td>
        <th scope="col" class="pb-1 font-medium">6am</th>
        <th scope="col" class="pb-1 font-medium">9am</th>
        <th scope="col" class="pb-1 font-medium">12pm</th>
        <th scope="col" class="pb-1 font-medium">3pm</th>
        <th scope="col" class="pb-1 font-medium">6pm</th>
        <th scope="col" class="pb-1 font-medium">9pm</th>
      </tr>
    </thead>
    <tbody>
        <tr><th scope="row" class="pr-2 text-left text-xs font-medium text-stone-600"><abbr title="Monday" class="no-underline">Mon</abbr></th><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-50 text-cyan-950">12</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-200 text-cyan-950">38</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">54</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">41</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-700 text-white">63</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-200 text-cyan-950">22</span></td></tr>
        <tr><th scope="row" class="pr-2 text-left text-xs font-medium text-stone-600"><abbr title="Tuesday" class="no-underline">Tue</abbr></th><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-50 text-cyan-950">10</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-200 text-cyan-950">35</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">51</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">44</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-700 text-white">66</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-200 text-cyan-950">25</span></td></tr>
        <tr><th scope="row" class="pr-2 text-left text-xs font-medium text-stone-600"><abbr title="Wednesday" class="no-underline">Wed</abbr></th><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-50 text-cyan-950">14</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">40</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">58</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">47</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-700 text-white">71</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-200 text-cyan-950">27</span></td></tr>
        <tr><th scope="row" class="pr-2 text-left text-xs font-medium text-stone-600"><abbr title="Thursday" class="no-underline">Thu</abbr></th><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-50 text-cyan-950">11</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-200 text-cyan-950">37</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">55</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">49</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-700 text-white">78</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-200 text-cyan-950">31</span></td></tr>
        <tr><th scope="row" class="pr-2 text-left text-xs font-medium text-stone-600"><abbr title="Friday" class="no-underline">Fri</abbr></th><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-50 text-cyan-950">9</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-200 text-cyan-950">29</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">47</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">52</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-900 text-white">84</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">46</span></td></tr>
        <tr><th scope="row" class="pr-2 text-left text-xs font-medium text-stone-600"><abbr title="Saturday" class="no-underline">Sat</abbr></th><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-50 text-cyan-950">4</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-50 text-cyan-950">18</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-700 text-white">62</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-700 text-white">70</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-900 text-white">92</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-400 text-cyan-950">58</span></td></tr>
        <tr><th scope="row" class="pr-2 text-left text-xs font-medium text-stone-600"><abbr title="Sunday" class="no-underline">Sun</abbr></th><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-50 text-cyan-950">3</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-200 text-cyan-950">21</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-700 text-white">68</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-700 text-white">74</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-900 text-white">81</span></td><td class="p-0.5"><span class="grid h-9 place-items-center rounded-md text-xs font-medium tabular-nums bg-cyan-200 text-cyan-950">33</span></td></tr>
    </tbody>
  </table>
  <div class="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-600" aria-hidden="true">
    <span>Orders</span>
    <span class="flex gap-1">
      <span class="rounded bg-cyan-50 px-1.5 py-0.5 text-cyan-950 ring-1 ring-cyan-100 tabular-nums">0&ndash;19</span>
      <span class="rounded bg-cyan-200 px-1.5 py-0.5 text-cyan-950 tabular-nums">20&ndash;39</span>
      <span class="rounded bg-cyan-400 px-1.5 py-0.5 text-cyan-950 tabular-nums">40&ndash;59</span>
      <span class="rounded bg-cyan-700 px-1.5 py-0.5 text-white tabular-nums">60&ndash;79</span>
      <span class="rounded bg-cyan-900 px-1.5 py-0.5 text-white tabular-nums">80+</span>
    </span>
  </div>
</div>

Conversion funnel with step drop-off

  • mx-auto
  • style="width:โ€ฆ"
  • ol
  • tabular-nums
  • bg-violet-*

An ordered list where each step is a centered bar (mx-auto plus an inline width) under a label row with the count and its share of all visitors. Between the steps a small line states the step conversion and the drop-off in words, which is the number product teams actually ask for. The violet shades lighten down the funnel, but the text carries every value, so nothing depends on telling the shades apart.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 sm:p-6">
  <div class="flex flex-wrap items-baseline justify-between gap-2">
    <h3 class="text-base font-semibold text-slate-900">Checkout funnel</h3>
    <p class="text-sm text-slate-600">Last 7 days</p>
  </div>
  <ol class="mt-5">
    <li>
      <div class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">Viewed a product</span><span class="tabular-nums text-slate-600"><span class="font-semibold text-slate-900">12,400</span> (100%)</span></div>
      <div class="mx-auto mt-1.5 h-9 w-full rounded-md bg-violet-900" aria-hidden="true"></div>
    </li>
    <li class="flex items-center justify-center gap-1.5 py-2 text-xs text-slate-600" aria-hidden="true"><svg viewBox="0 0 12 12" class="size-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2v8M2.5 6.5 6 10l3.5-3.5"/></svg>40% added to cart, 60% left</li>
    <li>
      <div class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">Added to cart</span><span class="tabular-nums text-slate-600"><span class="font-semibold text-slate-900">4,960</span> (40%)</span></div>
      <div class="mx-auto mt-1.5 h-9 rounded-md bg-violet-800" style="width:40%" aria-hidden="true"></div>
    </li>
    <li class="flex items-center justify-center gap-1.5 py-2 text-xs text-slate-600" aria-hidden="true"><svg viewBox="0 0 12 12" class="size-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2v8M2.5 6.5 6 10l3.5-3.5"/></svg>45% started checkout, 55% left</li>
    <li>
      <div class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">Started checkout</span><span class="tabular-nums text-slate-600"><span class="font-semibold text-slate-900">2,232</span> (18%)</span></div>
      <div class="mx-auto mt-1.5 h-9 rounded-md bg-violet-700" style="width:18%" aria-hidden="true"></div>
    </li>
    <li class="flex items-center justify-center gap-1.5 py-2 text-xs text-slate-600" aria-hidden="true"><svg viewBox="0 0 12 12" class="size-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2v8M2.5 6.5 6 10l3.5-3.5"/></svg>67% paid, 33% left</li>
    <li>
      <div class="flex items-baseline justify-between gap-3 text-sm"><span class="font-medium text-slate-800">Paid</span><span class="tabular-nums text-slate-600"><span class="font-semibold text-slate-900">1,488</span> (12%)</span></div>
      <div class="mx-auto mt-1.5 h-9 rounded-md bg-violet-600" style="width:12%" aria-hidden="true"></div>
    </li>
  </ol>
  <p class="mt-5 rounded-lg bg-violet-50 px-4 py-3 text-sm text-violet-900">The biggest loss is before the cart: 7,440 visitors viewed a product and left.</p>
</div>

Bullet charts against quarterly targets

  • absolute
  • inset-y-1
  • bg-stone-*
  • -translate-x-1/2
  • role="img"

A bullet chart replaces a gauge in a fraction of the space: stone-200, stone-300 and stone-400 bands mark good, fair and poor, a thin stone-900 bar shows the actual value, and a rose-700 tick shows the target. The measure and the target differ in shape as well as color, every row states both numbers and a status word, and each chart is one role="img" element with the full reading.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-stone-200 sm:p-6">
  <h3 class="text-base font-semibold text-stone-900">Q3 targets</h3>
  <p class="text-sm text-stone-600">Quarter closed 30 September 2026</p>
  <ul class="mt-5 space-y-5">
    <li>
      <div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 text-sm">
        <span class="font-medium text-stone-900">Revenue</span>
        <span class="text-stone-600 tabular-nums"><span class="font-semibold text-stone-900">$1.24M</span> of $1.40M <span class="ml-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-xs font-semibold text-amber-900">Behind, 89%</span></span>
      </div>
      <div class="relative mt-2 h-8 overflow-hidden rounded bg-stone-200" role="img" aria-label="Revenue: 1.24 million dollars against a 1.40 million target, in the fair band">
        <span class="absolute inset-y-0 left-0 w-[75%] bg-stone-300"></span>
        <span class="absolute inset-y-0 left-0 w-[56.25%] bg-stone-400"></span>
        <span class="absolute top-1/2 left-0 h-3 w-[77.5%] -translate-y-1/2 bg-stone-900"></span>
        <span class="absolute inset-y-1 left-[87.5%] w-1 -translate-x-1/2 rounded-full bg-rose-700"></span>
      </div>
    </li>
    <li>
      <div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 text-sm">
        <span class="font-medium text-stone-900">New customers</span>
        <span class="text-stone-600 tabular-nums"><span class="font-semibold text-stone-900">412</span> of 380 <span class="ml-1 rounded-md bg-emerald-50 px-1.5 py-0.5 text-xs font-semibold text-emerald-800">Ahead, 108%</span></span>
      </div>
      <div class="relative mt-2 h-8 overflow-hidden rounded bg-stone-200" role="img" aria-label="New customers: 412 against a target of 380, in the good band">
        <span class="absolute inset-y-0 left-0 w-[70%] bg-stone-300"></span>
        <span class="absolute inset-y-0 left-0 w-[50%] bg-stone-400"></span>
        <span class="absolute top-1/2 left-0 h-3 w-[82.4%] -translate-y-1/2 bg-stone-900"></span>
        <span class="absolute inset-y-1 left-[76%] w-1 -translate-x-1/2 rounded-full bg-rose-700"></span>
      </div>
    </li>
    <li>
      <div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 text-sm">
        <span class="font-medium text-stone-900">Support rating</span>
        <span class="text-stone-600 tabular-nums"><span class="font-semibold text-stone-900">4.3</span> of 4.5 <span class="ml-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-xs font-semibold text-amber-900">Close, 96%</span></span>
      </div>
      <div class="relative mt-2 h-8 overflow-hidden rounded bg-stone-200" role="img" aria-label="Support rating: 4.3 out of 5 against a target of 4.5, in the good band">
        <span class="absolute inset-y-0 left-0 w-[84%] bg-stone-300"></span>
        <span class="absolute inset-y-0 left-0 w-[70%] bg-stone-400"></span>
        <span class="absolute top-1/2 left-0 h-3 w-[86%] -translate-y-1/2 bg-stone-900"></span>
        <span class="absolute inset-y-1 left-[90%] w-1 -translate-x-1/2 rounded-full bg-rose-700"></span>
      </div>
    </li>
  </ul>
  <ul class="mt-5 flex flex-wrap gap-x-4 gap-y-2 border-t border-stone-100 pt-4 text-xs text-stone-700" aria-label="Legend">
    <li class="flex items-center gap-1.5"><span class="h-2 w-4 bg-stone-900" aria-hidden="true"></span>Actual</li>
    <li class="flex items-center gap-1.5"><span class="h-3.5 w-1 rounded-full bg-rose-700" aria-hidden="true"></span>Target</li>
    <li class="flex items-center gap-1.5"><span class="size-3 bg-stone-400" aria-hidden="true"></span>Poor</li>
    <li class="flex items-center gap-1.5"><span class="size-3 bg-stone-300" aria-hidden="true"></span>Fair</li>
    <li class="flex items-center gap-1.5"><span class="size-3 bg-stone-200 ring-1 ring-stone-300" aria-hidden="true"></span>Good</li>
  </ul>
</div>

Charts that react and explain themselves

A period switcher that rebuilds its bars with full class names, a scatter plot that follows a dark toggle through dark: fill and stroke classes, and a chart with a data table one click away.

Week, month and year switcher that rebuilds the bars

  • has-checked:
  • grid-cols-7
  • grid-cols-12
  • createElement
  • aria-label

Pick Week, Month or Year.

The switcher is a native radio group styled with has-checked:, and on change the JS rebuilds the bar list with createElement and textContent. Every class it assigns is written out in full in the script (grid-cols-7, grid-cols-4, grid-cols-12, bg-amber-700, bg-amber-900), because Tailwind scans the JS as text and would never generate a class glued together at runtime. Heights go through inline styles, the peak bar gets a darker shade plus a Peak label, and the chart aria-label and summary line are rewritten with the new totals.

View code
<div class="w-full max-w-2xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 sm:p-6">
  <div class="flex flex-wrap items-start justify-between gap-3">
    <div>
      <h3 class="text-base font-semibold text-slate-900">Solar generation</h3>
      <p id="tch-pd-sum" class="text-sm text-slate-600 tabular-nums">Total 130.5 kWh, average 18.6 kWh per day</p>
    </div>
    <fieldset class="flex rounded-lg bg-slate-100 p-1">
      <legend class="sr-only">Period</legend>
      <label class="flex min-h-9 cursor-pointer items-center rounded-md px-3.5 text-sm font-medium text-slate-700 hover:text-slate-900 has-checked:bg-white has-checked:text-slate-900 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-amber-800"><input type="radio" name="tch-pd" value="week" class="tch-pd-opt sr-only" checked>Week</label>
      <label class="flex min-h-9 cursor-pointer items-center rounded-md px-3.5 text-sm font-medium text-slate-700 hover:text-slate-900 has-checked:bg-white has-checked:text-slate-900 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-amber-800"><input type="radio" name="tch-pd" value="month" class="tch-pd-opt sr-only">Month</label>
      <label class="flex min-h-9 cursor-pointer items-center rounded-md px-3.5 text-sm font-medium text-slate-700 hover:text-slate-900 has-checked:bg-white has-checked:text-slate-900 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-amber-800"><input type="radio" name="tch-pd" value="year" class="tch-pd-opt sr-only">Year</label>
    </fieldset>
  </div>
  <div id="tch-pd-chart" role="img" aria-label="Solar generation per day this week" class="mt-6">
    <ol id="tch-pd-bars" class="grid h-52 grid-cols-7 items-end gap-1.5 border-b border-slate-300 sm:gap-3" aria-hidden="true"></ol>
    <ol id="tch-pd-labels" class="mt-2 grid grid-cols-7 gap-1.5 text-center text-[11px] text-slate-600 sm:gap-3 sm:text-xs" aria-hidden="true"></ol>
  </div>
</div>

Scatter plot that follows a dark mode toggle

  • dark:fill-sky-400
  • dark:stroke-slate-700
  • stroke-2
  • aria-pressed
  • @custom-variant dark

Switch the theme with the toggle.

Every SVG mark carries its light and dark color side by side: fill-sky-700 dark:fill-sky-400 on the circles, stroke-orange-700 dark:stroke-orange-300 on the triangles, stroke-slate-200 dark:stroke-slate-700 on the gridlines. The toggle only adds or removes the dark class on the wrapper, and because the page compiles dark: against .dark through @custom-variant, the whole chart re-themes in one step with no colors in JS. The series differ by shape too, filled circles against hollow triangles.

View code
<div id="tch-dk" class="w-full max-w-xl">
  <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 sm:p-6 dark:bg-slate-900 dark:shadow-none dark:ring-white/10">
    <div class="flex flex-wrap items-start justify-between gap-3">
      <div>
        <h3 class="text-base font-semibold text-slate-900 dark:text-white">Page weight against LCP</h3>
        <p class="text-sm text-slate-600 dark:text-slate-400">14 pages, mobile, 75th percentile</p>
      </div>
      <button id="tch-dk-toggle" type="button" aria-pressed="false" class="group inline-flex min-h-10 items-center gap-2.5 rounded-full px-3 text-sm font-medium text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 dark:text-slate-200 dark:ring-slate-600 dark:hover:bg-slate-800 dark:focus-visible:outline-sky-400">
        <span class="relative h-5 w-9 rounded-full bg-slate-300 group-aria-pressed:bg-sky-500" aria-hidden="true"><span class="absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm group-aria-pressed:translate-x-4 motion-safe:transition-transform"></span></span>
        Dark theme
      </button>
    </div>
    <ul class="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-sm text-slate-700 dark:text-slate-300" aria-label="Legend">
      <li class="flex items-center gap-2"><svg viewBox="0 0 12 12" class="size-3" aria-hidden="true"><circle cx="6" cy="6" r="5" class="fill-sky-700 dark:fill-sky-400"/></svg>Blog posts</li>
      <li class="flex items-center gap-2"><svg viewBox="0 0 14 14" class="size-3.5" aria-hidden="true"><path d="M7 1.5L12.5 11.5L1.5 11.5Z" class="fill-white stroke-orange-700 dark:fill-slate-900 dark:stroke-orange-300" stroke-width="2" stroke-linejoin="round"/></svg>Landing pages</li>
      <li class="flex items-center gap-2"><svg viewBox="0 0 24 8" class="h-2 w-6" aria-hidden="true"><path d="M0 4h24" class="stroke-emerald-700 dark:stroke-emerald-400" stroke-width="2" stroke-dasharray="4 3"/></svg>2.5 s target</li>
    </ul>
    <div class="mt-5 grid grid-cols-[2.25rem_1fr] gap-x-2">
      <div class="relative text-right text-xs text-slate-600 tabular-nums dark:text-slate-400" aria-hidden="true">
        <span class="absolute right-0 bottom-0 translate-y-1/2">0 s</span>
        <span class="absolute right-0 bottom-[40%] translate-y-1/2">2 s</span>
        <span class="absolute right-0 bottom-[80%] translate-y-1/2">4 s</span>
      </div>
      <div class="relative aspect-[8/5]">
        <svg viewBox="0 0 320 200" class="absolute inset-0 size-full overflow-visible" role="img" aria-label="Scatter plot. All seven blog posts weigh under 1.5 MB and load under the 2.5 second target. Every landing page above 2 MB misses it, the worst at 3.8 MB and 4.6 seconds.">
          <g class="stroke-slate-200 dark:stroke-slate-700" stroke-width="1">
            <path d="M0 40H320M0 80H320M0 120H320M0 160H320M80 0V200M160 0V200M240 0V200"/>
          </g>
          <path d="M0 200H320M0 0V200" class="stroke-slate-400 dark:stroke-slate-500" stroke-width="1.5" fill="none"/>
          <path d="M0 100H320" class="stroke-emerald-700 dark:stroke-emerald-400" stroke-width="2" stroke-dasharray="6 4"/>
          <circle cx="48" cy="152" r="5" class="fill-sky-700 dark:fill-sky-400"/>
          <circle cx="64" cy="140" r="5" class="fill-sky-700 dark:fill-sky-400"/>
          <circle cx="88" cy="132" r="5" class="fill-sky-700 dark:fill-sky-400"/>
          <circle cx="104" cy="116" r="5" class="fill-sky-700 dark:fill-sky-400"/>
          <circle cx="72" cy="144" r="5" class="fill-sky-700 dark:fill-sky-400"/>
          <circle cx="112" cy="108" r="5" class="fill-sky-700 dark:fill-sky-400"/>
          <circle cx="56" cy="156" r="5" class="fill-sky-700 dark:fill-sky-400"/>
          <g class="fill-white stroke-orange-700 dark:fill-slate-900 dark:stroke-orange-300" stroke-width="2" stroke-linejoin="round">
            <path d="M144 106L149.5 116L138.5 116Z"/>
            <path d="M184 78L189.5 88L178.5 88Z"/>
            <path d="M216 58L221.5 68L210.5 68Z"/>
            <path d="M248 70L253.5 80L242.5 80Z"/>
            <path d="M280 26L285.5 36L274.5 36Z"/>
            <path d="M168 90L173.5 100L162.5 100Z"/>
            <path d="M304 10L309.5 20L298.5 20Z"/>
          </g>
        </svg>
      </div>
      <div></div>
      <div class="relative mt-2 h-4 text-xs text-slate-600 tabular-nums dark:text-slate-400" aria-hidden="true">
        <span class="absolute left-0">0 MB</span>
        <span class="absolute left-1/4 -translate-x-1/2">1</span>
        <span class="absolute left-1/2 -translate-x-1/2">2</span>
        <span class="absolute left-3/4 -translate-x-1/2">3</span>
        <span class="absolute right-0">4 MB</span>
      </div>
    </div>
  </div>
</div>

Accessible chart with a data table toggle

  • <figure>
  • <figcaption>
  • sr-only
  • aria-expanded
  • border-dashed

Click Show data table.

The chart lives in a figure whose figcaption states the takeaway in words, the bars are aria-hidden, and the full numbers sit in a table that starts with sr-only: screen readers always get it, and the button removes sr-only so everyone can see it. The button reports its state with aria-expanded. Rainfall bars and the dashed 30-year average differ in form, a solid bar against a dashed line, so the comparison survives without color.

View code
<figure class="w-full max-w-2xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 sm:p-6">
  <figcaption>
    <span class="block text-base font-semibold text-slate-900">Rainfall at Kestrel Bay, 2025</span>
    <span class="mt-1 block text-sm text-slate-600">October brought 212 mm, more than twice April, and the year ended 121 mm above the 30-year average.</span>
  </figcaption>
  <ul class="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-sm text-slate-700" aria-hidden="true">
    <li class="flex items-center gap-2"><span class="h-3.5 w-3 rounded-sm bg-blue-800"></span>2025 rainfall</li>
    <li class="flex items-center gap-2"><span class="w-5 border-t-2 border-dashed border-slate-900"></span>30-year average</li>
  </ul>
  <div class="mt-5 grid grid-cols-[2rem_1fr] gap-x-2" aria-hidden="true">
    <div class="relative h-48 text-right text-xs text-slate-600 tabular-nums">
      <span class="absolute right-0 bottom-0 translate-y-1/2">0</span>
      <span class="absolute right-0 bottom-[40%] translate-y-1/2">100</span>
      <span class="absolute right-0 bottom-[80%] translate-y-1/2">200</span>
    </div>
    <div class="relative h-48">
      <div class="absolute inset-x-0 bottom-0 border-t border-slate-300"></div>
      <div class="absolute inset-x-0 bottom-[40%] border-t border-slate-200"></div>
      <div class="absolute inset-x-0 bottom-[80%] border-t border-slate-200"></div>
      <ul class="relative grid h-full grid-cols-12 gap-1 sm:gap-2">
        <li class="relative h-full"><span class="absolute inset-x-0 bottom-0 h-[59.2%] rounded-t-sm bg-blue-800"></span><span class="absolute -inset-x-0.5 bottom-[52.8%] border-t-2 border-dashed border-slate-900"></span></li>
        <li class="relative h-full"><span class="absolute inset-x-0 bottom-0 h-[48.4%] rounded-t-sm bg-blue-800"></span><span class="absolute -inset-x-0.5 bottom-[41.6%] border-t-2 border-dashed border-slate-900"></span></li>
        <li class="relative h-full"><span class="absolute inset-x-0 bottom-0 h-[43.6%] rounded-t-sm bg-blue-800"></span><span class="absolute -inset-x-0.5 bottom-[40.4%] border-t-2 border-dashed border-slate-900"></span></li>
        <li class="relative h-full"><span class="absolute inset-x-0 bottom-0 h-[33.6%] rounded-t-sm bg-blue-800"></span><span class="absolute -inset-x-0.5 bottom-[31.2%] border-t-2 border-dashed border-slate-900"></span></li>
        <li class="relative h-full"><span class="absolute inset-x-0 bottom-0 h-[28.8%] rounded-t-sm bg-blue-800"></span><span class="absolute -inset-x-0.5 bottom-[28%] border-t-2 border-dashed border-slate-900"></span></li>
        <li class="relative h-full"><span class="absolute inset-x-0 bottom-0 h-[26.4%] rounded-t-sm bg-blue-800"></span><span class="absolute -inset-x-0.5 bottom-[29.6%] border-t-2 border-dashed border-slate-900"></span></li>
        <li class="relative h-full"><span class="absolute inset-x-0 bottom-0 h-[31.6%] rounded-t-sm bg-blue-800"></span><span class="absolute -inset-x-0.5 bottom-[34%] border-t-2 border-dashed border-slate-900"></span></li>
        <li class="relative h-full"><span class="absolute inset-x-0 bottom-0 h-[39.2%] rounded-t-sm bg-blue-800"></span><span class="absolute -inset-x-0.5 bottom-[40.8%] border-t-2 border-dashed border-slate-900"></span></li>
        <li class="relative h-full"><span class="absolute inset-x-0 bottom-0 h-[56.4%] rounded-t-sm bg-blue-800"></span><span class="absolute -inset-x-0.5 bottom-[51.2%] border-t-2 border-dashed border-slate-900"></span></li>
        <li class="relative h-full"><span class="absolute inset-x-0 bottom-0 h-[84.8%] rounded-t-sm bg-blue-800"></span><span class="absolute -inset-x-0.5 bottom-[66%] border-t-2 border-dashed border-slate-900"></span></li>
        <li class="relative h-full"><span class="absolute inset-x-0 bottom-0 h-[74.8%] rounded-t-sm bg-blue-800"></span><span class="absolute -inset-x-0.5 bottom-[68%] border-t-2 border-dashed border-slate-900"></span></li>
        <li class="relative h-full"><span class="absolute inset-x-0 bottom-0 h-[65.2%] rounded-t-sm bg-blue-800"></span><span class="absolute -inset-x-0.5 bottom-[60%] border-t-2 border-dashed border-slate-900"></span></li>
      </ul>
    </div>
    <div></div>
    <ul class="mt-2 grid grid-cols-12 gap-1 text-center text-[11px] text-slate-600 sm:gap-2 sm:text-xs">
      <li><span class="sm:hidden">J</span><span class="hidden sm:inline">Jan</span></li>
      <li><span class="sm:hidden">F</span><span class="hidden sm:inline">Feb</span></li>
      <li><span class="sm:hidden">M</span><span class="hidden sm:inline">Mar</span></li>
      <li><span class="sm:hidden">A</span><span class="hidden sm:inline">Apr</span></li>
      <li><span class="sm:hidden">M</span><span class="hidden sm:inline">May</span></li>
      <li><span class="sm:hidden">J</span><span class="hidden sm:inline">Jun</span></li>
      <li><span class="sm:hidden">J</span><span class="hidden sm:inline">Jul</span></li>
      <li><span class="sm:hidden">A</span><span class="hidden sm:inline">Aug</span></li>
      <li><span class="sm:hidden">S</span><span class="hidden sm:inline">Sep</span></li>
      <li><span class="sm:hidden">O</span><span class="hidden sm:inline">Oct</span></li>
      <li><span class="sm:hidden">N</span><span class="hidden sm:inline">Nov</span></li>
      <li><span class="sm:hidden">D</span><span class="hidden sm:inline">Dec</span></li>
    </ul>
  </div>
  <button id="tch-ax-btn" type="button" aria-expanded="false" aria-controls="tch-ax-table" class="mt-5 inline-flex min-h-11 items-center gap-2 rounded-lg px-4 text-sm font-semibold text-blue-800 ring-1 ring-blue-200 hover:bg-blue-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-800">
    <svg viewBox="0 0 20 20" class="size-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="14" height="12" rx="1.5"/><path d="M3 8h14M3 12h14M8 4v12"/></svg>
    <span id="tch-ax-label">Show data table</span>
  </button>
  <div id="tch-ax-table" class="sr-only">
    <table class="mt-4 w-full text-left text-sm">
      <caption class="sr-only">Monthly rainfall at Kestrel Bay in 2025 compared with the 30-year average, in millimetres</caption>
      <thead class="border-b border-slate-200 text-xs font-semibold text-slate-600">
        <tr><th scope="col" class="py-2 pr-3">Month</th><th scope="col" class="px-3 py-2 text-right">2025 (mm)</th><th scope="col" class="px-3 py-2 text-right">Average (mm)</th><th scope="col" class="py-2 pl-3 text-right">Difference</th></tr>
      </thead>
      <tbody class="divide-y divide-slate-100 text-slate-800 tabular-nums">
        <tr><th scope="row" class="py-1.5 pr-3 font-medium">January</th><td class="px-3 py-1.5 text-right">148</td><td class="px-3 py-1.5 text-right">132</td><td class="py-1.5 pl-3 text-right">+16</td></tr>
        <tr><th scope="row" class="py-1.5 pr-3 font-medium">February</th><td class="px-3 py-1.5 text-right">121</td><td class="px-3 py-1.5 text-right">104</td><td class="py-1.5 pl-3 text-right">+17</td></tr>
        <tr><th scope="row" class="py-1.5 pr-3 font-medium">March</th><td class="px-3 py-1.5 text-right">109</td><td class="px-3 py-1.5 text-right">101</td><td class="py-1.5 pl-3 text-right">+8</td></tr>
        <tr><th scope="row" class="py-1.5 pr-3 font-medium">April</th><td class="px-3 py-1.5 text-right">84</td><td class="px-3 py-1.5 text-right">78</td><td class="py-1.5 pl-3 text-right">+6</td></tr>
        <tr><th scope="row" class="py-1.5 pr-3 font-medium">May</th><td class="px-3 py-1.5 text-right">72</td><td class="px-3 py-1.5 text-right">70</td><td class="py-1.5 pl-3 text-right">+2</td></tr>
        <tr><th scope="row" class="py-1.5 pr-3 font-medium">June</th><td class="px-3 py-1.5 text-right">66</td><td class="px-3 py-1.5 text-right">74</td><td class="py-1.5 pl-3 text-right">&minus;8</td></tr>
        <tr><th scope="row" class="py-1.5 pr-3 font-medium">July</th><td class="px-3 py-1.5 text-right">79</td><td class="px-3 py-1.5 text-right">85</td><td class="py-1.5 pl-3 text-right">&minus;6</td></tr>
        <tr><th scope="row" class="py-1.5 pr-3 font-medium">August</th><td class="px-3 py-1.5 text-right">98</td><td class="px-3 py-1.5 text-right">102</td><td class="py-1.5 pl-3 text-right">&minus;4</td></tr>
        <tr><th scope="row" class="py-1.5 pr-3 font-medium">September</th><td class="px-3 py-1.5 text-right">141</td><td class="px-3 py-1.5 text-right">128</td><td class="py-1.5 pl-3 text-right">+13</td></tr>
        <tr><th scope="row" class="py-1.5 pr-3 font-medium">October</th><td class="px-3 py-1.5 text-right">212</td><td class="px-3 py-1.5 text-right">165</td><td class="py-1.5 pl-3 text-right">+47</td></tr>
        <tr><th scope="row" class="py-1.5 pr-3 font-medium">November</th><td class="px-3 py-1.5 text-right">187</td><td class="px-3 py-1.5 text-right">170</td><td class="py-1.5 pl-3 text-right">+17</td></tr>
        <tr><th scope="row" class="py-1.5 pr-3 font-medium">December</th><td class="px-3 py-1.5 text-right">163</td><td class="px-3 py-1.5 text-right">150</td><td class="py-1.5 pl-3 text-right">+13</td></tr>
      </tbody>
      <tfoot class="border-t border-slate-300 font-semibold text-slate-900 tabular-nums">
        <tr><th scope="row" class="py-2 pr-3">Year</th><td class="px-3 py-2 text-right">1,480</td><td class="px-3 py-2 text-right">1,359</td><td class="py-2 pl-3 text-right">+121</td></tr>
      </tfoot>
    </table>
  </div>
</figure>

What are Tailwind charts, and can Tailwind draw them on its own?

No. Tailwind is a utility-first CSS framework, so it handles the card, the labels and the layout around a chart. The lines, slices and axes always come from a library or from hand-written markup.

The term covers any chart whose colors, spacing, type and dark mode behavior come from utility classes and theme variables, not from a separate chart theme file.

Chart.js or ApexCharts can draw the chart while Tailwind styles everything around it. shadcn/ui charts and Tremor skip some of that setup by shipping chart code that already reads Tailwind tokens. And if the chart is simple enough, divs sized with width and height utilities do the job with no library at all.

That last route stops at bar charts and progress bars built from width utilities. Line, pie and radar charts need SVG or canvas rendering.

Colors are where most projects trip. Libraries read them from an options object or a CSS variable, not from classes on the chart marks, so the Tailwind palette has to be passed in by hand.

Which chart library fits a Tailwind project best?

Your framework narrows this fast. Tremor and shadcn/ui charts are React only, so anything outside React rules out two of the five straight away.

LibraryRendererFrameworksTailwind styling channel
TremorSVG (Recharts)ReactClass tokens and color props
shadcn/ui chartsSVG (Recharts)React, Next.jsCSS variables from a config object
Flowbite chartsSVG (ApexCharts)Plain HTML firstUtility classes plus ApexCharts options
Chart.jsCanvasAny, with React and Vue wrappersOptions object only
D3.js and visxSVG (built by you)Any, visx is ReactClasses on every mark

Weight differs more than the table shows. TanStack publishes a competing charting library, so read its benchmark as a rough gap, not a promise.

Its comparison (baseline date 2026-10-14) measures minified, gzip-compressed bundles and lists Chart.js at 45.78 to 59.61 kB across its test charts. Recharts lands at 156.76 to 172.30 kB, or 97.24 to 112.60 kB with React externalized.

Both shadcn/ui charts and Tremor sit on Recharts, so they inherit that weight.

Tremor

Vercel acquired Tremor on January 22, 2025, and made all Tremor products, including Tremor Blocks, free and open source (Vercel announcement).

The @tremor/react package builds on React, Tailwind CSS, Headless UI and Recharts. Its cards, KPI blocks and charts suit a Tailwind dashboard where everything should share one look.

Series colors take Tailwind palette names such as indigo and rose, and you get area, bar, line and donut charts plus sparklines. The KPI cards and charts share one design language, which is the real draw.

It only works with React. There’s also a version question. Its installation docs ask for Tailwind CSS v3.4+, the last stable @tremor/react release is 3.18.7 from January 2025, and most of its dashboard templates still list Tailwind CSS v3.4 in their stack as of October 2026. Confirm Tailwind v4 support before starting a new project.

shadcn/ui charts

Under the hood this is Recharts with a few Tailwind-aware helpers on top.

The docs state that the component does not wrap Recharts, so you write the Recharts components yourself. The chart.tsx file the CLI adds supplies ChartContainer, tooltip and legend helpers, and colors that follow CSS variables.

The source lives in your repo and the colors follow your theme variables, so there is no wrapper API to learn on top of Recharts. You pay for that in bundle weight, and every Recharts upgrade is yours to apply. It’s React only as well.

Flowbite charts

Flowbite’s chart components are MIT-licensed and sit on ApexCharts, which has its own license terms (see below), with Tailwind classes handling the card, text and spacing around them.

  • Area, line, bar, column, pie and radial examples are ready to paste, and dark mode is supported by default per the Flowbite docs.
  • It runs from a CDN script tag with no build step.
  • Tailwind CSS, Flowbite and ApexCharts all have to be installed, which is more setup than it sounds like for a “just paste it” kit.
  • Svelte projects use @flowbite-svelte-plugins/chart, because the flowbite-svelte docs say the older Chart component will be deprecated.

Flowbite’s changelog records the move to Tailwind v4 in 3.0.0, and newer entries shift color and dark mode handling to CSS variables.

Check which Flowbite and ApexCharts versions a copied example targets, because Flowbite’s chart docs still load apexcharts@3.46.0. ApexCharts releases up to 5.0.0 were MIT. Since 5.2.0 it uses a dual license: the free Community License covers organizations with less than US$2 million in annual revenue, budget or funding, and a paid commercial license applies at or above that, so read the LICENSE file before commercial use.

Chart.js

Chart.js draws on a canvas element, so Tailwind classes never reach the bars or lines. Tailwind styles the wrapper, and the colors, fonts and grid all come from the options object.

That has two annoying side effects. Palette values get copied from Tailwind by hand, so a theme change means editing JavaScript. And responsive sizing depends on a dedicated, relatively positioned wrapper element.

It does have an MIT license, community wrappers for React and Vue, and a lighter bundle than the Recharts-based options. Pick it for dense data and framework flexibility, not for class-level styling.

D3.js and visx for custom builds

Groww’s engineering blog reports replacing a Highcharts bundle of about 96 KB gzipped with an in-house library built on visx, which came in at 23.6 KB gzipped. That is roughly a 75% cut, which the blog itself rounds to about 80%.

D3.js and visx are toolkits, not chart libraries. Every mark is an SVG element you place yourself, so fill and stroke utilities apply to bars, lines and ticks without any workaround.

You get full control and a bundle you shape yourself. The cost is that axes, tooltips, legends and keyboard access are all yours to build, which is why Groww ended up writing a library.

SVG, canvas or plain HTML: which rendering approach works with Tailwind classes?

SVG and plain HTML elements take Tailwind classes directly. Canvas does not, because it paints pixels that no class can reach.

With SVG, every bar, slice and tick is a DOM element, so fill, stroke and dark: utilities apply to it. Canvas is a single element holding a bitmap, which means colors travel in as JavaScript values. Plain HTML elements, meaning divs sized by utilities, get full Tailwind support but no real axes or scales.

Recharts and ApexCharts render SVG, which covers Tremor, shadcn/ui charts and Flowbite. Chart.js renders canvas.

SVG output still does not mean you can put a class on every mark. The library writes the markup, so shadcn/ui’s chart component reaches in with arbitrary variants such as [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground.

Hand-written inline SVG in HTML avoids that detour, because every element is yours and takes fill and stroke utilities directly.

A decent rule of thumb: SVG while the chart stays under a few thousand points, canvas beyond that, plain HTML for a handful of bars.

ApexCharts’ docs describe the SVG node ceiling as typically several thousand points. Its canvas renderer (added in ApexCharts 6.0 and an opt-in import since 7.0) switches the series layer to canvas above 8,000 points by default when the renderer option is set to auto. Without the renderer-canvas import, charts stay on SVG, and axes, grid, tooltips and exports remain SVG either way.

How to build a CSS-only bar chart with utility classes

A flex row, bottom alignment and one div per bar is all it takes. This is the same idea behind CSS-only charts, written in utilities.

<div class="flex h-40 items-end gap-2">
  <div class="w-8 rounded-t bg-sky-500" style="height: 40%"></div>
  <div class="w-8 rounded-t bg-sky-500" style="height: 75%"></div>
  <div class="w-8 rounded-t bg-sky-500" style="height: 55%"></div>
</div>

Heights go in inline styles on purpose. Tailwind scans source files as plain text and cannot read string interpolation, so a class name built from data, such as h-[${value}%], is never generated (Tailwind docs, detecting classes in source files).

This version has no axes or scales and no hover tooltips. Any text alternative is whatever you write by hand.

How to add a chart to a Tailwind project in React, Next.js, Vue or plain HTML

The steps barely change between frameworks. Only the package does. The sizing rule stays the same everywhere: the chart’s container needs a height the browser can measure.

  1. Set up Tailwind CSS and confirm utilities render.
  2. Install the chart package: Chart.js, ApexCharts, Recharts, Tremor or shadcn/ui charts.
  3. Wrap the chart in a card with an explicit height utility such as h-64.
  4. Pass the data, then map each series to a color.
  5. Resize the browser window and check the chart follows its container.

Tailwind owns the card, meaning padding, border, background and height, and a ready-made Tailwind card covers most of that.

React and Next.js

Tailwind has to work first. Setting up Tailwind CSS in a React project is a separate step that takes a few minutes.

  • For shadcn/ui charts, run the CLI add command for chart, then give ChartContainer a min-h value, which the docs call required for responsiveness.
  • Tremor needs @tremor/react installed and the “use client” directive at the top of the file, as its README example does.
  • With Recharts directly, the parent needs a defined size. The responsive prop arrived in Recharts 3.3, and older versions use ResponsiveContainer.

In Next.js, a file that renders chart components goes behind the “use client” directive. The Next.js docs reserve it for components that need state or event handlers, effects, browser APIs or custom hooks, and charts need the browser.

A flex-1 parent is the classic Tailwind trap. A Recharts issue from May 2024 (Recharts 2.12.6) reports a chart that never appeared inside a flex-1 container and only rendered once the nearest parent got an explicit h-[230px].

Vue

Chart.js through the vue-chartjs wrapper is the usual route in Vue.

Install vue-chartjs and chart.js together, register the chart parts you use, then render components such as Bar with data and options props. The vue-chartjs README lists Chart.js v4 support.

Chart.js needs a wrapper that is relatively positioned and dedicated to the canvas, according to its responsive docs. In Tailwind that is a div with relative and a height utility such as h-64, plus maintainAspectRatio set to false.

Plain HTML

Flowbite is the shortest route to a chart in plain HTML. Its docs list Tailwind CSS, Flowbite and ApexCharts as prerequisites, and ApexCharts loads from a CDN script tag.

<div id="area-chart" class="h-64 w-full"></div>
<script>
  const chart = new ApexCharts(document.getElementById("area-chart"), options);
    chart.render();
  </script>

Updating later is one call, updateSeries on the chart object, per the Flowbite docs. Chart.js loads from a script tag the same way, inside the same relative wrapper.

How to style chart colors with Tailwind v4 theme variables

Define the palette once as CSS variables and publish it to Tailwind with @theme. After that, each library needs the variable in its own form. SVG marks take a utility class. Canvas charts need a computed value, and the config-driven ones, shadcn/ui and Flowbite among them, want a variable name.

Tailwind v4 generates utilities from theme variable namespaces. A –color-chart-1 variable therefore produces bg-chart-1, fill-chart-1 and stroke-chart-1, which is what SVG marks need.

@import "tailwindcss";

:root {
  --chart-1: oklch(0.65 0.19 255);
  --chart-2: oklch(0.7 0.15 160);
}

@theme inline {
  --color-chart-1: var(--chart-1);
  --color-chart-2: var(--chart-2);
}

The inline keyword matters when a theme variable points at another variable, and Tailwind’s theme docs recommend it for that case. Point these variables at the tokens your design system already defines, and charts stay in step with buttons and cards.

  • Hand-built SVG from D3.js or visx takes fill-chart-1 and stroke-chart-1 classes straight on the marks.
  • shadcn/ui charts use five variables, –chart-1 to –chart-5, in the global stylesheet, with ChartConfig mapping each data key to a label and a color.
  • Flowbite charts keep series colors in the ApexCharts options, while label text accepts Tailwind utilities through the cssClass option.
  • Chart.js reads the variable at runtime with getComputedStyle, and the result goes into the options object.

In shadcn/ui’s chart component, ChartContainer turns each ChartConfig key into a –color-key variable. The series then reads it as fill=”var(–color-desktop)”.

Snippets from different eras clash here. The v3 docs site for shadcn/ui defines chart colors as HSL channel triplets such as 12 76% 61%, while setups for Tailwind v4 use full OKLCH values and read them as plain var(–chart-1).

A copied snippet that still wraps the variable in hsl() cannot parse an OKLCH value, so the series loses its intended color.

A developer write-up on bgenc.com (August 2023) hit the canvas limit in a React, Tailwind and DaisyUI app. Chart.js could not see the page’s CSS variables, so the fix was reading them with getComputedStyle.

How do Tailwind charts handle dark mode?

Anything you write yourself uses the dark: variant. Anything a library draws gets its colors from CSS variables swapped under a dark selector. SVG charts re-theme the moment the selector changes, while canvas charts have to read their colors again.

In Tailwind v4 the dark: variant follows prefers-color-scheme with no setup. A manual switch needs one line in your CSS, @custom-variant dark (&:where(.dark, .dark *));, which replaces the darkMode setting from v3 config.

A Tailwind toggle that adds or removes the dark class on the html element is all the control needs.

For shadcn/ui charts, declare a second palette under .dark. The chart.tsx file scopes each injected series variable per theme (light with no selector, dark under .dark), and ChartConfig accepts a theme object with light and dark values in place of a single color.

Hand-built SVG just gets dark: utilities on the marks, for example fill-sky-500 dark:fill-sky-400. Chart.js needs the variables re-read after the toggle and update called on each chart instance.

The bgenc.com example re-ran its color lookup whenever the theme switched, so the canvas never kept stale colors.

Flowbite’s own sources disagree here. Its chart docs describe dark mode as supported by default, while its changelog records removing a default dark mode setting from the plugin.

Test the toggle on your own Flowbite version instead of trusting either line.

Keep series order identical in both palettes. Series 1 stays blue in light mode and turns a lighter blue in dark mode, so a legend learned in one theme still works in the other.

Are Tailwind charts accessible?

Not by default. Tailwind adds no accessibility behavior of its own, and each library covers a different share of it, so most of the work lands on you.

Contrast for bars, lines and slices

WCAG 2.2 success criterion 1.4.11 requires a contrast ratio of at least 3:1 for graphical objects needed to understand the content, measured against adjacent colors (W3C).

Bars, lines and pie slices all count. The dark palette needs its own pass, because a color pair that clears 3:1 on white can fail on a dark card, and a color contrast checker run on both palettes catches it.

Axis and tick text follows the text rule, 4.5:1 under criterion 1.4.3. Pie slices with no percentages beside them need 3:1 against their neighbors and the chart background too, per Cornell University’s WCAG test guidance.

Keyboard and screen reader support by library

Recharts has an accessibilityLayer prop that adds arrow-key movement between data points. It is on by default since Recharts 3.0, and shadcn/ui’s examples still set it explicitly.

ApexCharts’ current docs describe keyboard navigation, ARIA support and a chart.accessibility.description option (as of October 2026), and tree-shaken builds import the keyboard feature separately.

Chart.js is the weak spot, since screen readers cannot read canvas content. The canvas needs role=”img” and an aria-label, or fallback content between its tags (Chart.js docs).

ApexCharts’ own conformance report (dated May 15, 2026, covering v5.x) rates criteria such as 1.1.1, 1.3.1 and 4.1.2 as Supports. It adds that custom colors you pass in are outside the library’s control, so final contrast depends on how you configure the chart.

Mantine’s chart package, built on Recharts, turns the accessibility layer on by default and offers an opt-out.

One failure mode is documented. A Recharts discussion (#4662) reports that with macOS VoiceOver running, the axes take over arrow-key navigation and the data points stop being reachable. A reply in the thread says turning VoiceOver’s QuickNav off restores arrow-key movement between the points.

Text alternatives and table fallbacks

Every chart needs its numbers in text form somewhere.

A data table gives screen reader users exact values that hover tooltips never expose. Build it as a properly structured accessible table and hide it visually with Tailwind’s sr-only utility, which keeps it available to assistive technology.

For a static chart, use role img plus a short aria-label that states the takeaway, not just the title. Interactive charts should keep the table next to the chart so keyboard users can switch to the raw values.

Color alone also fails the separate Use of Color criterion, so direct labels or line styles should back up the palette.

When do Tailwind charts fall short?

Mostly when the chart stops being a styled widget and starts acting like an app feature. Print output, server rendering, data-driven colors and copy-pasted code you have to maintain all expose limits in the approach.

Print and PDF output

Tailwind’s print: variant can reshape a layout, but Chart.js does not resize itself when print styles change the container, according to its responsive docs.

The documented workaround hooks the beforeprint event and calls resize on each chart instance. If the print layout is still off, pass an explicit size to resize in beforeprint, then call resize again in afterprint to restore automatic sizing.

Server-rendered pages

ApexCharts’ README now documents server rendering that outputs real SVG on the server and hydrates it on the client, replacing the dynamic import with ssr set to false that Next.js projects used to need (as of October 2026).

Charts that only render in the browser leave an empty card until JavaScript runs. A fixed height utility on the card stops the layout from jumping, and skeleton screens fill the space until the chart arrives.

Series colors that come from data

Colors chosen at runtime need a different fix than inline heights. Register the possible classes ahead of time with @source inline, which accepts brace expansion (Tailwind docs, detecting classes in source files).

@source inline("{fill,stroke}-sky-{300,400,500}");

Keep the safelist short. Every entry ships in the stylesheet whether a chart uses it or not.

Who maintains the chart code

With copy-paste code like shadcn/ui charts, the chart file lives in your repo, so fixes and upgrades are yours. Recent versions of its chart docs carry an Updating to Recharts v3 section.

Packages such as Chart.js, ApexCharts and @tremor/react deliver fixes by version bump, and breaking changes arrive the same way.

If the chart is the product itself, such as a dense trading view, a dedicated charting library with its own theming serves better than Tailwind styling around it. Tailwind still styles the shell.

Tailwind Charts FAQ

Are there free Tailwind chart templates?

Yes. Material Tailwind and David UI each give away one line chart block and keep the rest of their chart blocks behind a PRO tier, and both need the recharts package installed.

The freefrontend collections add Chart.js and ApexCharts examples as free CodePen demos.

Which chart type fits which kind of data?

Match the chart to the question the data answers. Line and area charts show change over time, bar charts compare categories, and donut charts show parts of a whole when there are only a handful of slices.

A sparkline puts a compact trend inside a KPI card.

How do I lazy load a chart below the fold?

Import the chart module only when its card nears the viewport. A dynamic import() fired by an IntersectionObserver works in any framework, and the lazy component APIs in React and Vue do the same job.

Reserve the card height first so the page does not jump when the chart arrives.

How can a chart update with live data?

Change the data, then trigger a redraw. In Recharts, new data held in state redraws the chart. In Chart.js, push values into the dataset and call update(), and in ApexCharts call updateSeries, as the Flowbite docs show.

Can Tailwind charts run in the TALL stack?

Yes. Beartropy Charts is a component library built for Tailwind, Alpine, Laravel and Livewire.

For ApexCharts, the community larapex-charts package wraps the library for Laravel.

Keeping Tailwind Charts Working Through Upgrades

Pin the chart library and Tailwind at fixed versions, and keep the palette in CSS variables. A release then touches one stylesheet and one dependency line instead of every chart.

In practice that means lockfile entries for both packages, series colors that live in variables and not in per-chart options, and a contrast and appearance check in both themes after each bump.

Pinning comes first because it turns a breaking change into a scheduled task. Variables come second so a new color format means editing one block, and the theme check comes last because it only holds once the first two do.

The downside is delay, since a pinned library misses fixes until someone raises the version on purpose. Teams that share tokens across products can settle who owns that bump by following design system best practices.