Open demo

CSS code

HTML
<div class="states">
<div class="tcard">
  <div class="head" aria-hidden="true">Loading</div>
  <div class="tscroll" role="region" tabindex="0" aria-label="Invoices, scrollable">
  <table class="tbl" aria-busy="true">
    <caption class="sr-only">Invoices, loading</caption>
    <thead><tr><th scope="col">Client</th><th scope="col">Status</th><th scope="col" class="num">Amount</th></tr></thead>
    <tbody>
      <tr><td><span class="who"><span class="sk av"></span><span class="sk" style="width:120px"></span></span></td><td><span class="sk" style="width:64px"></span></td><td class="num"><span class="sk" style="width:70px;margin-left:auto"></span></td></tr>
      <tr><td><span class="who"><span class="sk av"></span><span class="sk" style="width:150px"></span></span></td><td><span class="sk" style="width:52px"></span></td><td class="num"><span class="sk" style="width:58px;margin-left:auto"></span></td></tr>
      <tr><td><span class="who"><span class="sk av"></span><span class="sk" style="width:100px"></span></span></td><td><span class="sk" style="width:70px"></span></td><td class="num"><span class="sk" style="width:80px;margin-left:auto"></span></td></tr>
    </tbody>
  </table>
  </div>
</div>

<div class="tcard">
  <div class="head" aria-hidden="true">Empty</div>
  <div class="state blue">
    <span class="ic" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/><circle cx="12" cy="12" r="10"/></svg></span>
    <h2>No invoices yet</h2>
    <p>Invoices appear here once you send your first one. It takes about a minute.</p>
    <div class="acts"><button class="pri" type="button">Create an invoice</button><button type="button">Import from CSV</button></div>
  </div>
</div>

<div class="tcard">
  <div class="head" aria-hidden="true">Error</div>
  <div class="state red" role="alert">
    <span class="ic" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg></span>
    <h2>Could not load invoices</h2>
    <p>The request timed out after 30 seconds. Your data is safe — this is a connection problem.</p>
    <div class="acts"><button class="pri" type="button">Try again</button><button type="button">Contact support</button></div>
  </div>
</div>
</div>
CSS
/* Three states a data table spends real time in. Loading keeps the real <thead> and fills the
   body with skeleton bars; empty and error replace the table with a message and next steps. */
.states{display:grid;gap:18px}
@media(min-width:900px){.states{grid-template-columns:1fr 1fr;align-items:start}
  .states>:first-child{grid-column:1/-1}}
.tcard{background:#fff;border:1px solid #e7e9f0;border-radius:14px;overflow:hidden}
.tscroll{overflow-x:auto}
.tscroll:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.tcard .head{padding:14px 16px;border-bottom:1px solid #eceef3;font-size:.8rem;font-weight:700;
  letter-spacing:.05em;text-transform:uppercase;color:#667085}
.tbl{width:100%;border-collapse:collapse;font-size:.89rem}
.tbl thead th{text-align:left;font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;
  color:#5f6778;font-weight:700;padding:13px 16px;background:#fafbfc;border-bottom:1px solid #eceef3}
.tbl td{padding:13px 16px;border-bottom:1px solid #f2f4f7;color:#3c4048}
.tbl tbody tr:last-child td{border-bottom:0}
.tbl .num{text-align:right}
.tbl .who{display:flex;align-items:center;gap:10px}
/* skeleton bars */
.sk{background:#eceff4;border-radius:6px;height:11px;position:relative;overflow:hidden;display:block}
.sk::after{content:"";position:absolute;inset:0;transform:translateX(-100%);
  background:linear-gradient(90deg,transparent,rgba(255,255,255,.85),transparent);
  animation:shimmer 1.4s infinite}
@keyframes shimmer{100%{transform:translateX(100%)}}
.sk.av{width:28px;height:28px;border-radius:50%;flex-shrink:0}
/* empty and error */
.state{padding:44px 26px;text-align:center}
.state .ic{width:52px;height:52px;border-radius:14px;display:grid;place-items:center;margin:0 auto 16px}
.state .ic svg{display:block;width:24px;height:24px;fill:none;stroke-width:2;stroke-linecap:round}
.state h2{font-size:1rem;margin-bottom:6px;color:#16181c}
.state p{font-size:.87rem;color:#5f6778;line-height:1.6;max-width:44ch;margin:0 auto}
.state .acts{display:flex;gap:9px;justify-content:center;margin-top:18px;flex-wrap:wrap}
.state button{font:inherit;font-size:.85rem;font-weight:700;padding:9px 18px;border-radius:9px;
  cursor:pointer;border:1px solid #e4e7ec;background:#fff;color:#344054}
.state button:hover{background:#f7f8fa}
.state button:focus-visible{outline:2px solid #4f46e5;outline-offset:2px}
.state button.pri{background:#4f46e5;border-color:#4f46e5;color:#fff}
.state button.pri:hover{background:#4338ca}
.blue .ic{background:#eef2ff}.blue .ic svg{stroke:#4f46e5}
.red .ic{background:#fdecec}.red .ic svg{stroke:#dc2626}
@media(prefers-reduced-motion:reduce){.sk::after{animation:none;display:none}}

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div class="states">
<div class="card tcard overflow-hidden">
  <div class="card-header" aria-hidden="true">Loading</div>
  <div class="table-responsive" role="region" tabindex="0" aria-label="Invoices, scrollable">
  <table class="table align-middle mb-0 tbl" aria-busy="true">
    <caption class="visually-hidden">Invoices, loading</caption>
    <thead><tr><th scope="col">Client</th><th scope="col">Status</th><th scope="col" class="text-end">Amount</th></tr></thead>
    <tbody class="placeholder-wave">
      <tr><td><span class="who d-flex align-items-center"><span class="placeholder av rounded-circle"></span><span class="placeholder" style="width:120px"></span></span></td><td><span class="placeholder" style="width:64px"></span></td><td><span class="placeholder ms-auto" style="width:70px"></span></td></tr>
      <tr><td><span class="who d-flex align-items-center"><span class="placeholder av rounded-circle"></span><span class="placeholder" style="width:150px"></span></span></td><td><span class="placeholder" style="width:52px"></span></td><td><span class="placeholder ms-auto" style="width:58px"></span></td></tr>
      <tr><td><span class="who d-flex align-items-center"><span class="placeholder av rounded-circle"></span><span class="placeholder" style="width:100px"></span></span></td><td><span class="placeholder" style="width:70px"></span></td><td><span class="placeholder ms-auto" style="width:80px"></span></td></tr>
    </tbody>
  </table>
  </div>
</div>

<div class="card tcard overflow-hidden">
  <div class="card-header" aria-hidden="true">Empty</div>
  <div class="state blue text-center">
    <span class="ic" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/><circle cx="12" cy="12" r="10"/></svg></span>
    <h2>No invoices yet</h2>
    <p class="mx-auto mb-0">Invoices appear here once you send your first one. It takes about a minute.</p>
    <div class="acts d-flex flex-wrap justify-content-center"><button class="btn btn-primary" type="button">Create an invoice</button><button class="btn btn-light" type="button">Import from CSV</button></div>
  </div>
</div>

<div class="card tcard overflow-hidden">
  <div class="card-header" aria-hidden="true">Error</div>
  <div class="state red text-center" role="alert">
    <span class="ic" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg></span>
    <h2>Could not load invoices</h2>
    <p class="mx-auto mb-0">The request timed out after 30 seconds. Your data is safe — this is a connection problem.</p>
    <div class="acts d-flex flex-wrap justify-content-center"><button class="btn btn-primary" type="button">Try again</button><button class="btn btn-light" type="button">Contact support</button></div>
  </div>
</div>
</div>
CSS
/* Bootstrap .card + .table for loading (with .placeholder-wave skeletons), and .card + .btn for the
   empty and error states. Colours come in through the --bs-card-*, --bs-table-* and --bs-btn-* variables. */
.states{display:grid;gap:18px}
@media(min-width:900px){.states{grid-template-columns:1fr 1fr;align-items:start}
  .states>:first-child{grid-column:1/-1}}
.tcard{--bs-card-border-color:#e7e9f0;--bs-card-border-radius:14px;--bs-card-bg:#fff;
  --bs-card-cap-bg:#fff;--bs-card-cap-padding-x:16px;--bs-card-cap-padding-y:14px}
.tcard .card-header{border-bottom-color:#eceef3;font-size:.8rem;font-weight:700;letter-spacing:.05em;
  text-transform:uppercase;color:#667085}
.tcard .table-responsive:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px}
.tbl{--bs-table-color:#3c4048;--bs-table-bg:#fff;--bs-table-border-color:#f2f4f7;font-size:.89rem}
.tbl > :not(caption) > * > *{padding:13px 16px}
.tbl > tbody > tr:last-child > *{border-bottom-width:0}
.tbl > thead > tr > th{font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;color:#5f6778;
  background:#fafbfc;border-bottom-color:#eceef3}
.tbl .who{gap:10px}
.tbl .placeholder{display:block;height:11px;min-height:0;border-radius:6px;background-color:#eceff4;opacity:1}
.tbl .placeholder.av{width:28px;height:28px;flex-shrink:0}
.state{padding:44px 26px}
.state .ic{width:52px;height:52px;border-radius:14px;display:grid;place-items:center;margin:0 auto 16px}
.state .ic svg{display:block;width:24px;height:24px;fill:none;stroke-width:2;stroke-linecap:round}
.state h2{font-size:1rem;font-weight:700;line-height:normal;margin-bottom:6px;color:#16181c}
.state p{font-size:.87rem;color:#5f6778;line-height:1.6;max-width:44ch}
.state .acts{gap:9px;margin-top:18px}
.state .btn{--bs-btn-padding-x:18px;--bs-btn-padding-y:9px;--bs-btn-font-size:.85rem;--bs-btn-font-weight:700;
  --bs-btn-line-height:normal;--bs-btn-border-radius:9px;--bs-btn-focus-box-shadow:0 0 0 2px #fff,0 0 0 4px #4f46e5}
.state .btn-primary{--bs-btn-bg:#4f46e5;--bs-btn-border-color:#4f46e5;--bs-btn-hover-bg:#4338ca;
  --bs-btn-hover-border-color:#4338ca;--bs-btn-active-bg:#3730a3;--bs-btn-active-border-color:#3730a3}
.state .btn-light{--bs-btn-color:#344054;--bs-btn-bg:#fff;--bs-btn-border-color:#e4e7ec;--bs-btn-hover-color:#344054;
  --bs-btn-hover-bg:#f7f8fa;--bs-btn-hover-border-color:#e4e7ec;--bs-btn-active-bg:#eef0f4;--bs-btn-active-border-color:#e4e7ec}
.blue .ic{background:#eef2ff}.blue .ic svg{stroke:#4f46e5}
.red .ic{background:#fdecec}.red .ic svg{stroke:#dc2626}
@media(prefers-reduced-motion:reduce){.placeholder-wave{animation:none;-webkit-mask-image:none;mask-image:none}}

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="grid gap-[18px] min-[900px]:grid-cols-2 min-[900px]:items-start">
<div class="overflow-hidden rounded-[14px] border border-[#e7e9f0] bg-white min-[900px]:col-span-full">
  <div class="border-b border-[#eceef3] px-4 py-3.5 text-[.8rem] font-bold tracking-[.05em] text-[#667085] uppercase" aria-hidden="true">Loading</div>
  <div class="overflow-x-auto focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5]" role="region" tabindex="0" aria-label="Invoices, scrollable">
  <table class="w-full border-collapse text-[.89rem] [&_td]:border-b [&_td]:border-[#f2f4f7] [&_td]:px-4 [&_td]:py-[13px] [&_tr:last-child_td]:border-b-0" aria-busy="true">
    <caption class="sr-only">Invoices, loading</caption>
    <thead><tr><th scope="col" class="border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-left">Client</th><th scope="col" class="border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-left">Status</th><th scope="col" class="border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-right">Amount</th></tr></thead>
    <tbody>
      <tr><td><span class="flex items-center gap-2.5"><span class="relative block overflow-hidden bg-[#eceff4] after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/85 after:to-transparent motion-reduce:after:hidden size-7 shrink-0 rounded-full"></span><span class="relative block h-[11px] overflow-hidden rounded-md bg-[#eceff4] after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/85 after:to-transparent motion-reduce:after:hidden w-[120px]"></span></span></td><td><span class="relative block h-[11px] overflow-hidden rounded-md bg-[#eceff4] after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/85 after:to-transparent motion-reduce:after:hidden w-16"></span></td><td><span class="relative block h-[11px] overflow-hidden rounded-md bg-[#eceff4] after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/85 after:to-transparent motion-reduce:after:hidden ml-auto w-[70px]"></span></td></tr>
      <tr><td><span class="flex items-center gap-2.5"><span class="relative block overflow-hidden bg-[#eceff4] after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/85 after:to-transparent motion-reduce:after:hidden size-7 shrink-0 rounded-full"></span><span class="relative block h-[11px] overflow-hidden rounded-md bg-[#eceff4] after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/85 after:to-transparent motion-reduce:after:hidden w-[150px]"></span></span></td><td><span class="relative block h-[11px] overflow-hidden rounded-md bg-[#eceff4] after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/85 after:to-transparent motion-reduce:after:hidden w-[52px]"></span></td><td><span class="relative block h-[11px] overflow-hidden rounded-md bg-[#eceff4] after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/85 after:to-transparent motion-reduce:after:hidden ml-auto w-[58px]"></span></td></tr>
      <tr><td><span class="flex items-center gap-2.5"><span class="relative block overflow-hidden bg-[#eceff4] after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/85 after:to-transparent motion-reduce:after:hidden size-7 shrink-0 rounded-full"></span><span class="relative block h-[11px] overflow-hidden rounded-md bg-[#eceff4] after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/85 after:to-transparent motion-reduce:after:hidden w-[100px]"></span></span></td><td><span class="relative block h-[11px] overflow-hidden rounded-md bg-[#eceff4] after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/85 after:to-transparent motion-reduce:after:hidden w-[70px]"></span></td><td><span class="relative block h-[11px] overflow-hidden rounded-md bg-[#eceff4] after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/85 after:to-transparent motion-reduce:after:hidden ml-auto w-20"></span></td></tr>
    </tbody>
  </table>
  </div>
</div>

<div class="overflow-hidden rounded-[14px] border border-[#e7e9f0] bg-white">
  <div class="border-b border-[#eceef3] px-4 py-3.5 text-[.8rem] font-bold tracking-[.05em] text-[#667085] uppercase" aria-hidden="true">Empty</div>
  <div class="px-[26px] py-11 text-center">
    <span class="mx-auto mb-4 grid size-[52px] place-items-center rounded-[14px] bg-[#eef2ff]" aria-hidden="true"><svg class="size-6 fill-none stroke-[#4f46e5] stroke-2 [stroke-linecap:round]" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/><circle cx="12" cy="12" r="10"/></svg></span>
    <h2 class="mb-1.5 text-base font-bold">No invoices yet</h2>
    <p class="mx-auto max-w-[44ch] text-[.87rem] leading-[1.6] text-[#5f6778]">Invoices appear here once you send your first one. It takes about a minute.</p>
    <div class="mt-[18px] flex flex-wrap justify-center gap-[9px]"><button class="cursor-pointer rounded-[9px] border border-[#4f46e5] bg-[#4f46e5] px-[18px] py-[9px] text-[.85rem] font-bold text-white hover:bg-[#4338ca] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]" type="button">Create an invoice</button><button class="cursor-pointer rounded-[9px] border border-[#e4e7ec] bg-white px-[18px] py-[9px] text-[.85rem] font-bold text-[#344054] hover:bg-[#f7f8fa] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]" type="button">Import from CSV</button></div>
  </div>
</div>

<div class="overflow-hidden rounded-[14px] border border-[#e7e9f0] bg-white">
  <div class="border-b border-[#eceef3] px-4 py-3.5 text-[.8rem] font-bold tracking-[.05em] text-[#667085] uppercase" aria-hidden="true">Error</div>
  <div class="px-[26px] py-11 text-center" role="alert">
    <span class="mx-auto mb-4 grid size-[52px] place-items-center rounded-[14px] bg-[#fdecec]" aria-hidden="true"><svg class="size-6 fill-none stroke-[#dc2626] stroke-2 [stroke-linecap:round]" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg></span>
    <h2 class="mb-1.5 text-base font-bold">Could not load invoices</h2>
    <p class="mx-auto max-w-[44ch] text-[.87rem] leading-[1.6] text-[#5f6778]">The request timed out after 30 seconds. Your data is safe — this is a connection problem.</p>
    <div class="mt-[18px] flex flex-wrap justify-center gap-[9px]"><button class="cursor-pointer rounded-[9px] border border-[#4f46e5] bg-[#4f46e5] px-[18px] py-[9px] text-[.85rem] font-bold text-white hover:bg-[#4338ca] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]" type="button">Try again</button><button class="cursor-pointer rounded-[9px] border border-[#e4e7ec] bg-white px-[18px] py-[9px] text-[.85rem] font-bold text-[#344054] hover:bg-[#f7f8fa] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]" type="button">Contact support</button></div>
  </div>
</div>
</div>
CSS
@theme {
  --animate-shimmer: shimmer 1.4s infinite;
  @keyframes shimmer { 0% { transform: translateX(-100%) } 100% { transform: translateX(100%) } }
}

About this set

The three states a data table shows before it has rows to display, in one set. Loading keeps the real header row and fills three body rows with skeleton bars for the avatar, client name, status and amount, so the columns are already the right width when the data arrives. Empty replaces the table with an icon, a heading, a short explanation and two next steps, Create an invoice and Import from CSV. Error uses the same layout in red with Try again and Contact support, and is marked role=”alert” so assistive technology announces it when it appears. Each state sits in the same white card with a small uppercase label, and on wide screens the loading card spans the full width with the two message cards side by side. Use loading while a request is in flight, empty when the request succeeds with no rows, and error when it fails. The skeleton is a grey bar with a white gradient sliding across it; the Bootstrap version uses the stock .placeholder and .placeholder-wave classes instead, and the Tailwind version defines a shimmer animation in its theme.

What’s included

  • Loading table keeps the real header so column widths do not jump
  • Skeleton shimmer that stops under prefers-reduced-motion
  • Empty state with a primary and a secondary action
  • Error state announced with role="alert"
  • Bootstrap version built on .placeholder-wave, .card and .btn

Accessibility

The loading table has a caption, header cells with scope="col" and aria-busy="true" while it waits for data. The error message uses role="alert", the decorative icons are aria-hidden, and the state labels are hidden from screen readers because the headings already say what happened. Buttons show a 2px focus ring, and the shimmer is switched off when reduced motion is requested.

Customise it

Skeleton colour is #eceff4 on .sk (plain CSS), .placeholder (Bootstrap) or the bg- utility on each bar (Tailwind), and the shimmer speed is the 1.4s in the animation. The blue and red icon tiles are set by the .blue and .red rules, and the primary button colour by #4f46e5.