22 CSS Skeleton Loaders10 / 22
Layout-Preserving Skeleton with min-height
A skeleton that is shorter than its content doesn't prevent layout shift — it schedules one. Both columns here load the same article; the left one lets the content push the footer down, the right one declares min-height and aspect-ratio so nothing below it ever moves. Flip the switch and watch the ruler.
Published
The code
<div class="sk-10">
<input class="sk-10__sr" type="checkbox" id="sk-10-load">
<div class="sk-10__stage">
<section class="sk-10__wrap" aria-labelledby="sk-10-h">
<header class="sk-10__head">
<p class="sk-10__eyebrow">Core Web Vitals · CLS</p>
<h2 class="sk-10__h" id="sk-10-h">Reserve the space, or schedule the jump</h2>
<p class="sk-10__sub">Same article, same skeleton, one difference: the right column declares <code>min-height</code> and <code>aspect-ratio</code>. Watch the ruler and the footer row, not the card.</p>
<label class="sk-10__switch" for="sk-10-load">
<span class="sk-10__track" aria-hidden="true"><span class="sk-10__thumb"></span></span>
<span class="sk-10__switchtext">Simulate content arriving</span>
</label>
</header>
<div class="sk-10__cols">
<article class="sk-10__col sk-10__col--bad">
<header class="sk-10__chead">
<p class="sk-10__label">no reservation</p>
<p class="sk-10__badge sk-10__badge--bad">CLS 0.19</p>
</header>
<div class="sk-10__slot sk-10__slot--loose" role="status" aria-live="polite">
<span class="sk-10__vh">Loading article…</span>
<div class="sk-10__skel" aria-hidden="true">
<span class="sk-10__bar sk-10__bar--thumb"></span>
<span class="sk-10__bar sk-10__bar--title"></span>
<span class="sk-10__bar sk-10__bar--sm"></span>
<span class="sk-10__bar sk-10__bar--sm" style="inline-size:56%"></span>
</div>
<div class="sk-10__real">
<img class="sk-10__img" src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=640&h=360&q=70" width="640" height="360" alt="Sunlight through a beech forest" loading="lazy" decoding="async">
<h3 class="sk-10__title">Field data beats a stopwatch</h3>
<p class="sk-10__copy">Sam Rivera pulled p75 CLS from RUM instead of the lab and found three routes where the loading shell was two rows shorter than the page it stood in for. The fix was six lines of CSS.</p>
<p class="sk-10__by">Sam Rivera · Performance · 6 min read</p>
</div>
</div>
<p class="sk-10__ruler" aria-hidden="true">footer baseline</p>
<footer class="sk-10__foot">
<span class="sk-10__fdot" aria-hidden="true"></span>
<p class="sk-10__ftext">This row moves down 118px when the article lands.</p>
</footer>
</article>
<article class="sk-10__col sk-10__col--good">
<header class="sk-10__chead">
<p class="sk-10__label sk-10__label--on">min-height: 19rem</p>
<p class="sk-10__badge sk-10__badge--good">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M5 12.6 9.6 17 19 7.5" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
CLS 0.00
</p>
</header>
<div class="sk-10__slot sk-10__slot--fixed" role="status" aria-live="polite">
<span class="sk-10__vh">Loading article…</span>
<div class="sk-10__skel" aria-hidden="true">
<span class="sk-10__bar sk-10__bar--media"></span>
<span class="sk-10__bar sk-10__bar--title"></span>
<span class="sk-10__bar sk-10__bar--sm"></span>
<span class="sk-10__bar sk-10__bar--sm" style="inline-size:56%"></span>
</div>
<div class="sk-10__real">
<img class="sk-10__img" src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=640&h=360&q=70" width="640" height="360" alt="Sunlight through a beech forest" loading="lazy" decoding="async">
<h3 class="sk-10__title">Field data beats a stopwatch</h3>
<p class="sk-10__copy">Sam Rivera pulled p75 CLS from RUM instead of the lab and found three routes where the loading shell was two rows shorter than the page it stood in for. The fix was six lines of CSS.</p>
<p class="sk-10__by">Sam Rivera · Performance · 6 min read</p>
</div>
</div>
<p class="sk-10__ruler" aria-hidden="true">footer baseline</p>
<footer class="sk-10__foot">
<span class="sk-10__fdot" aria-hidden="true"></span>
<p class="sk-10__ftext">This row never moves. The box was already the right size.</p>
</footer>
</article>
</div>
<aside class="sk-10__spec" aria-label="Recipe">
<p class="sk-10__srow"><span class="sk-10__skey">slot</span><span class="sk-10__sval">min-block-size: 19rem (or clamp(16rem, 26vw, 22rem)) + contain: layout</span></p>
<p class="sk-10__srow"><span class="sk-10__skey">media</span><span class="sk-10__sval">aspect-ratio: 16/9 + width/height attributes on every img</span></p>
<p class="sk-10__srow"><span class="sk-10__skey">verify</span><span class="sk-10__sval">field p75 CLS from RUM, not a lab run — target 0.00</span></p>
</aside>
</section>
</div>
</div><div class="sk-10">
<input class="sk-10__sr" type="checkbox" id="sk-10-load">
<div class="sk-10__stage">
<section class="sk-10__wrap" aria-labelledby="sk-10-h">
<header class="sk-10__head">
<p class="sk-10__eyebrow">Core Web Vitals · CLS</p>
<h2 class="sk-10__h" id="sk-10-h">Reserve the space, or schedule the jump</h2>
<p class="sk-10__sub">Same article, same skeleton, one difference: the right column declares <code>min-height</code> and <code>aspect-ratio</code>. Watch the ruler and the footer row, not the card.</p>
<label class="sk-10__switch" for="sk-10-load">
<span class="sk-10__track" aria-hidden="true"><span class="sk-10__thumb"></span></span>
<span class="sk-10__switchtext">Simulate content arriving</span>
</label>
</header>
<div class="sk-10__cols">
<article class="sk-10__col sk-10__col--bad">
<header class="sk-10__chead">
<p class="sk-10__label">no reservation</p>
<p class="sk-10__badge sk-10__badge--bad">CLS 0.19</p>
</header>
<div class="sk-10__slot sk-10__slot--loose" role="status" aria-live="polite">
<span class="sk-10__vh">Loading article…</span>
<div class="sk-10__skel" aria-hidden="true">
<span class="sk-10__bar sk-10__bar--thumb"></span>
<span class="sk-10__bar sk-10__bar--title"></span>
<span class="sk-10__bar sk-10__bar--sm"></span>
<span class="sk-10__bar sk-10__bar--sm" style="inline-size:56%"></span>
</div>
<div class="sk-10__real">
<img class="sk-10__img" src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=640&h=360&q=70" width="640" height="360" alt="Sunlight through a beech forest" loading="lazy" decoding="async">
<h3 class="sk-10__title">Field data beats a stopwatch</h3>
<p class="sk-10__copy">Sam Rivera pulled p75 CLS from RUM instead of the lab and found three routes where the loading shell was two rows shorter than the page it stood in for. The fix was six lines of CSS.</p>
<p class="sk-10__by">Sam Rivera · Performance · 6 min read</p>
</div>
</div>
<p class="sk-10__ruler" aria-hidden="true">footer baseline</p>
<footer class="sk-10__foot">
<span class="sk-10__fdot" aria-hidden="true"></span>
<p class="sk-10__ftext">This row moves down 118px when the article lands.</p>
</footer>
</article>
<article class="sk-10__col sk-10__col--good">
<header class="sk-10__chead">
<p class="sk-10__label sk-10__label--on">min-height: 19rem</p>
<p class="sk-10__badge sk-10__badge--good">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M5 12.6 9.6 17 19 7.5" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
CLS 0.00
</p>
</header>
<div class="sk-10__slot sk-10__slot--fixed" role="status" aria-live="polite">
<span class="sk-10__vh">Loading article…</span>
<div class="sk-10__skel" aria-hidden="true">
<span class="sk-10__bar sk-10__bar--media"></span>
<span class="sk-10__bar sk-10__bar--title"></span>
<span class="sk-10__bar sk-10__bar--sm"></span>
<span class="sk-10__bar sk-10__bar--sm" style="inline-size:56%"></span>
</div>
<div class="sk-10__real">
<img class="sk-10__img" src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=640&h=360&q=70" width="640" height="360" alt="Sunlight through a beech forest" loading="lazy" decoding="async">
<h3 class="sk-10__title">Field data beats a stopwatch</h3>
<p class="sk-10__copy">Sam Rivera pulled p75 CLS from RUM instead of the lab and found three routes where the loading shell was two rows shorter than the page it stood in for. The fix was six lines of CSS.</p>
<p class="sk-10__by">Sam Rivera · Performance · 6 min read</p>
</div>
</div>
<p class="sk-10__ruler" aria-hidden="true">footer baseline</p>
<footer class="sk-10__foot">
<span class="sk-10__fdot" aria-hidden="true"></span>
<p class="sk-10__ftext">This row never moves. The box was already the right size.</p>
</footer>
</article>
</div>
<aside class="sk-10__spec" aria-label="Recipe">
<p class="sk-10__srow"><span class="sk-10__skey">slot</span><span class="sk-10__sval">min-block-size: 19rem (or clamp(16rem, 26vw, 22rem)) + contain: layout</span></p>
<p class="sk-10__srow"><span class="sk-10__skey">media</span><span class="sk-10__sval">aspect-ratio: 16/9 + width/height attributes on every img</span></p>
<p class="sk-10__srow"><span class="sk-10__skey">verify</span><span class="sk-10__sval">field p75 CLS from RUM, not a lab run — target 0.00</span></p>
</aside>
</section>
</div>
</div>@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap');
.sk-10 {
--bg: #ffffff;
--bg2: #f2f4f8;
--surface: #fcfdff;
--line: rgba(10,14,26,.12);
--ink: #0a0e1a;
--muted: rgba(10,14,26,.64);
--faint: rgba(10,14,26,.45);
--accent: #1d4ed8;
--bad: #c2410c;
--good: #0f7b4f;
--sk: rgba(10,14,26,.09);
--sheen: rgba(10,14,26,.16);
--mono: 'Geist Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
position: relative;
box-sizing: border-box;
color: var(--ink);
isolation: isolate;
font-family: 'Geist',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
background: radial-gradient(58% 40% at 90% -8%, rgba(29,78,216,.08) 0%, transparent 60%), linear-gradient(178deg,#fff 0%,var(--bg2) 100%);
}
.sk-10 *,
.sk-10 *::before,
.sk-10 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0.7 0.1 260)) {
.sk-10 {
--ink: oklch(0.17 0.02 265);
--muted: oklch(0.17 0.02 265 / .64);
--faint: oklch(0.17 0.02 265 / .45);
--accent: oklch(0.5 0.17 265);
--bad: oklch(0.55 0.17 40);
--good: oklch(0.52 0.13 155);
}
}
@supports (color: color-mix(in oklab, white 10%, transparent)) {
.sk-10 {
--sk: color-mix(in oklab, currentColor 9%, transparent);
--sheen: color-mix(in oklab, currentColor 16%, transparent);
}
}
.sk-10__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.sk-10__sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}
.sk-10__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: clamp(1.15rem,4vw,3.25rem);
}
.sk-10__wrap {
inline-size: min(74rem,100%);
display: grid;
gap: clamp(1rem,2.2vw,1.6rem);
}
.sk-10__head {
display: grid;
gap: .6rem;
max-inline-size: 47rem;
justify-items: start;
}
.sk-10__eyebrow {
margin: 0;
font-family: var(--mono);
font-size: .72rem;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--accent);
}
.sk-10__h {
margin: 0;
font-size: clamp(1.6rem,3.6vw,2.5rem);
font-weight: 600;
letter-spacing: -.032em;
line-height: 1.06;
text-wrap: balance;
}
.sk-10__sub {
margin: 0;
font-size: clamp(.94rem,1.3vw,1.02rem);
line-height: 1.55;
color: var(--muted);
text-wrap: pretty;
}
.sk-10__sub code {
font-family: var(--mono);
font-size: .88em;
color: var(--ink);
}
.sk-10__switch {
margin-block-start: .35rem;
min-block-size: 2.75rem;
display: inline-flex;
align-items: center;
gap: .7rem;
cursor: pointer;
font-size: .9rem;
font-weight: 500;
}
.sk-10__track {
inline-size: 2.9rem;
block-size: 1.7rem;
border-radius: 999px;
background: var(--sk);
border: 1px solid var(--line);
display: flex;
align-items: center;
padding: .18rem;
transition: background .2s ease;
}
.sk-10__thumb {
inline-size: 1.25rem;
block-size: 1.25rem;
border-radius: 999px;
background: #fff;
box-shadow: 0 2px 6px rgba(10,14,26,.3);
transition: translate .2s cubic-bezier(.2,.7,.3,1);
}
.sk-10:has(#sk-10-load:checked) .sk-10__track {
background: var(--accent);
border-color: var(--accent);
}
.sk-10:has(#sk-10-load:checked) .sk-10__thumb {
translate: 1.2rem 0;
}
.sk-10__sr:focus-visible + .sk-10__stage .sk-10__track {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.sk-10__cols {
display: grid;
gap: clamp(.9rem,1.8vw,1.25rem);
grid-template-columns: repeat(auto-fit,minmax(18rem,1fr));
align-items: start;
}
.sk-10__col {
display: grid;
gap: .75rem;
padding: clamp(1rem,1.9vw,1.4rem);
border: 1px solid var(--line);
border-radius: 1.1rem;
background: var(--surface);
box-shadow: 0 22px 50px -38px rgba(10,14,26,.45);
}
.sk-10__col--bad {
border-color: rgba(194,65,12,.3);
}
.sk-10__col--good {
border-color: rgba(15,123,79,.32);
}
.sk-10__chead {
display: flex;
align-items: center;
justify-content: space-between;
gap: .8rem;
flex-wrap: wrap;
}
.sk-10__label {
margin: 0;
font-family: var(--mono);
font-size: .72rem;
color: var(--bad);
}
.sk-10__label--on {
color: var(--good);
}
.sk-10__badge {
margin: 0;
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .3rem .65rem;
border-radius: 999px;
font-family: var(--mono);
font-size: .72rem;
font-variant-numeric: tabular-nums;
border: 1px solid;
}
.sk-10__badge svg {
inline-size: .9rem;
block-size: .9rem;
}
.sk-10__badge--bad {
color: var(--bad);
border-color: rgba(194,65,12,.35);
background: rgba(194,65,12,.08);
}
.sk-10__badge--good {
color: var(--good);
border-color: rgba(15,123,79,.35);
background: rgba(15,123,79,.08);
}
.sk-10__slot {
display: grid;
gap: .7rem;
padding: .9rem;
border: 1px dashed var(--line);
border-radius: .85rem;
background: rgba(0,0,0,.015);
}
.sk-10__slot--fixed {
min-block-size: 19rem;
contain: layout;
}
.sk-10__real {
display: none;
gap: .7rem;
}
.sk-10:has(#sk-10-load:checked) .sk-10__skel {
display: none;
}
.sk-10:has(#sk-10-load:checked) .sk-10__real {
display: grid;
}
.sk-10__skel {
display: grid;
gap: .7rem;
}
.sk-10__img {
inline-size: 100%;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: .6rem;
background: var(--sk);
display: block;
}
.sk-10__col--bad .sk-10__img {
aspect-ratio: auto;
}
.sk-10__title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -.02em;
}
.sk-10__copy {
margin: 0;
font-size: .88rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.sk-10__by {
margin: 0;
font-family: var(--mono);
font-size: .7rem;
color: var(--faint);
}
.sk-10__ruler {
margin: 0;
padding-block-start: .4rem;
border-block-start: 2px dashed var(--accent);
font-family: var(--mono);
font-size: .62rem;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--accent);
opacity: .75;
}
.sk-10__foot {
display: flex;
align-items: center;
gap: .6rem;
}
.sk-10__fdot {
inline-size: .55rem;
block-size: .55rem;
border-radius: 999px;
background: var(--accent);
flex: none;
}
.sk-10__ftext {
margin: 0;
font-size: .82rem;
color: var(--muted);
}
.sk-10__bar {
display: block;
position: relative;
overflow: hidden;
block-size: .8rem;
inline-size: 100%;
border-radius: 6px;
background: var(--sk);
}
.sk-10__bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 18%, var(--sheen) 50%, transparent 82%);
translate: -100% 0;
animation: sk-10-sheen 1.4s linear infinite;
}
@keyframes sk-10-sheen {
to {
translate: 100% 0;
}
}
.sk-10__bar--sm {
block-size: .68rem;
}
.sk-10__bar--title {
block-size: 1.25rem;
inline-size: 78%;
border-radius: 7px;
}
.sk-10__bar--media {
block-size: auto;
aspect-ratio: 16/9;
border-radius: .6rem;
}
.sk-10__bar--thumb {
block-size: 4.4rem;
border-radius: .6rem;
}
.sk-10__spec {
padding: clamp(.85rem,1.5vw,1.1rem) clamp(1rem,1.8vw,1.3rem);
border: 1px solid var(--line);
border-radius: .9rem;
background: var(--surface);
display: grid;
gap: .45rem;
}
.sk-10__srow {
margin: 0;
display: flex;
gap: .9rem;
flex-wrap: wrap;
font-family: var(--mono);
font-size: .74rem;
line-height: 1.5;
}
.sk-10__skey {
min-inline-size: 3.6rem;
color: var(--accent);
letter-spacing: .08em;
text-transform: uppercase;
font-size: .66rem;
padding-block-start: .12rem;
}
.sk-10__sval {
color: var(--muted);
word-break: break-word;
}
.sk-10[data-theme="dark"] {
--bg: #07090f;
--bg2: #0d1018;
--surface: rgba(255,255,255,.03);
--line: rgba(255,255,255,.11);
--ink: #f2f5fb;
--muted: rgba(242,245,251,.62);
--faint: rgba(242,245,251,.42);
--accent: #7aa2ff;
--bad: #fb923c;
--good: #4ade9a;
background: linear-gradient(178deg,#0d1018 0%,#07090f 100%);
}
.sk-10[data-theme="dark"] .sk-10__slot {
background: rgba(255,255,255,.02);
}
@media (prefers-color-scheme: dark) {
.sk-10:not([data-theme="light"]) {
--bg: #07090f;
--bg2: #0d1018;
--surface: rgba(255,255,255,.03);
--line: rgba(255,255,255,.11);
--ink: #f2f5fb;
--muted: rgba(242,245,251,.62);
--faint: rgba(242,245,251,.42);
--accent: #7aa2ff;
--bad: #fb923c;
--good: #4ade9a;
background: linear-gradient(178deg,#0d1018 0%,#07090f 100%);
}
.sk-10:not([data-theme="light"]) .sk-10__slot {
background: rgba(255,255,255,.02);
}
}
@media (prefers-reduced-motion: reduce) {
.sk-10__bar::after {
animation: none;
background: none;
}
.sk-10__bar {
background: linear-gradient(105deg, var(--sk) 0%, var(--sheen) 50%, var(--sk) 100%);
}
.sk-10__thumb,
.sk-10__track {
transition: none;
}
}
@media (forced-colors: active) {
.sk-10__bar {
background: GrayText;
forced-color-adjust: none;
}
.sk-10__bar::after {
animation: none;
background: none;
}
.sk-10__col,
.sk-10__slot,
.sk-10__spec,
.sk-10__badge,
.sk-10__track {
border-color: CanvasText;
}
}@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap');
.sk-10 {
--bg: #ffffff;
--bg2: #f2f4f8;
--surface: #fcfdff;
--line: rgba(10,14,26,.12);
--ink: #0a0e1a;
--muted: rgba(10,14,26,.64);
--faint: rgba(10,14,26,.45);
--accent: #1d4ed8;
--bad: #c2410c;
--good: #0f7b4f;
--sk: rgba(10,14,26,.09);
--sheen: rgba(10,14,26,.16);
--mono: 'Geist Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
position: relative;
box-sizing: border-box;
color: var(--ink);
isolation: isolate;
font-family: 'Geist',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
background: radial-gradient(58% 40% at 90% -8%, rgba(29,78,216,.08) 0%, transparent 60%), linear-gradient(178deg,#fff 0%,var(--bg2) 100%);
}
.sk-10 *,
.sk-10 *::before,
.sk-10 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0.7 0.1 260)) {
.sk-10 {
--ink: oklch(0.17 0.02 265);
--muted: oklch(0.17 0.02 265 / .64);
--faint: oklch(0.17 0.02 265 / .45);
--accent: oklch(0.5 0.17 265);
--bad: oklch(0.55 0.17 40);
--good: oklch(0.52 0.13 155);
}
}
@supports (color: color-mix(in oklab, white 10%, transparent)) {
.sk-10 {
--sk: color-mix(in oklab, currentColor 9%, transparent);
--sheen: color-mix(in oklab, currentColor 16%, transparent);
}
}
.sk-10__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.sk-10__sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}
.sk-10__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: clamp(1.15rem,4vw,3.25rem);
}
.sk-10__wrap {
inline-size: min(74rem,100%);
display: grid;
gap: clamp(1rem,2.2vw,1.6rem);
}
.sk-10__head {
display: grid;
gap: .6rem;
max-inline-size: 47rem;
justify-items: start;
}
.sk-10__eyebrow {
margin: 0;
font-family: var(--mono);
font-size: .72rem;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--accent);
}
.sk-10__h {
margin: 0;
font-size: clamp(1.6rem,3.6vw,2.5rem);
font-weight: 600;
letter-spacing: -.032em;
line-height: 1.06;
text-wrap: balance;
}
.sk-10__sub {
margin: 0;
font-size: clamp(.94rem,1.3vw,1.02rem);
line-height: 1.55;
color: var(--muted);
text-wrap: pretty;
}
.sk-10__sub code {
font-family: var(--mono);
font-size: .88em;
color: var(--ink);
}
.sk-10__switch {
margin-block-start: .35rem;
min-block-size: 2.75rem;
display: inline-flex;
align-items: center;
gap: .7rem;
cursor: pointer;
font-size: .9rem;
font-weight: 500;
}
.sk-10__track {
inline-size: 2.9rem;
block-size: 1.7rem;
border-radius: 999px;
background: var(--sk);
border: 1px solid var(--line);
display: flex;
align-items: center;
padding: .18rem;
transition: background .2s ease;
}
.sk-10__thumb {
inline-size: 1.25rem;
block-size: 1.25rem;
border-radius: 999px;
background: #fff;
box-shadow: 0 2px 6px rgba(10,14,26,.3);
transition: translate .2s cubic-bezier(.2,.7,.3,1);
}
.sk-10:has(#sk-10-load:checked) .sk-10__track {
background: var(--accent);
border-color: var(--accent);
}
.sk-10:has(#sk-10-load:checked) .sk-10__thumb {
translate: 1.2rem 0;
}
.sk-10__sr:focus-visible + .sk-10__stage .sk-10__track {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.sk-10__cols {
display: grid;
gap: clamp(.9rem,1.8vw,1.25rem);
grid-template-columns: repeat(auto-fit,minmax(18rem,1fr));
align-items: start;
}
.sk-10__col {
display: grid;
gap: .75rem;
padding: clamp(1rem,1.9vw,1.4rem);
border: 1px solid var(--line);
border-radius: 1.1rem;
background: var(--surface);
box-shadow: 0 22px 50px -38px rgba(10,14,26,.45);
}
.sk-10__col--bad {
border-color: rgba(194,65,12,.3);
}
.sk-10__col--good {
border-color: rgba(15,123,79,.32);
}
.sk-10__chead {
display: flex;
align-items: center;
justify-content: space-between;
gap: .8rem;
flex-wrap: wrap;
}
.sk-10__label {
margin: 0;
font-family: var(--mono);
font-size: .72rem;
color: var(--bad);
}
.sk-10__label--on {
color: var(--good);
}
.sk-10__badge {
margin: 0;
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .3rem .65rem;
border-radius: 999px;
font-family: var(--mono);
font-size: .72rem;
font-variant-numeric: tabular-nums;
border: 1px solid;
}
.sk-10__badge svg {
inline-size: .9rem;
block-size: .9rem;
}
.sk-10__badge--bad {
color: var(--bad);
border-color: rgba(194,65,12,.35);
background: rgba(194,65,12,.08);
}
.sk-10__badge--good {
color: var(--good);
border-color: rgba(15,123,79,.35);
background: rgba(15,123,79,.08);
}
.sk-10__slot {
display: grid;
gap: .7rem;
padding: .9rem;
border: 1px dashed var(--line);
border-radius: .85rem;
background: rgba(0,0,0,.015);
}
.sk-10__slot--fixed {
min-block-size: 19rem;
contain: layout;
}
.sk-10__real {
display: none;
gap: .7rem;
}
.sk-10:has(#sk-10-load:checked) .sk-10__skel {
display: none;
}
.sk-10:has(#sk-10-load:checked) .sk-10__real {
display: grid;
}
.sk-10__skel {
display: grid;
gap: .7rem;
}
.sk-10__img {
inline-size: 100%;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: .6rem;
background: var(--sk);
display: block;
}
.sk-10__col--bad .sk-10__img {
aspect-ratio: auto;
}
.sk-10__title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -.02em;
}
.sk-10__copy {
margin: 0;
font-size: .88rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.sk-10__by {
margin: 0;
font-family: var(--mono);
font-size: .7rem;
color: var(--faint);
}
.sk-10__ruler {
margin: 0;
padding-block-start: .4rem;
border-block-start: 2px dashed var(--accent);
font-family: var(--mono);
font-size: .62rem;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--accent);
opacity: .75;
}
.sk-10__foot {
display: flex;
align-items: center;
gap: .6rem;
}
.sk-10__fdot {
inline-size: .55rem;
block-size: .55rem;
border-radius: 999px;
background: var(--accent);
flex: none;
}
.sk-10__ftext {
margin: 0;
font-size: .82rem;
color: var(--muted);
}
.sk-10__bar {
display: block;
position: relative;
overflow: hidden;
block-size: .8rem;
inline-size: 100%;
border-radius: 6px;
background: var(--sk);
}
.sk-10__bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 18%, var(--sheen) 50%, transparent 82%);
translate: -100% 0;
animation: sk-10-sheen 1.4s linear infinite;
}
@keyframes sk-10-sheen {
to {
translate: 100% 0;
}
}
.sk-10__bar--sm {
block-size: .68rem;
}
.sk-10__bar--title {
block-size: 1.25rem;
inline-size: 78%;
border-radius: 7px;
}
.sk-10__bar--media {
block-size: auto;
aspect-ratio: 16/9;
border-radius: .6rem;
}
.sk-10__bar--thumb {
block-size: 4.4rem;
border-radius: .6rem;
}
.sk-10__spec {
padding: clamp(.85rem,1.5vw,1.1rem) clamp(1rem,1.8vw,1.3rem);
border: 1px solid var(--line);
border-radius: .9rem;
background: var(--surface);
display: grid;
gap: .45rem;
}
.sk-10__srow {
margin: 0;
display: flex;
gap: .9rem;
flex-wrap: wrap;
font-family: var(--mono);
font-size: .74rem;
line-height: 1.5;
}
.sk-10__skey {
min-inline-size: 3.6rem;
color: var(--accent);
letter-spacing: .08em;
text-transform: uppercase;
font-size: .66rem;
padding-block-start: .12rem;
}
.sk-10__sval {
color: var(--muted);
word-break: break-word;
}
.sk-10[data-theme="dark"] {
--bg: #07090f;
--bg2: #0d1018;
--surface: rgba(255,255,255,.03);
--line: rgba(255,255,255,.11);
--ink: #f2f5fb;
--muted: rgba(242,245,251,.62);
--faint: rgba(242,245,251,.42);
--accent: #7aa2ff;
--bad: #fb923c;
--good: #4ade9a;
background: linear-gradient(178deg,#0d1018 0%,#07090f 100%);
}
.sk-10[data-theme="dark"] .sk-10__slot {
background: rgba(255,255,255,.02);
}
@media (prefers-color-scheme: dark) {
.sk-10:not([data-theme="light"]) {
--bg: #07090f;
--bg2: #0d1018;
--surface: rgba(255,255,255,.03);
--line: rgba(255,255,255,.11);
--ink: #f2f5fb;
--muted: rgba(242,245,251,.62);
--faint: rgba(242,245,251,.42);
--accent: #7aa2ff;
--bad: #fb923c;
--good: #4ade9a;
background: linear-gradient(178deg,#0d1018 0%,#07090f 100%);
}
.sk-10:not([data-theme="light"]) .sk-10__slot {
background: rgba(255,255,255,.02);
}
}
@media (prefers-reduced-motion: reduce) {
.sk-10__bar::after {
animation: none;
background: none;
}
.sk-10__bar {
background: linear-gradient(105deg, var(--sk) 0%, var(--sheen) 50%, var(--sk) 100%);
}
.sk-10__thumb,
.sk-10__track {
transition: none;
}
}
@media (forced-colors: active) {
.sk-10__bar {
background: GrayText;
forced-color-adjust: none;
}
.sk-10__bar::after {
animation: none;
background: none;
}
.sk-10__col,
.sk-10__slot,
.sk-10__spec,
.sk-10__badge,
.sk-10__track {
border-color: CanvasText;
}
}Here's a working CSS Skeleton Loader from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Layout-Preserving Skeleton with min-height
Source: https://codefronts.com/components/css-skeleton-loaders/layout-preserving-skeleton-with-min-height/
A skeleton that is shorter than its content doesn't prevent layout shift — it schedules one. Both columns here load the same article; the left one lets the content push the footer down, the right one declares min-height and aspect-ratio so nothing below it ever moves. Flip the switch and watch the ruler.
## HTML
```html
<div class="sk-10">
<input class="sk-10__sr" type="checkbox" id="sk-10-load">
<div class="sk-10__stage">
<section class="sk-10__wrap" aria-labelledby="sk-10-h">
<header class="sk-10__head">
<p class="sk-10__eyebrow">Core Web Vitals · CLS</p>
<h2 class="sk-10__h" id="sk-10-h">Reserve the space, or schedule the jump</h2>
<p class="sk-10__sub">Same article, same skeleton, one difference: the right column declares <code>min-height</code> and <code>aspect-ratio</code>. Watch the ruler and the footer row, not the card.</p>
<label class="sk-10__switch" for="sk-10-load">
<span class="sk-10__track" aria-hidden="true"><span class="sk-10__thumb"></span></span>
<span class="sk-10__switchtext">Simulate content arriving</span>
</label>
</header>
<div class="sk-10__cols">
<article class="sk-10__col sk-10__col--bad">
<header class="sk-10__chead">
<p class="sk-10__label">no reservation</p>
<p class="sk-10__badge sk-10__badge--bad">CLS 0.19</p>
</header>
<div class="sk-10__slot sk-10__slot--loose" role="status" aria-live="polite">
<span class="sk-10__vh">Loading article…</span>
<div class="sk-10__skel" aria-hidden="true">
<span class="sk-10__bar sk-10__bar--thumb"></span>
<span class="sk-10__bar sk-10__bar--title"></span>
<span class="sk-10__bar sk-10__bar--sm"></span>
<span class="sk-10__bar sk-10__bar--sm" style="inline-size:56%"></span>
</div>
<div class="sk-10__real">
<img class="sk-10__img" src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=640&h=360&q=70" width="640" height="360" alt="Sunlight through a beech forest" loading="lazy" decoding="async">
<h3 class="sk-10__title">Field data beats a stopwatch</h3>
<p class="sk-10__copy">Sam Rivera pulled p75 CLS from RUM instead of the lab and found three routes where the loading shell was two rows shorter than the page it stood in for. The fix was six lines of CSS.</p>
<p class="sk-10__by">Sam Rivera · Performance · 6 min read</p>
</div>
</div>
<p class="sk-10__ruler" aria-hidden="true">footer baseline</p>
<footer class="sk-10__foot">
<span class="sk-10__fdot" aria-hidden="true"></span>
<p class="sk-10__ftext">This row moves down 118px when the article lands.</p>
</footer>
</article>
<article class="sk-10__col sk-10__col--good">
<header class="sk-10__chead">
<p class="sk-10__label sk-10__label--on">min-height: 19rem</p>
<p class="sk-10__badge sk-10__badge--good">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M5 12.6 9.6 17 19 7.5" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
CLS 0.00
</p>
</header>
<div class="sk-10__slot sk-10__slot--fixed" role="status" aria-live="polite">
<span class="sk-10__vh">Loading article…</span>
<div class="sk-10__skel" aria-hidden="true">
<span class="sk-10__bar sk-10__bar--media"></span>
<span class="sk-10__bar sk-10__bar--title"></span>
<span class="sk-10__bar sk-10__bar--sm"></span>
<span class="sk-10__bar sk-10__bar--sm" style="inline-size:56%"></span>
</div>
<div class="sk-10__real">
<img class="sk-10__img" src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=640&h=360&q=70" width="640" height="360" alt="Sunlight through a beech forest" loading="lazy" decoding="async">
<h3 class="sk-10__title">Field data beats a stopwatch</h3>
<p class="sk-10__copy">Sam Rivera pulled p75 CLS from RUM instead of the lab and found three routes where the loading shell was two rows shorter than the page it stood in for. The fix was six lines of CSS.</p>
<p class="sk-10__by">Sam Rivera · Performance · 6 min read</p>
</div>
</div>
<p class="sk-10__ruler" aria-hidden="true">footer baseline</p>
<footer class="sk-10__foot">
<span class="sk-10__fdot" aria-hidden="true"></span>
<p class="sk-10__ftext">This row never moves. The box was already the right size.</p>
</footer>
</article>
</div>
<aside class="sk-10__spec" aria-label="Recipe">
<p class="sk-10__srow"><span class="sk-10__skey">slot</span><span class="sk-10__sval">min-block-size: 19rem (or clamp(16rem, 26vw, 22rem)) + contain: layout</span></p>
<p class="sk-10__srow"><span class="sk-10__skey">media</span><span class="sk-10__sval">aspect-ratio: 16/9 + width/height attributes on every img</span></p>
<p class="sk-10__srow"><span class="sk-10__skey">verify</span><span class="sk-10__sval">field p75 CLS from RUM, not a lab run — target 0.00</span></p>
</aside>
</section>
</div>
</div>
```
## CSS
```css
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap');
.sk-10 {
--bg: #ffffff;
--bg2: #f2f4f8;
--surface: #fcfdff;
--line: rgba(10,14,26,.12);
--ink: #0a0e1a;
--muted: rgba(10,14,26,.64);
--faint: rgba(10,14,26,.45);
--accent: #1d4ed8;
--bad: #c2410c;
--good: #0f7b4f;
--sk: rgba(10,14,26,.09);
--sheen: rgba(10,14,26,.16);
--mono: 'Geist Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
position: relative;
box-sizing: border-box;
color: var(--ink);
isolation: isolate;
font-family: 'Geist',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
background: radial-gradient(58% 40% at 90% -8%, rgba(29,78,216,.08) 0%, transparent 60%), linear-gradient(178deg,#fff 0%,var(--bg2) 100%);
}
.sk-10 *,
.sk-10 *::before,
.sk-10 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0.7 0.1 260)) {
.sk-10 {
--ink: oklch(0.17 0.02 265);
--muted: oklch(0.17 0.02 265 / .64);
--faint: oklch(0.17 0.02 265 / .45);
--accent: oklch(0.5 0.17 265);
--bad: oklch(0.55 0.17 40);
--good: oklch(0.52 0.13 155);
}
}
@supports (color: color-mix(in oklab, white 10%, transparent)) {
.sk-10 {
--sk: color-mix(in oklab, currentColor 9%, transparent);
--sheen: color-mix(in oklab, currentColor 16%, transparent);
}
}
.sk-10__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.sk-10__sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}
.sk-10__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: clamp(1.15rem,4vw,3.25rem);
}
.sk-10__wrap {
inline-size: min(74rem,100%);
display: grid;
gap: clamp(1rem,2.2vw,1.6rem);
}
.sk-10__head {
display: grid;
gap: .6rem;
max-inline-size: 47rem;
justify-items: start;
}
.sk-10__eyebrow {
margin: 0;
font-family: var(--mono);
font-size: .72rem;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--accent);
}
.sk-10__h {
margin: 0;
font-size: clamp(1.6rem,3.6vw,2.5rem);
font-weight: 600;
letter-spacing: -.032em;
line-height: 1.06;
text-wrap: balance;
}
.sk-10__sub {
margin: 0;
font-size: clamp(.94rem,1.3vw,1.02rem);
line-height: 1.55;
color: var(--muted);
text-wrap: pretty;
}
.sk-10__sub code {
font-family: var(--mono);
font-size: .88em;
color: var(--ink);
}
.sk-10__switch {
margin-block-start: .35rem;
min-block-size: 2.75rem;
display: inline-flex;
align-items: center;
gap: .7rem;
cursor: pointer;
font-size: .9rem;
font-weight: 500;
}
.sk-10__track {
inline-size: 2.9rem;
block-size: 1.7rem;
border-radius: 999px;
background: var(--sk);
border: 1px solid var(--line);
display: flex;
align-items: center;
padding: .18rem;
transition: background .2s ease;
}
.sk-10__thumb {
inline-size: 1.25rem;
block-size: 1.25rem;
border-radius: 999px;
background: #fff;
box-shadow: 0 2px 6px rgba(10,14,26,.3);
transition: translate .2s cubic-bezier(.2,.7,.3,1);
}
.sk-10:has(#sk-10-load:checked) .sk-10__track {
background: var(--accent);
border-color: var(--accent);
}
.sk-10:has(#sk-10-load:checked) .sk-10__thumb {
translate: 1.2rem 0;
}
.sk-10__sr:focus-visible + .sk-10__stage .sk-10__track {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.sk-10__cols {
display: grid;
gap: clamp(.9rem,1.8vw,1.25rem);
grid-template-columns: repeat(auto-fit,minmax(18rem,1fr));
align-items: start;
}
.sk-10__col {
display: grid;
gap: .75rem;
padding: clamp(1rem,1.9vw,1.4rem);
border: 1px solid var(--line);
border-radius: 1.1rem;
background: var(--surface);
box-shadow: 0 22px 50px -38px rgba(10,14,26,.45);
}
.sk-10__col--bad {
border-color: rgba(194,65,12,.3);
}
.sk-10__col--good {
border-color: rgba(15,123,79,.32);
}
.sk-10__chead {
display: flex;
align-items: center;
justify-content: space-between;
gap: .8rem;
flex-wrap: wrap;
}
.sk-10__label {
margin: 0;
font-family: var(--mono);
font-size: .72rem;
color: var(--bad);
}
.sk-10__label--on {
color: var(--good);
}
.sk-10__badge {
margin: 0;
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .3rem .65rem;
border-radius: 999px;
font-family: var(--mono);
font-size: .72rem;
font-variant-numeric: tabular-nums;
border: 1px solid;
}
.sk-10__badge svg {
inline-size: .9rem;
block-size: .9rem;
}
.sk-10__badge--bad {
color: var(--bad);
border-color: rgba(194,65,12,.35);
background: rgba(194,65,12,.08);
}
.sk-10__badge--good {
color: var(--good);
border-color: rgba(15,123,79,.35);
background: rgba(15,123,79,.08);
}
.sk-10__slot {
display: grid;
gap: .7rem;
padding: .9rem;
border: 1px dashed var(--line);
border-radius: .85rem;
background: rgba(0,0,0,.015);
}
.sk-10__slot--fixed {
min-block-size: 19rem;
contain: layout;
}
.sk-10__real {
display: none;
gap: .7rem;
}
.sk-10:has(#sk-10-load:checked) .sk-10__skel {
display: none;
}
.sk-10:has(#sk-10-load:checked) .sk-10__real {
display: grid;
}
.sk-10__skel {
display: grid;
gap: .7rem;
}
.sk-10__img {
inline-size: 100%;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: .6rem;
background: var(--sk);
display: block;
}
.sk-10__col--bad .sk-10__img {
aspect-ratio: auto;
}
.sk-10__title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -.02em;
}
.sk-10__copy {
margin: 0;
font-size: .88rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.sk-10__by {
margin: 0;
font-family: var(--mono);
font-size: .7rem;
color: var(--faint);
}
.sk-10__ruler {
margin: 0;
padding-block-start: .4rem;
border-block-start: 2px dashed var(--accent);
font-family: var(--mono);
font-size: .62rem;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--accent);
opacity: .75;
}
.sk-10__foot {
display: flex;
align-items: center;
gap: .6rem;
}
.sk-10__fdot {
inline-size: .55rem;
block-size: .55rem;
border-radius: 999px;
background: var(--accent);
flex: none;
}
.sk-10__ftext {
margin: 0;
font-size: .82rem;
color: var(--muted);
}
.sk-10__bar {
display: block;
position: relative;
overflow: hidden;
block-size: .8rem;
inline-size: 100%;
border-radius: 6px;
background: var(--sk);
}
.sk-10__bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 18%, var(--sheen) 50%, transparent 82%);
translate: -100% 0;
animation: sk-10-sheen 1.4s linear infinite;
}
@keyframes sk-10-sheen {
to {
translate: 100% 0;
}
}
.sk-10__bar--sm {
block-size: .68rem;
}
.sk-10__bar--title {
block-size: 1.25rem;
inline-size: 78%;
border-radius: 7px;
}
.sk-10__bar--media {
block-size: auto;
aspect-ratio: 16/9;
border-radius: .6rem;
}
.sk-10__bar--thumb {
block-size: 4.4rem;
border-radius: .6rem;
}
.sk-10__spec {
padding: clamp(.85rem,1.5vw,1.1rem) clamp(1rem,1.8vw,1.3rem);
border: 1px solid var(--line);
border-radius: .9rem;
background: var(--surface);
display: grid;
gap: .45rem;
}
.sk-10__srow {
margin: 0;
display: flex;
gap: .9rem;
flex-wrap: wrap;
font-family: var(--mono);
font-size: .74rem;
line-height: 1.5;
}
.sk-10__skey {
min-inline-size: 3.6rem;
color: var(--accent);
letter-spacing: .08em;
text-transform: uppercase;
font-size: .66rem;
padding-block-start: .12rem;
}
.sk-10__sval {
color: var(--muted);
word-break: break-word;
}
.sk-10[data-theme="dark"] {
--bg: #07090f;
--bg2: #0d1018;
--surface: rgba(255,255,255,.03);
--line: rgba(255,255,255,.11);
--ink: #f2f5fb;
--muted: rgba(242,245,251,.62);
--faint: rgba(242,245,251,.42);
--accent: #7aa2ff;
--bad: #fb923c;
--good: #4ade9a;
background: linear-gradient(178deg,#0d1018 0%,#07090f 100%);
}
.sk-10[data-theme="dark"] .sk-10__slot {
background: rgba(255,255,255,.02);
}
@media (prefers-color-scheme: dark) {
.sk-10:not([data-theme="light"]) {
--bg: #07090f;
--bg2: #0d1018;
--surface: rgba(255,255,255,.03);
--line: rgba(255,255,255,.11);
--ink: #f2f5fb;
--muted: rgba(242,245,251,.62);
--faint: rgba(242,245,251,.42);
--accent: #7aa2ff;
--bad: #fb923c;
--good: #4ade9a;
background: linear-gradient(178deg,#0d1018 0%,#07090f 100%);
}
.sk-10:not([data-theme="light"]) .sk-10__slot {
background: rgba(255,255,255,.02);
}
}
@media (prefers-reduced-motion: reduce) {
.sk-10__bar::after {
animation: none;
background: none;
}
.sk-10__bar {
background: linear-gradient(105deg, var(--sk) 0%, var(--sheen) 50%, var(--sk) 100%);
}
.sk-10__thumb,
.sk-10__track {
transition: none;
}
}
@media (forced-colors: active) {
.sk-10__bar {
background: GrayText;
forced-color-adjust: none;
}
.sk-10__bar::after {
animation: none;
background: none;
}
.sk-10__col,
.sk-10__slot,
.sk-10__spec,
.sk-10__badge,
.sk-10__track {
border-color: CanvasText;
}
}
```Here's a working CSS Skeleton Loader from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Layout-Preserving Skeleton with min-height
Source: https://codefronts.com/components/css-skeleton-loaders/layout-preserving-skeleton-with-min-height/
A skeleton that is shorter than its content doesn't prevent layout shift — it schedules one. Both columns here load the same article; the left one lets the content push the footer down, the right one declares min-height and aspect-ratio so nothing below it ever moves. Flip the switch and watch the ruler.
## HTML
```html
<div class="sk-10">
<input class="sk-10__sr" type="checkbox" id="sk-10-load">
<div class="sk-10__stage">
<section class="sk-10__wrap" aria-labelledby="sk-10-h">
<header class="sk-10__head">
<p class="sk-10__eyebrow">Core Web Vitals · CLS</p>
<h2 class="sk-10__h" id="sk-10-h">Reserve the space, or schedule the jump</h2>
<p class="sk-10__sub">Same article, same skeleton, one difference: the right column declares <code>min-height</code> and <code>aspect-ratio</code>. Watch the ruler and the footer row, not the card.</p>
<label class="sk-10__switch" for="sk-10-load">
<span class="sk-10__track" aria-hidden="true"><span class="sk-10__thumb"></span></span>
<span class="sk-10__switchtext">Simulate content arriving</span>
</label>
</header>
<div class="sk-10__cols">
<article class="sk-10__col sk-10__col--bad">
<header class="sk-10__chead">
<p class="sk-10__label">no reservation</p>
<p class="sk-10__badge sk-10__badge--bad">CLS 0.19</p>
</header>
<div class="sk-10__slot sk-10__slot--loose" role="status" aria-live="polite">
<span class="sk-10__vh">Loading article…</span>
<div class="sk-10__skel" aria-hidden="true">
<span class="sk-10__bar sk-10__bar--thumb"></span>
<span class="sk-10__bar sk-10__bar--title"></span>
<span class="sk-10__bar sk-10__bar--sm"></span>
<span class="sk-10__bar sk-10__bar--sm" style="inline-size:56%"></span>
</div>
<div class="sk-10__real">
<img class="sk-10__img" src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=640&h=360&q=70" width="640" height="360" alt="Sunlight through a beech forest" loading="lazy" decoding="async">
<h3 class="sk-10__title">Field data beats a stopwatch</h3>
<p class="sk-10__copy">Sam Rivera pulled p75 CLS from RUM instead of the lab and found three routes where the loading shell was two rows shorter than the page it stood in for. The fix was six lines of CSS.</p>
<p class="sk-10__by">Sam Rivera · Performance · 6 min read</p>
</div>
</div>
<p class="sk-10__ruler" aria-hidden="true">footer baseline</p>
<footer class="sk-10__foot">
<span class="sk-10__fdot" aria-hidden="true"></span>
<p class="sk-10__ftext">This row moves down 118px when the article lands.</p>
</footer>
</article>
<article class="sk-10__col sk-10__col--good">
<header class="sk-10__chead">
<p class="sk-10__label sk-10__label--on">min-height: 19rem</p>
<p class="sk-10__badge sk-10__badge--good">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M5 12.6 9.6 17 19 7.5" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
CLS 0.00
</p>
</header>
<div class="sk-10__slot sk-10__slot--fixed" role="status" aria-live="polite">
<span class="sk-10__vh">Loading article…</span>
<div class="sk-10__skel" aria-hidden="true">
<span class="sk-10__bar sk-10__bar--media"></span>
<span class="sk-10__bar sk-10__bar--title"></span>
<span class="sk-10__bar sk-10__bar--sm"></span>
<span class="sk-10__bar sk-10__bar--sm" style="inline-size:56%"></span>
</div>
<div class="sk-10__real">
<img class="sk-10__img" src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=640&h=360&q=70" width="640" height="360" alt="Sunlight through a beech forest" loading="lazy" decoding="async">
<h3 class="sk-10__title">Field data beats a stopwatch</h3>
<p class="sk-10__copy">Sam Rivera pulled p75 CLS from RUM instead of the lab and found three routes where the loading shell was two rows shorter than the page it stood in for. The fix was six lines of CSS.</p>
<p class="sk-10__by">Sam Rivera · Performance · 6 min read</p>
</div>
</div>
<p class="sk-10__ruler" aria-hidden="true">footer baseline</p>
<footer class="sk-10__foot">
<span class="sk-10__fdot" aria-hidden="true"></span>
<p class="sk-10__ftext">This row never moves. The box was already the right size.</p>
</footer>
</article>
</div>
<aside class="sk-10__spec" aria-label="Recipe">
<p class="sk-10__srow"><span class="sk-10__skey">slot</span><span class="sk-10__sval">min-block-size: 19rem (or clamp(16rem, 26vw, 22rem)) + contain: layout</span></p>
<p class="sk-10__srow"><span class="sk-10__skey">media</span><span class="sk-10__sval">aspect-ratio: 16/9 + width/height attributes on every img</span></p>
<p class="sk-10__srow"><span class="sk-10__skey">verify</span><span class="sk-10__sval">field p75 CLS from RUM, not a lab run — target 0.00</span></p>
</aside>
</section>
</div>
</div>
```
## CSS
```css
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap');
.sk-10 {
--bg: #ffffff;
--bg2: #f2f4f8;
--surface: #fcfdff;
--line: rgba(10,14,26,.12);
--ink: #0a0e1a;
--muted: rgba(10,14,26,.64);
--faint: rgba(10,14,26,.45);
--accent: #1d4ed8;
--bad: #c2410c;
--good: #0f7b4f;
--sk: rgba(10,14,26,.09);
--sheen: rgba(10,14,26,.16);
--mono: 'Geist Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
position: relative;
box-sizing: border-box;
color: var(--ink);
isolation: isolate;
font-family: 'Geist',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
background: radial-gradient(58% 40% at 90% -8%, rgba(29,78,216,.08) 0%, transparent 60%), linear-gradient(178deg,#fff 0%,var(--bg2) 100%);
}
.sk-10 *,
.sk-10 *::before,
.sk-10 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0.7 0.1 260)) {
.sk-10 {
--ink: oklch(0.17 0.02 265);
--muted: oklch(0.17 0.02 265 / .64);
--faint: oklch(0.17 0.02 265 / .45);
--accent: oklch(0.5 0.17 265);
--bad: oklch(0.55 0.17 40);
--good: oklch(0.52 0.13 155);
}
}
@supports (color: color-mix(in oklab, white 10%, transparent)) {
.sk-10 {
--sk: color-mix(in oklab, currentColor 9%, transparent);
--sheen: color-mix(in oklab, currentColor 16%, transparent);
}
}
.sk-10__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.sk-10__sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}
.sk-10__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: clamp(1.15rem,4vw,3.25rem);
}
.sk-10__wrap {
inline-size: min(74rem,100%);
display: grid;
gap: clamp(1rem,2.2vw,1.6rem);
}
.sk-10__head {
display: grid;
gap: .6rem;
max-inline-size: 47rem;
justify-items: start;
}
.sk-10__eyebrow {
margin: 0;
font-family: var(--mono);
font-size: .72rem;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--accent);
}
.sk-10__h {
margin: 0;
font-size: clamp(1.6rem,3.6vw,2.5rem);
font-weight: 600;
letter-spacing: -.032em;
line-height: 1.06;
text-wrap: balance;
}
.sk-10__sub {
margin: 0;
font-size: clamp(.94rem,1.3vw,1.02rem);
line-height: 1.55;
color: var(--muted);
text-wrap: pretty;
}
.sk-10__sub code {
font-family: var(--mono);
font-size: .88em;
color: var(--ink);
}
.sk-10__switch {
margin-block-start: .35rem;
min-block-size: 2.75rem;
display: inline-flex;
align-items: center;
gap: .7rem;
cursor: pointer;
font-size: .9rem;
font-weight: 500;
}
.sk-10__track {
inline-size: 2.9rem;
block-size: 1.7rem;
border-radius: 999px;
background: var(--sk);
border: 1px solid var(--line);
display: flex;
align-items: center;
padding: .18rem;
transition: background .2s ease;
}
.sk-10__thumb {
inline-size: 1.25rem;
block-size: 1.25rem;
border-radius: 999px;
background: #fff;
box-shadow: 0 2px 6px rgba(10,14,26,.3);
transition: translate .2s cubic-bezier(.2,.7,.3,1);
}
.sk-10:has(#sk-10-load:checked) .sk-10__track {
background: var(--accent);
border-color: var(--accent);
}
.sk-10:has(#sk-10-load:checked) .sk-10__thumb {
translate: 1.2rem 0;
}
.sk-10__sr:focus-visible + .sk-10__stage .sk-10__track {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.sk-10__cols {
display: grid;
gap: clamp(.9rem,1.8vw,1.25rem);
grid-template-columns: repeat(auto-fit,minmax(18rem,1fr));
align-items: start;
}
.sk-10__col {
display: grid;
gap: .75rem;
padding: clamp(1rem,1.9vw,1.4rem);
border: 1px solid var(--line);
border-radius: 1.1rem;
background: var(--surface);
box-shadow: 0 22px 50px -38px rgba(10,14,26,.45);
}
.sk-10__col--bad {
border-color: rgba(194,65,12,.3);
}
.sk-10__col--good {
border-color: rgba(15,123,79,.32);
}
.sk-10__chead {
display: flex;
align-items: center;
justify-content: space-between;
gap: .8rem;
flex-wrap: wrap;
}
.sk-10__label {
margin: 0;
font-family: var(--mono);
font-size: .72rem;
color: var(--bad);
}
.sk-10__label--on {
color: var(--good);
}
.sk-10__badge {
margin: 0;
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .3rem .65rem;
border-radius: 999px;
font-family: var(--mono);
font-size: .72rem;
font-variant-numeric: tabular-nums;
border: 1px solid;
}
.sk-10__badge svg {
inline-size: .9rem;
block-size: .9rem;
}
.sk-10__badge--bad {
color: var(--bad);
border-color: rgba(194,65,12,.35);
background: rgba(194,65,12,.08);
}
.sk-10__badge--good {
color: var(--good);
border-color: rgba(15,123,79,.35);
background: rgba(15,123,79,.08);
}
.sk-10__slot {
display: grid;
gap: .7rem;
padding: .9rem;
border: 1px dashed var(--line);
border-radius: .85rem;
background: rgba(0,0,0,.015);
}
.sk-10__slot--fixed {
min-block-size: 19rem;
contain: layout;
}
.sk-10__real {
display: none;
gap: .7rem;
}
.sk-10:has(#sk-10-load:checked) .sk-10__skel {
display: none;
}
.sk-10:has(#sk-10-load:checked) .sk-10__real {
display: grid;
}
.sk-10__skel {
display: grid;
gap: .7rem;
}
.sk-10__img {
inline-size: 100%;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: .6rem;
background: var(--sk);
display: block;
}
.sk-10__col--bad .sk-10__img {
aspect-ratio: auto;
}
.sk-10__title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -.02em;
}
.sk-10__copy {
margin: 0;
font-size: .88rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.sk-10__by {
margin: 0;
font-family: var(--mono);
font-size: .7rem;
color: var(--faint);
}
.sk-10__ruler {
margin: 0;
padding-block-start: .4rem;
border-block-start: 2px dashed var(--accent);
font-family: var(--mono);
font-size: .62rem;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--accent);
opacity: .75;
}
.sk-10__foot {
display: flex;
align-items: center;
gap: .6rem;
}
.sk-10__fdot {
inline-size: .55rem;
block-size: .55rem;
border-radius: 999px;
background: var(--accent);
flex: none;
}
.sk-10__ftext {
margin: 0;
font-size: .82rem;
color: var(--muted);
}
.sk-10__bar {
display: block;
position: relative;
overflow: hidden;
block-size: .8rem;
inline-size: 100%;
border-radius: 6px;
background: var(--sk);
}
.sk-10__bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 18%, var(--sheen) 50%, transparent 82%);
translate: -100% 0;
animation: sk-10-sheen 1.4s linear infinite;
}
@keyframes sk-10-sheen {
to {
translate: 100% 0;
}
}
.sk-10__bar--sm {
block-size: .68rem;
}
.sk-10__bar--title {
block-size: 1.25rem;
inline-size: 78%;
border-radius: 7px;
}
.sk-10__bar--media {
block-size: auto;
aspect-ratio: 16/9;
border-radius: .6rem;
}
.sk-10__bar--thumb {
block-size: 4.4rem;
border-radius: .6rem;
}
.sk-10__spec {
padding: clamp(.85rem,1.5vw,1.1rem) clamp(1rem,1.8vw,1.3rem);
border: 1px solid var(--line);
border-radius: .9rem;
background: var(--surface);
display: grid;
gap: .45rem;
}
.sk-10__srow {
margin: 0;
display: flex;
gap: .9rem;
flex-wrap: wrap;
font-family: var(--mono);
font-size: .74rem;
line-height: 1.5;
}
.sk-10__skey {
min-inline-size: 3.6rem;
color: var(--accent);
letter-spacing: .08em;
text-transform: uppercase;
font-size: .66rem;
padding-block-start: .12rem;
}
.sk-10__sval {
color: var(--muted);
word-break: break-word;
}
.sk-10[data-theme="dark"] {
--bg: #07090f;
--bg2: #0d1018;
--surface: rgba(255,255,255,.03);
--line: rgba(255,255,255,.11);
--ink: #f2f5fb;
--muted: rgba(242,245,251,.62);
--faint: rgba(242,245,251,.42);
--accent: #7aa2ff;
--bad: #fb923c;
--good: #4ade9a;
background: linear-gradient(178deg,#0d1018 0%,#07090f 100%);
}
.sk-10[data-theme="dark"] .sk-10__slot {
background: rgba(255,255,255,.02);
}
@media (prefers-color-scheme: dark) {
.sk-10:not([data-theme="light"]) {
--bg: #07090f;
--bg2: #0d1018;
--surface: rgba(255,255,255,.03);
--line: rgba(255,255,255,.11);
--ink: #f2f5fb;
--muted: rgba(242,245,251,.62);
--faint: rgba(242,245,251,.42);
--accent: #7aa2ff;
--bad: #fb923c;
--good: #4ade9a;
background: linear-gradient(178deg,#0d1018 0%,#07090f 100%);
}
.sk-10:not([data-theme="light"]) .sk-10__slot {
background: rgba(255,255,255,.02);
}
}
@media (prefers-reduced-motion: reduce) {
.sk-10__bar::after {
animation: none;
background: none;
}
.sk-10__bar {
background: linear-gradient(105deg, var(--sk) 0%, var(--sheen) 50%, var(--sk) 100%);
}
.sk-10__thumb,
.sk-10__track {
transition: none;
}
}
@media (forced-colors: active) {
.sk-10__bar {
background: GrayText;
forced-color-adjust: none;
}
.sk-10__bar::after {
animation: none;
background: none;
}
.sk-10__col,
.sk-10__slot,
.sk-10__spec,
.sk-10__badge,
.sk-10__track {
border-color: CanvasText;
}
}
```How this works
CLS is the sum of unexpected movement, weighted by how much of the viewport moved. A loading state that occupies less space than its result is one of the top three causes in the field — and it is entirely a CSS problem:
.sk-10__slot{
/* the resolved article measures ~19rem here — so reserve it up front */
min-block-size:19rem;
contain:layout; /* internals can never reflow the page */
}
.sk-10__slot .sk-10__media{
aspect-ratio:16/9; /* the image box is correct with zero bytes loaded */
}Measure, do not guess. Render the real component, read its height in DevTools at your two or three key breakpoints, and set min-height to the smallest of them. Over-reserving leaves a gap under the content; under-reserving reintroduces the shift. When the height genuinely varies, reserve the minimum plausible height and let the container grow downward only — growth at the bottom edge of a block shifts less than growth in the middle of a page.
/* fluid but bounded, so the reservation adapts with the viewport */
.sk-10__slot{ min-block-size:clamp(16rem, 26vw, 22rem) }Prefer intrinsic reservations where they exist. aspect-ratio on media, width/height attributes on <img> and <iframe>, and font-size-adjust or a metric-matched fallback font for text all reserve space without a magic number. min-height is the tool for the remainder.
Why the footer is in the demo. CLS is not about the loading box — it is about everything below it. The ruler line and the footer row are the actual subjects: on the left they move when the article arrives, on the right they never do.
Make it yours
- Set
min-heightfrom the p50 rendered height of your real component, then verify with the Web Vitals extension that field CLS stays at 0. - Use
clamp()for the reservation when the component's height changes with viewport width. - Add
content-visibility: autopluscontain-intrinsic-sizefor long lists — same reservation idea, applied to off-screen rows. - For infinite scroll, reserve the next page's height before fetching so the scrollbar does not jump under the user's thumb.
- If the content can be much taller than the reservation, anchor the container to the top and accept growth downward — never centre it.
- Drop the ruler and badges for production; they exist to make the shift visible here.
Gotchas — read before shipping
min-heighton the skeleton but not on the resolved container still shifts — put it on the shared wrapper, as here.- Over-reserving is its own bug: a 40rem reservation for 20rem of content leaves a hole that reads as broken.
heightinstead ofmin-heightclips longer content. Always themin-variant.- Percentage
min-heightneeds a resolved parent height; inside anauto-height parent it computes to 0. contain: layoutcreates a containing block for absolutely positioned descendants — check tooltips and dropdowns after adding it.- Late-loading webfonts change text height even with a perfect reservation. Pair this with
font-display: optionalor a metric-compatible fallback.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by :has(), oklch(), color-mix(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 105+.
min-height and the CLS technique work in every browser. aspect-ratio needs Chrome 88 / Safari 15 / Firefox 89; contain: layout is Chrome 52 / Safari 15.4 / Firefox 69. :has() (Chrome 105 / Safari 15.4 / Firefox 121) powers the JS-free load simulation only — the reservation itself has no modern dependency.