22 CSS Avatars15 / 22
CSS Avatar Fallback for Broken Images
What happens when the photo 404s — and this demo actually breaks one so you can see the fallback fire. The initials layer sits beneath the image from the start, so there is no flash and no layout shift; the image simply stops covering it. Includes the detail almost every tutorial misses: images that already failed before your script ran, caught with a complete && naturalWidth === 0 check.
Published
The code
<div class="av-15">
<button class="av-15__theme" type="button" aria-pressed="false">
<svg class="av-15__i av-15__i--moon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.4 8.4 0 0 1 9.7 3.5a8.5 8.5 0 1 0 10.8 10.8Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
<svg class="av-15__i av-15__i--sun" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4.1" stroke="currentColor" stroke-width="1.7"/><path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.7 5.7l1.4 1.4M16.9 16.9l1.4 1.4M18.3 5.7l-1.4 1.4M7.1 16.9l-1.4 1.4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<span class="av-15__vh">Toggle dark theme</span>
</button>
<div class="av-15__stage">
<section class="av-15__wrap" aria-labelledby="av-15-h">
<header class="av-15__head">
<p class="av-15__eyebrow">Resilience · Fallback</p>
<h2 class="av-15__h" id="av-15-h">When the photo 404s</h2>
<p class="av-15__sub">The third avatar points at a dead URL. Its initials were always there, underneath.</p>
</header>
<ul class="av-15__row">
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:264" aria-hidden="true">PS</span>
<img class="av-15__img" src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200&auto=format&fit=facearea&facepad=2.5" alt="Priya Sharma" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Priya Sharma</span><span class="av-15__meta av-15__meta--ok">Loaded</span></span>
</li>
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:152" aria-hidden="true">JL</span>
<img class="av-15__img" src="https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=200&auto=format&fit=facearea&facepad=2.5" alt="Jordan Lee" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Jordan Lee</span><span class="av-15__meta av-15__meta--ok">Loaded</span></span>
</li>
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:22" aria-hidden="true">NK</span>
<img class="av-15__img" id="av-15-broken" src="https://images.unsplash.com/photo-0000000000000-deleted-avatar?w=200&auto=format" alt="Nina Kaur" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Nina Kaur</span><span class="av-15__meta av-15__meta--bad">Photo unavailable</span></span>
</li>
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:318" aria-hidden="true">TB</span>
<img class="av-15__img" src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200&auto=format&fit=facearea&facepad=2.5" alt="Tom Bergström" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Tom Bergström</span><span class="av-15__meta av-15__meta--ok">Loaded</span></span>
</li>
</ul>
<div class="av-15__bar">
<button class="av-15__btn" type="button" id="av-15-retry">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20 12a8 8 0 1 1-2.6-5.9M20 4v4h-4" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
Retry the broken photo
</button>
<p class="av-15__live" id="av-15-live" aria-live="polite">1 of 4 photos failed. Initials shown instead.</p>
</div>
</section>
</div>
</div><div class="av-15">
<button class="av-15__theme" type="button" aria-pressed="false">
<svg class="av-15__i av-15__i--moon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.4 8.4 0 0 1 9.7 3.5a8.5 8.5 0 1 0 10.8 10.8Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
<svg class="av-15__i av-15__i--sun" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4.1" stroke="currentColor" stroke-width="1.7"/><path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.7 5.7l1.4 1.4M16.9 16.9l1.4 1.4M18.3 5.7l-1.4 1.4M7.1 16.9l-1.4 1.4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<span class="av-15__vh">Toggle dark theme</span>
</button>
<div class="av-15__stage">
<section class="av-15__wrap" aria-labelledby="av-15-h">
<header class="av-15__head">
<p class="av-15__eyebrow">Resilience · Fallback</p>
<h2 class="av-15__h" id="av-15-h">When the photo 404s</h2>
<p class="av-15__sub">The third avatar points at a dead URL. Its initials were always there, underneath.</p>
</header>
<ul class="av-15__row">
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:264" aria-hidden="true">PS</span>
<img class="av-15__img" src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200&auto=format&fit=facearea&facepad=2.5" alt="Priya Sharma" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Priya Sharma</span><span class="av-15__meta av-15__meta--ok">Loaded</span></span>
</li>
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:152" aria-hidden="true">JL</span>
<img class="av-15__img" src="https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=200&auto=format&fit=facearea&facepad=2.5" alt="Jordan Lee" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Jordan Lee</span><span class="av-15__meta av-15__meta--ok">Loaded</span></span>
</li>
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:22" aria-hidden="true">NK</span>
<img class="av-15__img" id="av-15-broken" src="https://images.unsplash.com/photo-0000000000000-deleted-avatar?w=200&auto=format" alt="Nina Kaur" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Nina Kaur</span><span class="av-15__meta av-15__meta--bad">Photo unavailable</span></span>
</li>
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:318" aria-hidden="true">TB</span>
<img class="av-15__img" src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200&auto=format&fit=facearea&facepad=2.5" alt="Tom Bergström" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Tom Bergström</span><span class="av-15__meta av-15__meta--ok">Loaded</span></span>
</li>
</ul>
<div class="av-15__bar">
<button class="av-15__btn" type="button" id="av-15-retry">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20 12a8 8 0 1 1-2.6-5.9M20 4v4h-4" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
Retry the broken photo
</button>
<p class="av-15__live" id="av-15-live" aria-live="polite">1 of 4 photos failed. Initials shown instead.</p>
</div>
</section>
</div>
</div>@supports (color: oklch(50% .1 250)) {
.av-15 {
--page: oklch(97.6% .004 265);
--panel: oklch(100% 0 0);
--panel-2: oklch(96.4% .006 265);
--ink: oklch(20% .022 265);
--muted: oklch(53% .022 265);
--line: oklch(92% .008 265);
--accent: oklch(58% .196 272);
--ok: oklch(58% .15 155);
--bad: oklch(58% .19 30);
}
.av-15__init {
background: oklch(93% .055 var(--h));
color: oklch(44% .13 var(--h));
}
}
.av-15 {
--page: #f7f8fa;
--panel: #ffffff;
--panel-2: #f1f3f7;
--ink: #10131a;
--muted: #5f6672;
--line: #e4e7ee;
--accent: #4f46e5;
--ok: #15803d;
--bad: #c2410c;
--glass: rgba(255,255,255,.68);
--shadow: 0 1px 2px rgba(16,19,26,.05), 0 16px 36px -20px rgba(16,19,26,.3);
--r: 18px;
--size: clamp(4rem,11vw,5.5rem);
width: 100%;
inline-size: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
position: relative;
background: var(--page);
color: var(--ink);
font-family: system-ui,-apple-system,"Segoe UI",sans-serif;
-webkit-font-smoothing: antialiased;
}
.av-15 *,
.av-15 *::before,
.av-15 *::after {
box-sizing: border-box;
}
.av-15__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.av-15 [hidden] {
display: none !important;
}
.av-15__theme {
position: absolute;
inset-block-start: clamp(.85rem,2.2vw,1.5rem);
inset-inline-end: clamp(.85rem,2.2vw,1.5rem);
z-index: 20;
display: grid;
place-items: center;
inline-size: 2.5rem;
block-size: 2.5rem;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--glass);
color: var(--ink);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
cursor: pointer;
transition: translate .2s ease;
}
.av-15__theme::after {
content: "";
position: absolute;
inset: -.4rem;
border-radius: inherit;
}
.av-15__theme:hover {
translate: 0 -1px;
}
.av-15__theme:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.av-15__i {
inline-size: 1.15rem;
block-size: 1.15rem;
}
.av-15__i--sun {
display: none;
}
.av-15[data-theme="dark"] .av-15__i--moon {
display: none;
}
.av-15[data-theme="dark"] .av-15__i--sun {
display: block;
}
.av-15__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: clamp(1.25rem,5vw,4rem);
}
.av-15__wrap {
inline-size: min(56rem,100%);
display: flex;
flex-direction: column;
gap: clamp(1.1rem,2.6vw,1.6rem);
}
.av-15__head {
display: flex;
flex-direction: column;
gap: .4rem;
padding-inline-end: 3.5rem;
}
.av-15__eyebrow {
margin: 0;
font-size: .7rem;
font-weight: 600;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
}
.av-15__h {
margin: 0;
font-size: clamp(1.6rem,4.2vw,2.4rem);
font-weight: 650;
letter-spacing: -.03em;
line-height: 1.05;
}
.av-15__sub {
margin: 0;
max-inline-size: 36rem;
font-size: .95rem;
line-height: 1.55;
color: var(--muted);
text-wrap: pretty;
}
.av-15__row {
list-style: none;
margin: 0;
padding: clamp(1.5rem,4vw,2.25rem) clamp(1rem,3vw,1.75rem);
display: grid;
gap: clamp(1.25rem,3vw,2rem);
grid-template-columns: repeat(auto-fit,minmax(9rem,1fr));
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--r);
box-shadow: var(--shadow);
}
.av-15__cell {
display: flex;
flex-direction: column;
align-items: center;
gap: .7rem;
text-align: center;
}
.av-15__fig {
position: relative;
display: block;
inline-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
overflow: hidden;
background: var(--panel-2);
box-shadow: 0 0 0 1px rgba(16,19,26,.06);
}
.av-15__init {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 50%;
background: hsl(var(--h) 72% 92%);
color: hsl(var(--h) 55% 30%);
font-size: calc(var(--size) * .32);
font-weight: 700;
letter-spacing: .02em;
}
.av-15__img {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
border-radius: 50%;
object-fit: cover;
object-position: center top;
transition: opacity .3s ease;
}
.av-15__fig[data-state="broken"] .av-15__img {
opacity: 0;
}
.av-15__fig[data-state="broken"] {
box-shadow: 0 0 0 1px var(--line), 0 0 0 3px var(--panel);
}
.av-15__fig:has(img[data-broken]) .av-15__img {
opacity: 0;
}
.av-15__who {
display: flex;
flex-direction: column;
gap: .1rem;
}
.av-15__name {
font-size: .92rem;
font-weight: 600;
letter-spacing: -.015em;
}
.av-15__meta {
font-size: .75rem;
font-weight: 600;
}
.av-15__meta--ok {
color: var(--ok);
}
.av-15__meta--bad {
color: var(--bad);
}
.av-15__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .85rem 1.25rem;
padding: .9rem 1.1rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
}
.av-15__btn {
appearance: none;
display: inline-flex;
align-items: center;
gap: .5rem;
min-block-size: 2.75rem;
padding: 0 1rem;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--ink);
color: var(--panel);
font: inherit;
font-size: .86rem;
font-weight: 600;
cursor: pointer;
transition: translate .16s ease, opacity .16s ease;
}
.av-15__btn svg {
inline-size: 1.05rem;
block-size: 1.05rem;
}
.av-15__btn:hover {
translate: 0 -1px;
opacity: .92;
}
.av-15__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.av-15__live {
margin: 0;
font-size: .82rem;
color: var(--muted);
}
.av-15[data-theme="dark"] {
--page: #0a0b0e;
--panel: #14161c;
--panel-2: #1c1f27;
--ink: #f2f4f9;
--muted: #98a0b0;
--line: #262a33;
--accent: #8b8cf9;
--ok: #4ade80;
--bad: #fb923c;
--glass: rgba(255,255,255,.07);
--shadow: 0 1px 2px rgba(0,0,0,.6), 0 20px 44px -24px rgba(0,0,0,.85);
}
.av-15[data-theme="dark"] .av-15__init {
background: hsl(var(--h) 42% 22%);
color: hsl(var(--h) 78% 82%);
}
@supports (color: oklch(50% .1 250)) {
.av-15[data-theme="dark"] .av-15__init {
background: oklch(30% .07 var(--h));
color: oklch(88% .12 var(--h));
}
}
@media (prefers-color-scheme: dark) {
.av-15:not([data-theme="light"]) {
--page: #0a0b0e;
--panel: #14161c;
--panel-2: #1c1f27;
--ink: #f2f4f9;
--muted: #98a0b0;
--line: #262a33;
--accent: #8b8cf9;
--ok: #4ade80;
--bad: #fb923c;
--glass: rgba(255,255,255,.07);
--shadow: 0 1px 2px rgba(0,0,0,.6), 0 20px 44px -24px rgba(0,0,0,.85);
}
.av-15:not([data-theme="light"]) .av-15__i--moon {
display: none;
}
.av-15:not([data-theme="light"]) .av-15__i--sun {
display: block;
}
.av-15:not([data-theme="light"]) .av-15__init {
background: hsl(var(--h) 42% 22%);
color: hsl(var(--h) 78% 82%);
}
@supports (color: oklch(50% .1 250)) {
.av-15:not([data-theme="light"]) .av-15__init {
background: oklch(30% .07 var(--h));
color: oklch(88% .12 var(--h));
}
}
}
@media (prefers-reduced-motion: reduce) {
.av-15 *,
.av-15 *::before,
.av-15 *::after {
transition: none !important;
animation: none !important;
}
}
@media (forced-colors: active) {
.av-15__init {
background: Canvas;
color: CanvasText;
box-shadow: inset 0 0 0 1px CanvasText;
}
.av-15__row,
.av-15__bar,
.av-15__btn,
.av-15__theme {
border-color: CanvasText;
}
}@supports (color: oklch(50% .1 250)) {
.av-15 {
--page: oklch(97.6% .004 265);
--panel: oklch(100% 0 0);
--panel-2: oklch(96.4% .006 265);
--ink: oklch(20% .022 265);
--muted: oklch(53% .022 265);
--line: oklch(92% .008 265);
--accent: oklch(58% .196 272);
--ok: oklch(58% .15 155);
--bad: oklch(58% .19 30);
}
.av-15__init {
background: oklch(93% .055 var(--h));
color: oklch(44% .13 var(--h));
}
}
.av-15 {
--page: #f7f8fa;
--panel: #ffffff;
--panel-2: #f1f3f7;
--ink: #10131a;
--muted: #5f6672;
--line: #e4e7ee;
--accent: #4f46e5;
--ok: #15803d;
--bad: #c2410c;
--glass: rgba(255,255,255,.68);
--shadow: 0 1px 2px rgba(16,19,26,.05), 0 16px 36px -20px rgba(16,19,26,.3);
--r: 18px;
--size: clamp(4rem,11vw,5.5rem);
width: 100%;
inline-size: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
position: relative;
background: var(--page);
color: var(--ink);
font-family: system-ui,-apple-system,"Segoe UI",sans-serif;
-webkit-font-smoothing: antialiased;
}
.av-15 *,
.av-15 *::before,
.av-15 *::after {
box-sizing: border-box;
}
.av-15__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.av-15 [hidden] {
display: none !important;
}
.av-15__theme {
position: absolute;
inset-block-start: clamp(.85rem,2.2vw,1.5rem);
inset-inline-end: clamp(.85rem,2.2vw,1.5rem);
z-index: 20;
display: grid;
place-items: center;
inline-size: 2.5rem;
block-size: 2.5rem;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--glass);
color: var(--ink);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
cursor: pointer;
transition: translate .2s ease;
}
.av-15__theme::after {
content: "";
position: absolute;
inset: -.4rem;
border-radius: inherit;
}
.av-15__theme:hover {
translate: 0 -1px;
}
.av-15__theme:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.av-15__i {
inline-size: 1.15rem;
block-size: 1.15rem;
}
.av-15__i--sun {
display: none;
}
.av-15[data-theme="dark"] .av-15__i--moon {
display: none;
}
.av-15[data-theme="dark"] .av-15__i--sun {
display: block;
}
.av-15__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: clamp(1.25rem,5vw,4rem);
}
.av-15__wrap {
inline-size: min(56rem,100%);
display: flex;
flex-direction: column;
gap: clamp(1.1rem,2.6vw,1.6rem);
}
.av-15__head {
display: flex;
flex-direction: column;
gap: .4rem;
padding-inline-end: 3.5rem;
}
.av-15__eyebrow {
margin: 0;
font-size: .7rem;
font-weight: 600;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
}
.av-15__h {
margin: 0;
font-size: clamp(1.6rem,4.2vw,2.4rem);
font-weight: 650;
letter-spacing: -.03em;
line-height: 1.05;
}
.av-15__sub {
margin: 0;
max-inline-size: 36rem;
font-size: .95rem;
line-height: 1.55;
color: var(--muted);
text-wrap: pretty;
}
.av-15__row {
list-style: none;
margin: 0;
padding: clamp(1.5rem,4vw,2.25rem) clamp(1rem,3vw,1.75rem);
display: grid;
gap: clamp(1.25rem,3vw,2rem);
grid-template-columns: repeat(auto-fit,minmax(9rem,1fr));
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--r);
box-shadow: var(--shadow);
}
.av-15__cell {
display: flex;
flex-direction: column;
align-items: center;
gap: .7rem;
text-align: center;
}
.av-15__fig {
position: relative;
display: block;
inline-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
overflow: hidden;
background: var(--panel-2);
box-shadow: 0 0 0 1px rgba(16,19,26,.06);
}
.av-15__init {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 50%;
background: hsl(var(--h) 72% 92%);
color: hsl(var(--h) 55% 30%);
font-size: calc(var(--size) * .32);
font-weight: 700;
letter-spacing: .02em;
}
.av-15__img {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
border-radius: 50%;
object-fit: cover;
object-position: center top;
transition: opacity .3s ease;
}
.av-15__fig[data-state="broken"] .av-15__img {
opacity: 0;
}
.av-15__fig[data-state="broken"] {
box-shadow: 0 0 0 1px var(--line), 0 0 0 3px var(--panel);
}
.av-15__fig:has(img[data-broken]) .av-15__img {
opacity: 0;
}
.av-15__who {
display: flex;
flex-direction: column;
gap: .1rem;
}
.av-15__name {
font-size: .92rem;
font-weight: 600;
letter-spacing: -.015em;
}
.av-15__meta {
font-size: .75rem;
font-weight: 600;
}
.av-15__meta--ok {
color: var(--ok);
}
.av-15__meta--bad {
color: var(--bad);
}
.av-15__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .85rem 1.25rem;
padding: .9rem 1.1rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
}
.av-15__btn {
appearance: none;
display: inline-flex;
align-items: center;
gap: .5rem;
min-block-size: 2.75rem;
padding: 0 1rem;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--ink);
color: var(--panel);
font: inherit;
font-size: .86rem;
font-weight: 600;
cursor: pointer;
transition: translate .16s ease, opacity .16s ease;
}
.av-15__btn svg {
inline-size: 1.05rem;
block-size: 1.05rem;
}
.av-15__btn:hover {
translate: 0 -1px;
opacity: .92;
}
.av-15__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.av-15__live {
margin: 0;
font-size: .82rem;
color: var(--muted);
}
.av-15[data-theme="dark"] {
--page: #0a0b0e;
--panel: #14161c;
--panel-2: #1c1f27;
--ink: #f2f4f9;
--muted: #98a0b0;
--line: #262a33;
--accent: #8b8cf9;
--ok: #4ade80;
--bad: #fb923c;
--glass: rgba(255,255,255,.07);
--shadow: 0 1px 2px rgba(0,0,0,.6), 0 20px 44px -24px rgba(0,0,0,.85);
}
.av-15[data-theme="dark"] .av-15__init {
background: hsl(var(--h) 42% 22%);
color: hsl(var(--h) 78% 82%);
}
@supports (color: oklch(50% .1 250)) {
.av-15[data-theme="dark"] .av-15__init {
background: oklch(30% .07 var(--h));
color: oklch(88% .12 var(--h));
}
}
@media (prefers-color-scheme: dark) {
.av-15:not([data-theme="light"]) {
--page: #0a0b0e;
--panel: #14161c;
--panel-2: #1c1f27;
--ink: #f2f4f9;
--muted: #98a0b0;
--line: #262a33;
--accent: #8b8cf9;
--ok: #4ade80;
--bad: #fb923c;
--glass: rgba(255,255,255,.07);
--shadow: 0 1px 2px rgba(0,0,0,.6), 0 20px 44px -24px rgba(0,0,0,.85);
}
.av-15:not([data-theme="light"]) .av-15__i--moon {
display: none;
}
.av-15:not([data-theme="light"]) .av-15__i--sun {
display: block;
}
.av-15:not([data-theme="light"]) .av-15__init {
background: hsl(var(--h) 42% 22%);
color: hsl(var(--h) 78% 82%);
}
@supports (color: oklch(50% .1 250)) {
.av-15:not([data-theme="light"]) .av-15__init {
background: oklch(30% .07 var(--h));
color: oklch(88% .12 var(--h));
}
}
}
@media (prefers-reduced-motion: reduce) {
.av-15 *,
.av-15 *::before,
.av-15 *::after {
transition: none !important;
animation: none !important;
}
}
@media (forced-colors: active) {
.av-15__init {
background: Canvas;
color: CanvasText;
box-shadow: inset 0 0 0 1px CanvasText;
}
.av-15__row,
.av-15__bar,
.av-15__btn,
.av-15__theme {
border-color: CanvasText;
}
}const av15 = document.querySelector('.av-15');
const av15live = av15.querySelector('#av-15-live');
const av15watch = (img) => {
const fig = img.closest('.av-15__fig');
const fail = () => { fig.dataset.state = 'broken'; img.dataset.broken = 'true'; };
img.addEventListener('error', fail);
img.addEventListener('load', () => { fig.dataset.state = 'ok'; delete img.dataset.broken; });
if (img.complete && img.naturalWidth === 0) fail();
};
av15.querySelectorAll('.av-15__img').forEach(av15watch);
av15.querySelector('#av-15-retry').addEventListener('click', () => {
const img = av15.querySelector('#av-15-broken');
const base = img.src.split('&cb=')[0];
av15live.textContent = 'Retrying Nina Kaur photo…';
img.src = base + '&cb=' + Date.now();
setTimeout(() => {
av15live.textContent = img.dataset.broken
? 'Still unavailable. Initials shown instead.'
: 'Photo loaded.';
}, 1200);
});
av15.querySelector('.av-15__theme').addEventListener('click', (e) => {
const on = av15.dataset.theme !== 'dark';
av15.dataset.theme = on ? 'dark' : 'light';
e.currentTarget.setAttribute('aria-pressed', String(on));
});const av15 = document.querySelector('.av-15');
const av15live = av15.querySelector('#av-15-live');
const av15watch = (img) => {
const fig = img.closest('.av-15__fig');
const fail = () => { fig.dataset.state = 'broken'; img.dataset.broken = 'true'; };
img.addEventListener('error', fail);
img.addEventListener('load', () => { fig.dataset.state = 'ok'; delete img.dataset.broken; });
if (img.complete && img.naturalWidth === 0) fail();
};
av15.querySelectorAll('.av-15__img').forEach(av15watch);
av15.querySelector('#av-15-retry').addEventListener('click', () => {
const img = av15.querySelector('#av-15-broken');
const base = img.src.split('&cb=')[0];
av15live.textContent = 'Retrying Nina Kaur photo…';
img.src = base + '&cb=' + Date.now();
setTimeout(() => {
av15live.textContent = img.dataset.broken
? 'Still unavailable. Initials shown instead.'
: 'Photo loaded.';
}, 1200);
});
av15.querySelector('.av-15__theme').addEventListener('click', (e) => {
const on = av15.dataset.theme !== 'dark';
av15.dataset.theme = on ? 'dark' : 'light';
e.currentTarget.setAttribute('aria-pressed', String(on));
});Here's a working CSS Avatar 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: CSS Avatar Fallback for Broken Images
Source: https://codefronts.com/components/css-avatars/css-avatar-fallback-for-broken-images/
What happens when the photo 404s — and this demo actually breaks one so you can see the fallback fire. The initials layer sits beneath the image from the start, so there is no flash and no layout shift; the image simply stops covering it. Includes the detail almost every tutorial misses: images that already failed before your script ran, caught with a complete && naturalWidth === 0 check.
## HTML
```html
<div class="av-15">
<button class="av-15__theme" type="button" aria-pressed="false">
<svg class="av-15__i av-15__i--moon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.4 8.4 0 0 1 9.7 3.5a8.5 8.5 0 1 0 10.8 10.8Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
<svg class="av-15__i av-15__i--sun" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4.1" stroke="currentColor" stroke-width="1.7"/><path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.7 5.7l1.4 1.4M16.9 16.9l1.4 1.4M18.3 5.7l-1.4 1.4M7.1 16.9l-1.4 1.4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<span class="av-15__vh">Toggle dark theme</span>
</button>
<div class="av-15__stage">
<section class="av-15__wrap" aria-labelledby="av-15-h">
<header class="av-15__head">
<p class="av-15__eyebrow">Resilience · Fallback</p>
<h2 class="av-15__h" id="av-15-h">When the photo 404s</h2>
<p class="av-15__sub">The third avatar points at a dead URL. Its initials were always there, underneath.</p>
</header>
<ul class="av-15__row">
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:264" aria-hidden="true">PS</span>
<img class="av-15__img" src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200&auto=format&fit=facearea&facepad=2.5" alt="Priya Sharma" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Priya Sharma</span><span class="av-15__meta av-15__meta--ok">Loaded</span></span>
</li>
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:152" aria-hidden="true">JL</span>
<img class="av-15__img" src="https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=200&auto=format&fit=facearea&facepad=2.5" alt="Jordan Lee" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Jordan Lee</span><span class="av-15__meta av-15__meta--ok">Loaded</span></span>
</li>
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:22" aria-hidden="true">NK</span>
<img class="av-15__img" id="av-15-broken" src="https://images.unsplash.com/photo-0000000000000-deleted-avatar?w=200&auto=format" alt="Nina Kaur" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Nina Kaur</span><span class="av-15__meta av-15__meta--bad">Photo unavailable</span></span>
</li>
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:318" aria-hidden="true">TB</span>
<img class="av-15__img" src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200&auto=format&fit=facearea&facepad=2.5" alt="Tom Bergström" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Tom Bergström</span><span class="av-15__meta av-15__meta--ok">Loaded</span></span>
</li>
</ul>
<div class="av-15__bar">
<button class="av-15__btn" type="button" id="av-15-retry">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20 12a8 8 0 1 1-2.6-5.9M20 4v4h-4" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
Retry the broken photo
</button>
<p class="av-15__live" id="av-15-live" aria-live="polite">1 of 4 photos failed. Initials shown instead.</p>
</div>
</section>
</div>
</div>
```
## CSS
```css
@supports (color: oklch(50% .1 250)) {
.av-15 {
--page: oklch(97.6% .004 265);
--panel: oklch(100% 0 0);
--panel-2: oklch(96.4% .006 265);
--ink: oklch(20% .022 265);
--muted: oklch(53% .022 265);
--line: oklch(92% .008 265);
--accent: oklch(58% .196 272);
--ok: oklch(58% .15 155);
--bad: oklch(58% .19 30);
}
.av-15__init {
background: oklch(93% .055 var(--h));
color: oklch(44% .13 var(--h));
}
}
.av-15 {
--page: #f7f8fa;
--panel: #ffffff;
--panel-2: #f1f3f7;
--ink: #10131a;
--muted: #5f6672;
--line: #e4e7ee;
--accent: #4f46e5;
--ok: #15803d;
--bad: #c2410c;
--glass: rgba(255,255,255,.68);
--shadow: 0 1px 2px rgba(16,19,26,.05), 0 16px 36px -20px rgba(16,19,26,.3);
--r: 18px;
--size: clamp(4rem,11vw,5.5rem);
width: 100%;
inline-size: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
position: relative;
background: var(--page);
color: var(--ink);
font-family: system-ui,-apple-system,"Segoe UI",sans-serif;
-webkit-font-smoothing: antialiased;
}
.av-15 *,
.av-15 *::before,
.av-15 *::after {
box-sizing: border-box;
}
.av-15__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.av-15 [hidden] {
display: none !important;
}
.av-15__theme {
position: absolute;
inset-block-start: clamp(.85rem,2.2vw,1.5rem);
inset-inline-end: clamp(.85rem,2.2vw,1.5rem);
z-index: 20;
display: grid;
place-items: center;
inline-size: 2.5rem;
block-size: 2.5rem;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--glass);
color: var(--ink);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
cursor: pointer;
transition: translate .2s ease;
}
.av-15__theme::after {
content: "";
position: absolute;
inset: -.4rem;
border-radius: inherit;
}
.av-15__theme:hover {
translate: 0 -1px;
}
.av-15__theme:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.av-15__i {
inline-size: 1.15rem;
block-size: 1.15rem;
}
.av-15__i--sun {
display: none;
}
.av-15[data-theme="dark"] .av-15__i--moon {
display: none;
}
.av-15[data-theme="dark"] .av-15__i--sun {
display: block;
}
.av-15__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: clamp(1.25rem,5vw,4rem);
}
.av-15__wrap {
inline-size: min(56rem,100%);
display: flex;
flex-direction: column;
gap: clamp(1.1rem,2.6vw,1.6rem);
}
.av-15__head {
display: flex;
flex-direction: column;
gap: .4rem;
padding-inline-end: 3.5rem;
}
.av-15__eyebrow {
margin: 0;
font-size: .7rem;
font-weight: 600;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
}
.av-15__h {
margin: 0;
font-size: clamp(1.6rem,4.2vw,2.4rem);
font-weight: 650;
letter-spacing: -.03em;
line-height: 1.05;
}
.av-15__sub {
margin: 0;
max-inline-size: 36rem;
font-size: .95rem;
line-height: 1.55;
color: var(--muted);
text-wrap: pretty;
}
.av-15__row {
list-style: none;
margin: 0;
padding: clamp(1.5rem,4vw,2.25rem) clamp(1rem,3vw,1.75rem);
display: grid;
gap: clamp(1.25rem,3vw,2rem);
grid-template-columns: repeat(auto-fit,minmax(9rem,1fr));
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--r);
box-shadow: var(--shadow);
}
.av-15__cell {
display: flex;
flex-direction: column;
align-items: center;
gap: .7rem;
text-align: center;
}
.av-15__fig {
position: relative;
display: block;
inline-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
overflow: hidden;
background: var(--panel-2);
box-shadow: 0 0 0 1px rgba(16,19,26,.06);
}
.av-15__init {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 50%;
background: hsl(var(--h) 72% 92%);
color: hsl(var(--h) 55% 30%);
font-size: calc(var(--size) * .32);
font-weight: 700;
letter-spacing: .02em;
}
.av-15__img {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
border-radius: 50%;
object-fit: cover;
object-position: center top;
transition: opacity .3s ease;
}
.av-15__fig[data-state="broken"] .av-15__img {
opacity: 0;
}
.av-15__fig[data-state="broken"] {
box-shadow: 0 0 0 1px var(--line), 0 0 0 3px var(--panel);
}
.av-15__fig:has(img[data-broken]) .av-15__img {
opacity: 0;
}
.av-15__who {
display: flex;
flex-direction: column;
gap: .1rem;
}
.av-15__name {
font-size: .92rem;
font-weight: 600;
letter-spacing: -.015em;
}
.av-15__meta {
font-size: .75rem;
font-weight: 600;
}
.av-15__meta--ok {
color: var(--ok);
}
.av-15__meta--bad {
color: var(--bad);
}
.av-15__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .85rem 1.25rem;
padding: .9rem 1.1rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
}
.av-15__btn {
appearance: none;
display: inline-flex;
align-items: center;
gap: .5rem;
min-block-size: 2.75rem;
padding: 0 1rem;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--ink);
color: var(--panel);
font: inherit;
font-size: .86rem;
font-weight: 600;
cursor: pointer;
transition: translate .16s ease, opacity .16s ease;
}
.av-15__btn svg {
inline-size: 1.05rem;
block-size: 1.05rem;
}
.av-15__btn:hover {
translate: 0 -1px;
opacity: .92;
}
.av-15__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.av-15__live {
margin: 0;
font-size: .82rem;
color: var(--muted);
}
.av-15[data-theme="dark"] {
--page: #0a0b0e;
--panel: #14161c;
--panel-2: #1c1f27;
--ink: #f2f4f9;
--muted: #98a0b0;
--line: #262a33;
--accent: #8b8cf9;
--ok: #4ade80;
--bad: #fb923c;
--glass: rgba(255,255,255,.07);
--shadow: 0 1px 2px rgba(0,0,0,.6), 0 20px 44px -24px rgba(0,0,0,.85);
}
.av-15[data-theme="dark"] .av-15__init {
background: hsl(var(--h) 42% 22%);
color: hsl(var(--h) 78% 82%);
}
@supports (color: oklch(50% .1 250)) {
.av-15[data-theme="dark"] .av-15__init {
background: oklch(30% .07 var(--h));
color: oklch(88% .12 var(--h));
}
}
@media (prefers-color-scheme: dark) {
.av-15:not([data-theme="light"]) {
--page: #0a0b0e;
--panel: #14161c;
--panel-2: #1c1f27;
--ink: #f2f4f9;
--muted: #98a0b0;
--line: #262a33;
--accent: #8b8cf9;
--ok: #4ade80;
--bad: #fb923c;
--glass: rgba(255,255,255,.07);
--shadow: 0 1px 2px rgba(0,0,0,.6), 0 20px 44px -24px rgba(0,0,0,.85);
}
.av-15:not([data-theme="light"]) .av-15__i--moon {
display: none;
}
.av-15:not([data-theme="light"]) .av-15__i--sun {
display: block;
}
.av-15:not([data-theme="light"]) .av-15__init {
background: hsl(var(--h) 42% 22%);
color: hsl(var(--h) 78% 82%);
}
@supports (color: oklch(50% .1 250)) {
.av-15:not([data-theme="light"]) .av-15__init {
background: oklch(30% .07 var(--h));
color: oklch(88% .12 var(--h));
}
}
}
@media (prefers-reduced-motion: reduce) {
.av-15 *,
.av-15 *::before,
.av-15 *::after {
transition: none !important;
animation: none !important;
}
}
@media (forced-colors: active) {
.av-15__init {
background: Canvas;
color: CanvasText;
box-shadow: inset 0 0 0 1px CanvasText;
}
.av-15__row,
.av-15__bar,
.av-15__btn,
.av-15__theme {
border-color: CanvasText;
}
}
```
## JavaScript
```js
const av15 = document.querySelector('.av-15');
const av15live = av15.querySelector('#av-15-live');
const av15watch = (img) => {
const fig = img.closest('.av-15__fig');
const fail = () => { fig.dataset.state = 'broken'; img.dataset.broken = 'true'; };
img.addEventListener('error', fail);
img.addEventListener('load', () => { fig.dataset.state = 'ok'; delete img.dataset.broken; });
if (img.complete && img.naturalWidth === 0) fail();
};
av15.querySelectorAll('.av-15__img').forEach(av15watch);
av15.querySelector('#av-15-retry').addEventListener('click', () => {
const img = av15.querySelector('#av-15-broken');
const base = img.src.split('&cb=')[0];
av15live.textContent = 'Retrying Nina Kaur photo…';
img.src = base + '&cb=' + Date.now();
setTimeout(() => {
av15live.textContent = img.dataset.broken
? 'Still unavailable. Initials shown instead.'
: 'Photo loaded.';
}, 1200);
});
av15.querySelector('.av-15__theme').addEventListener('click', (e) => {
const on = av15.dataset.theme !== 'dark';
av15.dataset.theme = on ? 'dark' : 'light';
e.currentTarget.setAttribute('aria-pressed', String(on));
});
```Here's a working CSS Avatar 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: CSS Avatar Fallback for Broken Images
Source: https://codefronts.com/components/css-avatars/css-avatar-fallback-for-broken-images/
What happens when the photo 404s — and this demo actually breaks one so you can see the fallback fire. The initials layer sits beneath the image from the start, so there is no flash and no layout shift; the image simply stops covering it. Includes the detail almost every tutorial misses: images that already failed before your script ran, caught with a complete && naturalWidth === 0 check.
## HTML
```html
<div class="av-15">
<button class="av-15__theme" type="button" aria-pressed="false">
<svg class="av-15__i av-15__i--moon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.3A8.4 8.4 0 0 1 9.7 3.5a8.5 8.5 0 1 0 10.8 10.8Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
<svg class="av-15__i av-15__i--sun" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4.1" stroke="currentColor" stroke-width="1.7"/><path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.7 5.7l1.4 1.4M16.9 16.9l1.4 1.4M18.3 5.7l-1.4 1.4M7.1 16.9l-1.4 1.4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<span class="av-15__vh">Toggle dark theme</span>
</button>
<div class="av-15__stage">
<section class="av-15__wrap" aria-labelledby="av-15-h">
<header class="av-15__head">
<p class="av-15__eyebrow">Resilience · Fallback</p>
<h2 class="av-15__h" id="av-15-h">When the photo 404s</h2>
<p class="av-15__sub">The third avatar points at a dead URL. Its initials were always there, underneath.</p>
</header>
<ul class="av-15__row">
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:264" aria-hidden="true">PS</span>
<img class="av-15__img" src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200&auto=format&fit=facearea&facepad=2.5" alt="Priya Sharma" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Priya Sharma</span><span class="av-15__meta av-15__meta--ok">Loaded</span></span>
</li>
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:152" aria-hidden="true">JL</span>
<img class="av-15__img" src="https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=200&auto=format&fit=facearea&facepad=2.5" alt="Jordan Lee" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Jordan Lee</span><span class="av-15__meta av-15__meta--ok">Loaded</span></span>
</li>
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:22" aria-hidden="true">NK</span>
<img class="av-15__img" id="av-15-broken" src="https://images.unsplash.com/photo-0000000000000-deleted-avatar?w=200&auto=format" alt="Nina Kaur" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Nina Kaur</span><span class="av-15__meta av-15__meta--bad">Photo unavailable</span></span>
</li>
<li class="av-15__cell">
<span class="av-15__fig" data-state="ok">
<span class="av-15__init" style="--h:318" aria-hidden="true">TB</span>
<img class="av-15__img" src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200&auto=format&fit=facearea&facepad=2.5" alt="Tom Bergström" width="200" height="200" decoding="async">
</span>
<span class="av-15__who"><span class="av-15__name">Tom Bergström</span><span class="av-15__meta av-15__meta--ok">Loaded</span></span>
</li>
</ul>
<div class="av-15__bar">
<button class="av-15__btn" type="button" id="av-15-retry">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20 12a8 8 0 1 1-2.6-5.9M20 4v4h-4" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
Retry the broken photo
</button>
<p class="av-15__live" id="av-15-live" aria-live="polite">1 of 4 photos failed. Initials shown instead.</p>
</div>
</section>
</div>
</div>
```
## CSS
```css
@supports (color: oklch(50% .1 250)) {
.av-15 {
--page: oklch(97.6% .004 265);
--panel: oklch(100% 0 0);
--panel-2: oklch(96.4% .006 265);
--ink: oklch(20% .022 265);
--muted: oklch(53% .022 265);
--line: oklch(92% .008 265);
--accent: oklch(58% .196 272);
--ok: oklch(58% .15 155);
--bad: oklch(58% .19 30);
}
.av-15__init {
background: oklch(93% .055 var(--h));
color: oklch(44% .13 var(--h));
}
}
.av-15 {
--page: #f7f8fa;
--panel: #ffffff;
--panel-2: #f1f3f7;
--ink: #10131a;
--muted: #5f6672;
--line: #e4e7ee;
--accent: #4f46e5;
--ok: #15803d;
--bad: #c2410c;
--glass: rgba(255,255,255,.68);
--shadow: 0 1px 2px rgba(16,19,26,.05), 0 16px 36px -20px rgba(16,19,26,.3);
--r: 18px;
--size: clamp(4rem,11vw,5.5rem);
width: 100%;
inline-size: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
position: relative;
background: var(--page);
color: var(--ink);
font-family: system-ui,-apple-system,"Segoe UI",sans-serif;
-webkit-font-smoothing: antialiased;
}
.av-15 *,
.av-15 *::before,
.av-15 *::after {
box-sizing: border-box;
}
.av-15__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.av-15 [hidden] {
display: none !important;
}
.av-15__theme {
position: absolute;
inset-block-start: clamp(.85rem,2.2vw,1.5rem);
inset-inline-end: clamp(.85rem,2.2vw,1.5rem);
z-index: 20;
display: grid;
place-items: center;
inline-size: 2.5rem;
block-size: 2.5rem;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--glass);
color: var(--ink);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
cursor: pointer;
transition: translate .2s ease;
}
.av-15__theme::after {
content: "";
position: absolute;
inset: -.4rem;
border-radius: inherit;
}
.av-15__theme:hover {
translate: 0 -1px;
}
.av-15__theme:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.av-15__i {
inline-size: 1.15rem;
block-size: 1.15rem;
}
.av-15__i--sun {
display: none;
}
.av-15[data-theme="dark"] .av-15__i--moon {
display: none;
}
.av-15[data-theme="dark"] .av-15__i--sun {
display: block;
}
.av-15__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: clamp(1.25rem,5vw,4rem);
}
.av-15__wrap {
inline-size: min(56rem,100%);
display: flex;
flex-direction: column;
gap: clamp(1.1rem,2.6vw,1.6rem);
}
.av-15__head {
display: flex;
flex-direction: column;
gap: .4rem;
padding-inline-end: 3.5rem;
}
.av-15__eyebrow {
margin: 0;
font-size: .7rem;
font-weight: 600;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
}
.av-15__h {
margin: 0;
font-size: clamp(1.6rem,4.2vw,2.4rem);
font-weight: 650;
letter-spacing: -.03em;
line-height: 1.05;
}
.av-15__sub {
margin: 0;
max-inline-size: 36rem;
font-size: .95rem;
line-height: 1.55;
color: var(--muted);
text-wrap: pretty;
}
.av-15__row {
list-style: none;
margin: 0;
padding: clamp(1.5rem,4vw,2.25rem) clamp(1rem,3vw,1.75rem);
display: grid;
gap: clamp(1.25rem,3vw,2rem);
grid-template-columns: repeat(auto-fit,minmax(9rem,1fr));
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--r);
box-shadow: var(--shadow);
}
.av-15__cell {
display: flex;
flex-direction: column;
align-items: center;
gap: .7rem;
text-align: center;
}
.av-15__fig {
position: relative;
display: block;
inline-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
overflow: hidden;
background: var(--panel-2);
box-shadow: 0 0 0 1px rgba(16,19,26,.06);
}
.av-15__init {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 50%;
background: hsl(var(--h) 72% 92%);
color: hsl(var(--h) 55% 30%);
font-size: calc(var(--size) * .32);
font-weight: 700;
letter-spacing: .02em;
}
.av-15__img {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
border-radius: 50%;
object-fit: cover;
object-position: center top;
transition: opacity .3s ease;
}
.av-15__fig[data-state="broken"] .av-15__img {
opacity: 0;
}
.av-15__fig[data-state="broken"] {
box-shadow: 0 0 0 1px var(--line), 0 0 0 3px var(--panel);
}
.av-15__fig:has(img[data-broken]) .av-15__img {
opacity: 0;
}
.av-15__who {
display: flex;
flex-direction: column;
gap: .1rem;
}
.av-15__name {
font-size: .92rem;
font-weight: 600;
letter-spacing: -.015em;
}
.av-15__meta {
font-size: .75rem;
font-weight: 600;
}
.av-15__meta--ok {
color: var(--ok);
}
.av-15__meta--bad {
color: var(--bad);
}
.av-15__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .85rem 1.25rem;
padding: .9rem 1.1rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
}
.av-15__btn {
appearance: none;
display: inline-flex;
align-items: center;
gap: .5rem;
min-block-size: 2.75rem;
padding: 0 1rem;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--ink);
color: var(--panel);
font: inherit;
font-size: .86rem;
font-weight: 600;
cursor: pointer;
transition: translate .16s ease, opacity .16s ease;
}
.av-15__btn svg {
inline-size: 1.05rem;
block-size: 1.05rem;
}
.av-15__btn:hover {
translate: 0 -1px;
opacity: .92;
}
.av-15__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.av-15__live {
margin: 0;
font-size: .82rem;
color: var(--muted);
}
.av-15[data-theme="dark"] {
--page: #0a0b0e;
--panel: #14161c;
--panel-2: #1c1f27;
--ink: #f2f4f9;
--muted: #98a0b0;
--line: #262a33;
--accent: #8b8cf9;
--ok: #4ade80;
--bad: #fb923c;
--glass: rgba(255,255,255,.07);
--shadow: 0 1px 2px rgba(0,0,0,.6), 0 20px 44px -24px rgba(0,0,0,.85);
}
.av-15[data-theme="dark"] .av-15__init {
background: hsl(var(--h) 42% 22%);
color: hsl(var(--h) 78% 82%);
}
@supports (color: oklch(50% .1 250)) {
.av-15[data-theme="dark"] .av-15__init {
background: oklch(30% .07 var(--h));
color: oklch(88% .12 var(--h));
}
}
@media (prefers-color-scheme: dark) {
.av-15:not([data-theme="light"]) {
--page: #0a0b0e;
--panel: #14161c;
--panel-2: #1c1f27;
--ink: #f2f4f9;
--muted: #98a0b0;
--line: #262a33;
--accent: #8b8cf9;
--ok: #4ade80;
--bad: #fb923c;
--glass: rgba(255,255,255,.07);
--shadow: 0 1px 2px rgba(0,0,0,.6), 0 20px 44px -24px rgba(0,0,0,.85);
}
.av-15:not([data-theme="light"]) .av-15__i--moon {
display: none;
}
.av-15:not([data-theme="light"]) .av-15__i--sun {
display: block;
}
.av-15:not([data-theme="light"]) .av-15__init {
background: hsl(var(--h) 42% 22%);
color: hsl(var(--h) 78% 82%);
}
@supports (color: oklch(50% .1 250)) {
.av-15:not([data-theme="light"]) .av-15__init {
background: oklch(30% .07 var(--h));
color: oklch(88% .12 var(--h));
}
}
}
@media (prefers-reduced-motion: reduce) {
.av-15 *,
.av-15 *::before,
.av-15 *::after {
transition: none !important;
animation: none !important;
}
}
@media (forced-colors: active) {
.av-15__init {
background: Canvas;
color: CanvasText;
box-shadow: inset 0 0 0 1px CanvasText;
}
.av-15__row,
.av-15__bar,
.av-15__btn,
.av-15__theme {
border-color: CanvasText;
}
}
```
## JavaScript
```js
const av15 = document.querySelector('.av-15');
const av15live = av15.querySelector('#av-15-live');
const av15watch = (img) => {
const fig = img.closest('.av-15__fig');
const fail = () => { fig.dataset.state = 'broken'; img.dataset.broken = 'true'; };
img.addEventListener('error', fail);
img.addEventListener('load', () => { fig.dataset.state = 'ok'; delete img.dataset.broken; });
if (img.complete && img.naturalWidth === 0) fail();
};
av15.querySelectorAll('.av-15__img').forEach(av15watch);
av15.querySelector('#av-15-retry').addEventListener('click', () => {
const img = av15.querySelector('#av-15-broken');
const base = img.src.split('&cb=')[0];
av15live.textContent = 'Retrying Nina Kaur photo…';
img.src = base + '&cb=' + Date.now();
setTimeout(() => {
av15live.textContent = img.dataset.broken
? 'Still unavailable. Initials shown instead.'
: 'Photo loaded.';
}, 1200);
});
av15.querySelector('.av-15__theme').addEventListener('click', (e) => {
const on = av15.dataset.theme !== 'dark';
av15.dataset.theme = on ? 'dark' : 'light';
e.currentTarget.setAttribute('aria-pressed', String(on));
});
```How this works
Don't swap — reveal. Replacing src with a placeholder means a second request that can also fail, and a visible flash. Instead, the fallback lives behind the photo permanently:
.av-15__fig{ position:relative; } /* initials layer */
.av-15__img{ position:absolute; inset:0; } /* photo on top */When the image fails it renders nothing, so the initials are simply uncovered — zero requests, zero shift, no flash.
The check everyone forgets. An error listener attached after the browser already failed the request never fires. So on init, ask each image whether it has finished and has no pixels:
imgs.forEach((img) => {
const fail = () => { img.closest('.av-15__fig').dataset.state = 'broken'; };
img.addEventListener('error', fail);
if (img.complete && img.naturalWidth === 0) fail(); // already dead
});A script-free path, too. If you can set an attribute server-side (or accept the inline handler), the whole thing becomes a selector — useful in static sites:
.av-15__fig:has(img[data-broken]) .av-15__init{ opacity:1; }And because the alt text is the person's name, assistive tech announces "Nina Kaur" whether the photo arrives or not. The third avatar in this demo points at a deliberately dead URL — that is the broken one, and it is indistinguishable from a designed initials avatar.
Make it yours
- Use the generated-hue monogram from demo 14 as the fallback layer so broken photos still look intentional and per-user.
- Swap initials for a neutral silhouette glyph when you have no name (invited-by-email users) — keep it inline SVG, not an emoji.
- Add
referrerpolicy="no-referrer"when hotlinking third-party avatars; a surprising share of "broken images" are referrer blocks, not 404s. - For a slow-network state rather than a failure, combine with demo 20's skeleton and treat
errorandtimeoutdifferently. - Retry once with a cache-busting parameter (as here) but never in a loop — a hard failure should settle into the fallback.
- In tables and dense lists, drop the retry button entirely; the fallback is enough and the extra control adds noise.
Gotchas — read before shipping
- An
errorlistener added after the failure never fires. Always add thecomplete && naturalWidth === 0check on init. onerrorthat rewritessrcto another remote URL can fail again and loop. Point at a local asset or reveal a CSS layer instead.- Never set
alt=""on a meaningful avatar — when the image breaks, the name is the only thing left. - Hiding the image with
display: noneon error can reflow the row; keep it absolutely positioned so the frame owns the geometry. - A broken image still shows the browser's own placeholder glyph in some engines if it has intrinsic size — absolute positioning plus a background-coloured frame hides it.
- Don't test only with a fake URL that returns 200 with an HTML error page: that decodes as an image error in some browsers and not others. Test a real 404.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by :has(), oklch(), backdrop-filter, text-wrap as this demo is written. The core technique itself goes back further — Chrome 105+.
The error event and naturalWidth are ancient and universal. :has() (Chrome 105 / Safari 15.4 / Firefox 121) powers the script-free variant and the theme toggle; oklch() sits behind @supports. The JavaScript is 18 lines of vanilla ES2024, no dependencies.