Skip to content

Commit 58b7fa4

Browse files
committed
fix: render brand marks via mask-image to fix mobile blur
iOS Safari rasterizes <img>-rendered SVGs at 1× device pixels when a CSS filter is applied, producing blur on retina/mobile. Switch the brand Mark to a <span> with mask-image + background: currentColor so recoloring happens on the GPU and stays vector-sharp at any DPR. Bump the team avatar request to ?size=84 for 3× DPR. Amp-Thread-ID: https://ampcode.com/threads/T-019e022a-fabb-703a-85e7-a4c1dcf38b1f
1 parent 91d3472 commit 58b7fa4

2 files changed

Lines changed: 41 additions & 34 deletions

File tree

src/routes/index.tsx

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -317,11 +317,16 @@ const logoOverrides = Object.fromEntries(
317317
)
318318

319319
/**
320-
* Render a brand mark — an `<img src="/logo/:slug">` when the slug is
321-
* in the manifest, else a `<span>` plain-text fallback. No client-side
322-
* image fallback so SSR HTML is final and there's no broken-image
323-
* flicker. Width is computed from the manifest aspect ratio so the
324-
* intrinsic image dimensions match the rendered box (no CLS).
320+
* Render a brand mark via CSS `mask-image` + `background: currentColor`
321+
* when the slug is in the manifest, else a `<span>` plain-text fallback.
322+
* No client-side image fallback so SSR HTML is final and there's no
323+
* broken-image flicker. Width is computed from the manifest aspect
324+
* ratio so the layout box matches the visual size (no CLS).
325+
*
326+
* Why mask instead of `<img>` + `filter`: iOS Safari has a long-standing
327+
* bug where applying `filter` to an `<img>` containing an SVG forces
328+
* rasterization at 1× device pixels, producing blur on retina/mobile.
329+
* `mask-image` recolors via the GPU and stays sharp at any DPR.
325330
*/
326331
function Mark({
327332
height,
@@ -339,24 +344,33 @@ function Mark({
339344
return <span className="text-lg font-medium tracking-[-0.01em] text-primary">{name}</span>
340345
// Per-slug visual scale lives in config (not the SVG bytes) so we
341346
// don't have to reason about server-side viewBox clipping. We
342-
// multiply both `<img>` dimensions so the layout box reflects the
343-
// visual size — important for the marquee, where transform-based
344-
// scaling would overflow into the overflow-hidden clip.
347+
// multiply both dimensions so the layout box reflects the visual
348+
// size — important for the marquee, where transform-based scaling
349+
// would overflow into the overflow-hidden clip.
345350
// lowercase to match the case-insensitive override map
346351
const scale = logoOverrides[slug.toLowerCase()]?.scale ?? 1
347352
const renderedHeight = height * scale
353+
const renderedWidth = Math.round(renderedHeight * ratio)
354+
const url = `/logos/mono/${slug}.svg`
348355
return (
349-
<img
350-
// "{name} logo" is more descriptive for SEO image search than just the
351-
// brand name. The parent <a> still carries `aria-label={name}` for SR
352-
// brevity, so we don't double-announce "logo" to assistive tech.
353-
alt={`${name} logo`}
354-
className="block w-auto transition-[filter] duration-100"
356+
<span
357+
aria-hidden="true"
358+
className="block bg-current transition-colors duration-100"
355359
data-mark
356-
height={renderedHeight}
357-
src={`/logos/mono/${slug}.svg`}
358-
style={{ height: `${renderedHeight}px` }}
359-
width={Math.round(renderedHeight * ratio)}
360+
role="img"
361+
style={{
362+
height: `${renderedHeight}px`,
363+
width: `${renderedWidth}px`,
364+
WebkitMaskImage: `url(${url})`,
365+
maskImage: `url(${url})`,
366+
WebkitMaskRepeat: 'no-repeat',
367+
maskRepeat: 'no-repeat',
368+
WebkitMaskPosition: 'left center',
369+
maskPosition: 'left center',
370+
WebkitMaskSize: 'contain',
371+
maskSize: 'contain',
372+
}}
373+
title={name}
360374
/>
361375
)
362376
}
@@ -513,7 +527,9 @@ function Team() {
513527
alt={`${t.handle} avatar`}
514528
className="block size-7 rounded-full border border-primary"
515529
height={28}
516-
src={`https://github.com/${t.github}.png?size=56`}
530+
// 84 = 28 × 3 so the avatar stays sharp on 3× DPR mobile
531+
// displays (the GitHub avatar service rounds up internally).
532+
src={`https://github.com/${t.github}.png?size=84`}
517533
width={28}
518534
/>
519535
),

src/styles.css

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -112,23 +112,14 @@
112112
}
113113

114114
/*
115-
* Brand-mark colors. Bytes ship pure white so we can recolor entirely
116-
* in CSS — `<img>`-rendered SVGs don't inherit parent paint, so we
117-
* lean on `filter` instead of `currentColor`. `brightness(< 1)` darkens
118-
* white toward gray; on hover we collapse to pure black (light) or
119-
* leave it pure white (dark).
115+
* Brand-mark colors. Bytes ship pure white so we use the SVG as a CSS
116+
* `mask-image` and paint via `background: currentColor`. This keeps the
117+
* mark sharp at any DPR (iOS Safari rasterizes `<img>` SVGs at 1× when
118+
* a CSS `filter` is applied — using mask + currentColor sidesteps it).
120119
*/
121120
[data-mark] {
122-
filter: brightness(0.42); /* ~#6b6b6b — matches `--color-muted` light */
121+
color: var(--color-muted);
123122
}
124123
a:hover [data-mark] {
125-
filter: brightness(0);
126-
}
127-
@media (prefers-color-scheme: dark) {
128-
[data-mark] {
129-
filter: brightness(0.54); /* ~#8a8a86 — matches `--color-muted` dark */
130-
}
131-
a:hover [data-mark] {
132-
filter: none;
133-
}
124+
color: var(--color-primary);
134125
}

0 commit comments

Comments
 (0)