Open demo

CSS code

HTML
<article class="post" aria-busy="true">
  <p class="sr-only" role="status">Loading article</p>
  <div aria-hidden="true">
    <div class="sk sk-cover"></div>
    <div class="pad">
      <div class="sk chip"></div>
      <div class="sk line" style="width:96%"></div>
      <div class="sk line" style="width:72%"></div>
      <div class="sk line" style="width:100%;height:9px;margin-top:14px"></div>
      <div class="sk line" style="width:88%;height:9px"></div>
      <div class="by"><div class="sk avatar"></div><div class="sk line" style="width:96px;margin:0"></div></div>
    </div>
  </div>
</article>

<article class="post">
  <div class="cover"></div>
  <div class="pad">
    <span class="tag">Engineering</span>
    <h2><a href="#">Why we stopped animating max-height</a></h2>
    <p>Six years of guessing a number that was always wrong, and the two-line grid rule that
      replaced it.</p>
    <div class="by"><i aria-hidden="true">AK</i><span>Aigars K. · 6 min read</span></div>
  </div>
</article>
CSS
/* A post card and its skeleton. The skeleton mirrors the loaded layout block for block (cover, tag,
   two title lines, two text lines, byline), so nothing jumps when the content arrives. The shapes are
   aria-hidden and a visually hidden status says "Loading article"; the shimmer stops under reduced motion.
   The loaded card's title is a stretched link, so the whole card opens the post. */
.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}
.post{position:relative;width:290px;max-width:100%;overflow:hidden;background:#fff;border:1px solid #e9ecf1;border-radius:16px}
.post .cover{height:150px;background:linear-gradient(140deg,#4f46e5,#0ea5e9)}
.post .pad{padding:18px}
.post .tag{display:inline-block;font-size:.7rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;
  color:#0369a1;background:#e0f2fe;padding:3px 9px;border-radius:999px;margin-bottom:10px}
.post h2{font-size:1rem;line-height:1.4;letter-spacing:-.01em}
.post h2 a{color:inherit;text-decoration:none}
.post h2 a::after{content:"";position:absolute;inset:0}
.post h2 a:focus-visible{outline:2px solid transparent}
.post:has(h2 a:focus-visible){outline:2px solid #4f46e5;outline-offset:2px}
@supports not selector(:has(*)){.post h2 a:focus-visible{outline-color:#4f46e5;outline-offset:2px}}
.post p{font-size:.85rem;color:#667085;line-height:1.6;margin-top:8px}
.post .by{display:flex;align-items:center;gap:9px;margin-top:16px}
.post .by i{width:30px;height:30px;border-radius:50%;background:#4f46e5;color:#fff;font-style:normal;
  font-size:.72rem;font-weight:800;display:grid;place-items:center}
.post .by span{font-size:.79rem;color:#667085}
.sk{background:#eceff4;border-radius:6px;position:relative;overflow:hidden}
.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.sk-cover{height:150px;border-radius:0}
.sk.chip{width:74px;height:18px;border-radius:999px;margin-bottom:12px}
.sk.line{height:12px;margin-bottom:9px}
.sk.avatar{width:30px;height:30px;border-radius:50%}
@media (prefers-reduced-motion:reduce){.sk::after{animation:none;display:none}}

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<article class="card post" aria-busy="true">
  <p class="visually-hidden" role="status">Loading article</p>
  <div class="placeholder-wave" aria-hidden="true">
    <span class="placeholder sk-cover"></span>
    <div class="card-body">
      <span class="placeholder chip d-block"></span>
      <span class="placeholder line d-block" style="width:96%"></span>
      <span class="placeholder line d-block" style="width:72%"></span>
      <span class="placeholder line text d-block w-100" style="margin-top:14px"></span>
      <span class="placeholder line text d-block" style="width:88%"></span>
      <div class="by d-flex align-items-center"><span class="placeholder avatar"></span><span class="placeholder line m-0" style="width:96px"></span></div>
    </div>
  </div>
</article>

<article class="card post">
  <div class="cover"></div>
  <div class="card-body">
    <span class="badge rounded-pill text-uppercase tag">Engineering</span>
    <h2 class="card-title"><a href="#" class="stretched-link">Why we stopped animating max-height</a></h2>
    <p class="card-text mb-0">Six years of guessing a number that was always wrong, and the two-line grid rule that
      replaced it.</p>
    <div class="by d-flex align-items-center"><i class="rounded-circle" aria-hidden="true">AK</i><span>Aigars K. · 6 min read</span></div>
  </div>
</article>
CSS
/* Bootstrap .card with its own .placeholder-wave skeleton, next to the loaded card. The skeleton shapes
   are aria-hidden and a visually hidden status says "Loading article"; the wave stops under reduced motion.
   The loaded card's title is a .stretched-link. */
.post{--bs-card-border-color:#e9ecf1;--bs-card-border-radius:16px;--bs-card-inner-border-radius:15px;
  --bs-card-spacer-y:18px;--bs-card-spacer-x:18px;width:290px;max-width:100%;overflow:hidden}
.post .cover{height:150px;background:linear-gradient(140deg,#4f46e5,#0ea5e9)}
.post .tag{--bs-badge-font-size:.7rem;--bs-badge-font-weight:700;--bs-badge-color:#0369a1;--bs-badge-padding-x:9px;
  --bs-badge-padding-y:3px;letter-spacing:.05em;background:#e0f2fe;margin-bottom:10px;line-height:normal}
.post .card-title{font-size:1rem;font-weight:700;line-height:1.4;letter-spacing:-.01em;margin:0}
.post .card-title a{color:inherit;text-decoration:none}
.post .stretched-link:focus-visible{outline:2px solid transparent}
.post:has(.stretched-link:focus-visible){outline:2px solid #4f46e5;outline-offset:2px}
@supports not selector(:has(*)){.post .stretched-link:focus-visible{outline-color:#4f46e5;outline-offset:2px}}
.post .card-text{font-size:.85rem;color:#667085;line-height:1.6;margin-top:8px}
.post .by{gap:9px;margin-top:16px}
.post .by i{width:30px;height:30px;background:#4f46e5;color:#fff;font-style:normal;font-size:.72rem;font-weight:800;
  display:grid;place-items:center}
.post .by span{font-size:.79rem;color:#667085}
.post .placeholder{background-color:#eceff4;opacity:1;min-height:0;vertical-align:top;border-radius:6px}
.post .placeholder.sk-cover{display:block;height:150px;border-radius:0}
.post .placeholder.chip{width:74px;height:18px;border-radius:999px;margin-bottom:12px}
.post .placeholder.line{height:12px;margin-bottom:9px}
.post .placeholder.text{height:9px}
.post .placeholder.avatar{width:30px;height:30px;border-radius:50%}
@media (prefers-reduced-motion:reduce){.post .placeholder-wave{animation:none;-webkit-mask-image:none;mask-image:none}}

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<article class="relative w-[290px] max-w-full overflow-hidden rounded-2xl border border-[#e9ecf1] bg-white" aria-busy="true">
  <p class="sr-only" role="status">Loading article</p>
  <div class="[&_.sk]:relative [&_.sk]:overflow-hidden [&_.sk]:rounded-md [&_.sk]:bg-[#eceff4] [&_.sk]:after:absolute [&_.sk]:after:inset-0 [&_.sk]:after:animate-shimmer [&_.sk]:after:bg-[linear-gradient(90deg,transparent,rgba(255,255,255,.85),transparent)] motion-reduce:[&_.sk]:after:hidden" aria-hidden="true">
    <div class="sk h-[150px] rounded-none!"></div>
    <div class="p-[18px]">
      <div class="sk mb-3 h-[18px] w-[74px] rounded-full!"></div>
      <div class="sk mb-[9px] h-3 w-[96%]"></div>
      <div class="sk mb-[9px] h-3 w-[72%]"></div>
      <div class="sk mt-3.5 mb-[9px] h-[9px] w-full"></div>
      <div class="sk mb-[9px] h-[9px] w-[88%]"></div>
      <div class="mt-4 flex items-center gap-[9px]"><div class="sk size-[30px] rounded-full!"></div><div class="sk h-3 w-24"></div></div>
    </div>
  </div>
</article>

<article class="relative w-[290px] max-w-full overflow-hidden rounded-2xl border border-[#e9ecf1] bg-white has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-2 has-[a:focus-visible]:outline-[#4f46e5]">
  <div class="h-[150px] bg-[linear-gradient(140deg,#4f46e5,#0ea5e9)]"></div>
  <div class="p-[18px]">
    <span class="mb-2.5 inline-block rounded-full bg-[#e0f2fe] px-[9px] py-[3px] text-[.7rem] font-bold tracking-[.05em] text-[#0369a1] uppercase">Engineering</span>
    <h2 class="text-base leading-[1.4] font-bold tracking-[-.01em]"><a href="#" class="after:absolute after:inset-0 focus-visible:outline-2 focus-visible:outline-transparent">Why we stopped animating max-height</a></h2>
    <p class="mt-2 text-[.85rem] leading-[1.6] text-[#667085]">Six years of guessing a number that was always wrong, and the two-line grid rule that
      replaced it.</p>
    <div class="mt-4 flex items-center gap-[9px]"><i class="grid size-[30px] place-items-center rounded-full bg-[#4f46e5] text-[.72rem] font-extrabold text-white not-italic" aria-hidden="true">AK</i><span class="text-[.79rem] text-[#667085]">Aigars K. · 6 min read</span></div>
  </div>
</article>
CSS
/* Shimmer for the skeleton shapes: used as after:animate-shimmer, switched off with motion-reduce:. */
@theme {
  --animate-shimmer: shimmer 1.4s infinite;
  @keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
}

About this set

A blog post card shown twice: once as a skeleton while it loads and once with its content. The skeleton copies the loaded layout block for block, with a cover, a category chip, two title bars, two shorter text bars, and an avatar with a name bar, so nothing shifts when the real content replaces it. Each grey shape has a white highlight sliding across it, and the highlight is removed entirely when the visitor asks for reduced motion. The skeleton card is marked aria-busy, its shapes are aria-hidden, and a visually hidden status element says Loading article, so a screen reader gets one short message instead of a series of empty boxes. The loaded card has a gradient cover, an Engineering tag, the title, a short excerpt and the author with the reading time, and its title is a stretched link that makes the whole card open the post. The Bootstrap version uses the stock .placeholder and .placeholder-wave classes recoloured to match; the Tailwind version defines the shimmer keyframes in its @theme and applies them with after:animate-shimmer.

What’s included

  • Skeleton mirrors the loaded card so nothing shifts
  • Shimmer removed under prefers-reduced-motion
  • aria-busy plus one hidden Loading article status
  • Loaded card clickable through a stretched link
  • Bootstrap version built on .placeholder-wave

Accessibility

The skeleton's shapes are aria-hidden and a role="status" element announces Loading article; remove the skeleton and aria-busy when content arrives. The loaded card's title is an h2 with the card's only link, and keyboard focus draws a 2px indigo ring around the card. The author initials are decorative next to the written name, and the byline grey was darkened to reach 4.5:1.

Customise it

The skeleton grey is #eceff4 on .sk (on .placeholder in Bootstrap and the [&_.sk] utilities in Tailwind), and the shimmer speed is 1.4s. Bar widths are inline percentages, so match them to your own layout.