Tags
The Astro rocket logo with a pink flame and the astro wordmark in white on a black card

How I made Astro figures size themselves and enlarge on tap — intrinsic width, a native dialog, and @2x

Why width:100% blurred a 256px screenshot in Astro, and how a native dialog made every diagram readable on a phone. Seven measurements corrected the design.

On this page

Introduction

I dropped a screenshot of this site’s own table-of-contents sidebar into an article, and it came back looking terrible. The sidebar is a narrow strip of UI, and in the article it had been blown up to the full width of the reading column, soft enough that the text inside it was fuzzy, and tall enough to push everything else off the screen. While I was looking at it I noticed the other half of the problem: there was no way to make any figure bigger. If a reader wanted a closer look, the only thing available was pinching the whole page.

That screenshot had been live for an entire release and nothing had reported it. Nothing was wrong with the capture either. The blur came from one CSS declaration applied to every figure on the site, and the narrow ones were the only place it did any damage.

Fixing the blur turned out to be the small half. The bigger finding was on the other side of the same component: the figures that had never been stretched, and therefore looked fine, were the ones nobody could read. This article covers both, and the seven design decisions along the way that felt obviously right and were measurably wrong.

One declaration stretched every figure to the column

Every figure on this site goes through one component, and that component had a single rule for whatever it wrapped:

src/components/article/Figure.astro
.frame :global(svg),
.frame :global(img) {
display: block;
width: 100%;
height: auto;
}

For most of the corpus this is invisible. The illustrations are 1,100 to 1,774 pixels wide and the reading column is 662, so the rule only ever scales them down, which costs nothing. The sidebar screenshot was 256 pixels wide and 836 tall, so the same rule stretched it 2.48 times, to 662 by 2,161.

The interesting part is what the browser had to work with. Astro generates a srcset from the source file, and a 256-pixel source produces exactly one candidate:

<img src="/_astro/sidebar-rail.DjnEbD0x_Z25K3Dc.webp"
srcset="/_astro/sidebar-rail.DjnEbD0x_Z25K3Dc.webp 256w"
sizes="(min-width: 256px) 256px, 100vw"
width="256" height="836">

So the browser downloaded 256 pixels of image, was told by CSS to fill 662 pixels of layout, and had nothing larger to fetch. On the laptop I was looking at it on, with a device pixel ratio of 2, the real budget was 1,324 device pixels covered by 256 real ones.

A 256-pixel screenshot drawn at 662 pixels against the same file drawn at its own widthTwo horizontal bars measured against a 662-pixel reading column. The upper bar, labelled width: 100%, reaches the full column width of 662 pixels and is marked as a 2.48 times upscale needing 1,324 device pixels while the file supplies only 256. The lower bar, labelled width: auto, stops at 256 pixels, is marked as no upscale, and needs 512 device pixels against the same 256 supplied.reading column, 662 pxwidth: 100%2.48× upscale662 px drawnneeds 1,324 device px, has 256width: autono upscale256 px drawnneeds 512 device px, has 256The file is 256 px wide. Only one of these asks the browser for pixels it does not have.
The same file under both rules. The bars are the rendered width; the line underneath each is the number that decides whether it looks sharp.

Nothing in the repository was watching for this. The cross-locale check confirms that a figure referenced in English is also referenced in Japanese and Traditional Chinese; it never looks at the file. The figure-fit check renders each diagram in a browser and measures whether its labels overflow their boxes, but it only measures the SVG components, because those are the ones with translated text in them. A raster had no check of its own, so the only thing standing between a stretched screenshot and production was somebody noticing.

Deriving the size instead of declaring it

The fix is smaller than the bug. Astro emits width and height attributes on every image it processes, because it knows the file’s real dimensions at build time. Setting the CSS width to auto makes the browser use that number:

src/components/article/Figure.astro
.frame :global(img) {
display: block;
margin-inline: auto;
width: auto;
max-width: 100%;
max-height: 56rem;
height: auto;
}

The alternative was a width prop on the component, which I turned down. This blog keeps one folder per article with three locale files in it, so a width in the markup is a number that has to be written three times and kept in step with the asset by hand, and the consistency checker would need a new rule to police it. With width: auto the file is the only place the size is recorded, and replacing the file moves the layout with it.

The height cap was the first thing to go wrong. The agent’s initial value was min(48rem, 85svh), on the reasoning that a figure should never be taller than the window. Measured on a browser window 724 pixels tall, that clamped the 256-wide screenshot to 188 by 615, which is to say it resampled an image that had just been fixed for being resampled. A cap that tracks the viewport turns “never upscale” back into “always resize”. The value is now a flat 56rem, which clears the tallest figure in the corpus at 836 pixels and still catches a pathological export.

One last piece was cosmetic. A 224-pixel screenshot centred in a 662-pixel bordered box reads as a mistake, so the frame shrink-wraps a raster. The component receives its contents through a slot and cannot know on the server whether it wrapped a picture or a diagram, so the test happens in CSS:

src/components/article/Figure.astro
.figure:has(.frame img) .frame {
width: fit-content;
max-width: 100%;
margin-inline: auto;
}

@2x, because width: auto cannot tell dense from big

Sizing from the intrinsic width leaves one thing unresolved. A 448-pixel file meant to be a 224-pixel screenshot at double density is indistinguishable, to CSS, from a 448-pixel picture that wants to be 448 pixels wide. The signal has to come from somewhere else, and the cheapest place is the filename: a raster named <name>@2x.webp is stored at twice its intended size, and the component halves it with zoom: 0.5.

Two things in that rule were found by measurement rather than by reasoning, and both were silent.

The selector needs to match twice. astro build writes the stem into the asset name verbatim, producing /_astro/sidebar-rail@2x.HASH.webp, but the dev server routes images through an endpoint with the path as a query parameter, where the @ is percent-encoded to %402x. A rule matching only the raw form works in production and does nothing at all locally, which is the worst arrangement available. It was written that way first, and the reason it got caught is that the number in the browser did not move.

The two caps then behave differently under zoom, which is why one is doubled and the other is not:

Declared Where it actually clamps
max-height: 56rem 448 pixels of screen. Absolute lengths are in the element’s own halved pixels, so the cap lands at half of what it says.
max-width: 100% The frame’s real width. Percentages resolve against the containing block, which is not zoomed, so they already mean what they say.

The first attempt doubled both. They do not behave alike, and the cost was an image overflowing a squeezed frame by 70 pixels. With the rule working I asked for the original screenshot to be retaken at double resolution: it is now 448 by 1,672, draws at 224 by 836, and covers its 448 device pixels with 448 real ones.

The figures that looked fine were the unreadable ones

That is the sizing story, and it affected exactly one file badly. The other half of the work started when the agent proposed showing the enlarge affordance only where a figure was actually being shrunk, on the reasoning that offering to enlarge something already at full size promises the reader nothing.

I rejected that. A figure can be at full size and still have text in it too small to read, and that applies at least as much to the diagrams as to the screenshots. It is worth showing the arithmetic that settled it, because it is the number the rest of the article rests on.

Every diagram in this repo is authored on an 800-unit viewBox and rendered into the same 662-pixel frame as everything else. That is a scale of 0.795 on a desktop. On a 390-pixel phone the frame is 324 pixels wide, which is a scale of 0.405. Running through the label sizes actually used across the corpus:

Authored font-size Uses Desktop (0.795×) Phone (0.405×)
9px 20 7.2 CSS px 3.6 CSS px
10px 136 8.0 CSS px 4.1 CSS px
11px 135 8.7 CSS px 4.5 CSS px
13px 78 10.3 CSS px 5.3 CSS px

The diagrams are the most carefully made things on this site, and on a phone their labels render between three and a half and five pixels tall. A gate keyed to “is this being shrunk” would have hidden the affordance from exactly them, because a diagram is never shrunk. It is scaled, and scaling is what makes it illegible.

So the enlarge behaviour went universal: every figure opens, raster and diagram alike, at every viewport. That decision is what turned a fix for one blurry screenshot into a change affecting all 86 figures on the site, in three languages.

The same diagram labels measured as authored, in the article column, on a phone, and in the dialogFour cards showing one 800-unit diagram at four renderings. As authored the reference labels are 10 and 11 units. In the 662-pixel article column the scale is 0.795 and they render at 8.0 and 8.7 CSS pixels. On a 324-pixel phone column the scale is 0.405 and they fall to 4.1 and 4.5 CSS pixels, marked as the failure. In the dialog the diagram is drawn at 800 pixels, a scale of 1.0, and the labels return to 10 and 11 CSS pixels.As authored800-unit viewBoxreference10px label1011px label11CSS pxIn the column662 px×0.79510px label8.011px label8.7CSS pxOn a phone324 px×0.40510px label4.111px label4.5CSS pxIn the dialog800 px×1.00010px label1011px label11CSS pxNothing about the diagram changed. Only the box it was poured into did.
The same diagram at each size it is asked to be. The rightmost card is what opening it restores, and the rest of the article is about getting there.

The dialog needed two rules, not one

The site already had the pattern. The search modal is a native <dialog> opened with showModal(), which gives focus handling, Escape, and a ::backdrop without any of it being written by hand. It has one property that most image lightboxes lack: because it lives in the browser’s top layer rather than being a positioned overlay, pinch-to-zoom keeps working inside it. That matters more than it sounds, because it is the difference between a magnifier with a fixed ceiling and one the reader can keep pushing.

The first version fitted the figure to the window on both axes, which is what a lightbox does. Opening the 256 by 836 screenshot returned it at 202 by 661, smaller than the 256 by 836 it already had in the article. Fitting a portrait image to a landscape window is a reduction, so the first magnifier made things smaller.

The second version fixed that for rasters and was still wrong for diagrams. A raster now capped at the file’s real width and scrolled if it was taller than the window, which is correct. A diagram has no natural size to stop at, so it fitted the window, and on a 500-pixel-wide browser that produced a magnification of 1.03 times. The diagram already filled the column, so fitting it to the window gained nothing at all. The dialog reproduced the problem it existed to solve.

What both attempts missed is that the two media want opposite things. A raster has a fixed number of pixels, and enlarging past them invents detail. A diagram is vector, has no ceiling, and has a meaningful floor: the viewBox it was drawn in. Below 800 units its labels are the shrunken ones that were unreadable in the article to begin with. So the rules are split:

src/components/article/Figure.astro
/* A diagram fits the window, but never draws below the grid it was authored on. */
.figzoom-frame[data-figzoom-media='vector'] .figzoom-stage {
min-width: calc(var(--figzoom-vbw, 800) * 1px);
}
/* A raster shows every pixel it has, and pans if it is taller than the window. */
.figzoom-frame[data-figzoom-media='raster'] {
width: min(var(--figzoom-cap, 95vw), 95vw);
}
The two sizing rules the dialog applies, one per medium, and the single rule that was rejectedTwo cards. The left card covers diagrams, which are vector: the dialog fits them to the window but never draws them below their 800-unit viewBox, taking a phone from 324 pixels to 800. The right card covers screenshots, which are raster: the dialog caps them at the file width and pans if the image is taller than the window, so a 256-pixel file stays 256 pixels. A strip underneath records the rejected single rule, fitting the window on both axes, which returned a 256 by 836 screenshot at 202 by 661.Diagram (vector)Fits the window, floor at the viewBoxNo ceiling: scaling costs nothingnever below 800 px324 px → 800 px on a phoneScreenshot (raster)Caps at the file, pans if tallerHard ceiling: past it, detail is inventednever above the file width256 px → 256 px, scrollsOne rule for both: fit the window on each axisreturned the 256×836 screenshot at 202×661, smaller than the article already showed it
Two media, two ceilings. The strip underneath is the single rule tried first, and the numbers it returned.

At one unit per pixel a font-size: 10 label renders at 10 CSS pixels and the frame pans instead of shrinking, which is the same trade the article already makes for its widest diagrams. Measured on the built site, a diagram goes from 662 pixels inline to 1,342 in the dialog on a desktop, and from 324 to 800 on a phone. The font-size: 11 label that was 4.5 CSS pixels tall in the article is 11 CSS pixels in the dialog, and pinch-zoom takes it further from there without ever going soft.

The button that erased every figure’s description

At this point the work passed type checking, the cross-locale checks, the production build, and the figure-fit measurement. A review pass over the branch then found two defects, and neither is the kind a build catches.

The first was in the markup the agent had chosen for the trigger. Wrapping the whole figure in a <button> is the natural way to make it clickable, and it is quietly destructive: ARIA specifies that a button’s children are presentational. Everything inside is removed from the accessibility tree, and the tree is the only thing a screen reader has.

What that removed here is not decoration. Every diagram carries role="img" with an aria-labelledby pointing at a title and a description, and every raster carries alt. Those are the strings the translation pipeline maintains in three languages, and they are frequently the longest and most carefully written text attached to a figure. Wrapped in a button, all of it disappeared and a screen reader was left with the two words on the button, across every figure on the site.

The accessibility tree for one figure, before and after the trigger stopped wrapping itTwo panels showing the accessibility tree for the same figure. On the left the trigger is a button wrapped around the figure, so the tree holds a single node reading button Enlarge figure; the svg with role img and aria-labelledby, and the img with its alt text, are shown dimmed in dashed boxes and marked as dropped, because a button treats its children as presentational. The result is two words in place of the description on all 86 figures in three languages. On the right the trigger is a badge beside the figure, so the tree holds both an image node carrying the figure description and a separate button node named Enlarge figure.Button wrapped around the figurebutton "Enlarge figure"svg role="img" aria-labelledbyimg alt="…"Badge beside the figureimage "The sidebar table of…"button "Enlarge figure"Two words, on all 86 figures, in three languagesThe figure keeps its own description, and the control has a name
The dimmed rows are still in the DOM and still translated. They are simply not exposed.

The fix is to stop wrapping. The trigger is now a magnifier badge sitting inside the frame beside the figure rather than a button around it, so the image keeps its own semantics and the badge carries the name and the popup announcement. Clicking anywhere on the frame still opens the dialog for pointer users, but nothing depends on that path. Reading the accessibility tree back afterwards, each figure reports an image node with its full description and a separate button "Enlarge figure", which is what should have been there all along.

The width that was not the width

The second defect was a number that had already been seen and explained away.

The dialog sizes a raster from its real width, and the code read naturalWidth. On a responsive image that property is density corrected: for the width descriptors Astro emits, the density is the candidate’s width divided by whatever sizes resolves to, so the value it returns is the layout width rather than the file’s. Measured on a 390-pixel viewport, the 448-pixel file reported naturalWidth: 389.

That collapsed the ceiling to roughly the size the figure already was, disabling the enlargement worst on the narrow screens the feature exists for. The width attribute is the correct value, it was already in the code as the fallback, and the two were simply in the wrong order.

The uncomfortable part is that the 389 had appeared during an earlier measurement pass, and the agent had dismissed it as a quirk of the dev server. It was not. A measurement that gets explained away is worth as little as one never taken.

What the numbers said

Every figure below was measured against the production build rather than the dev server, because the dev image endpoint serves different candidates and gives confident, wrong readings.

Before After
Screenshot drawn at 662 × 2,161 224 × 836
Device pixels needed 1,324 448
Device pixels available 256 448
Diagram, desktop 662 px 1,342 px in the dialog
Diagram, phone 324 px 800 px in the dialog
font-size: 11 label, phone 4.5 CSS px 11 CSS px

The screenshot went from covering a fifth of the pixels it was asked to fill to covering all of them. On a phone the diagrams enlarge 2.47 times, and because they are vector rather than pictures of vector, they stay sharp at whatever magnification a reader pinches to on top of that.

Summary

The original bug was a single declaration that had been correct for every figure I happened to have, and wrong for the first one that did not fit the assumption. Replacing it with width: auto moves the decision to the file, where it can be changed by replacing something rather than by editing three locale files.

The larger finding was on the other side of the same component, and it only got looked at because the first proposal was rejected. Eighty-six figures were shipping with labels around four pixels tall on a phone, and none of them registered as a problem, because nothing about them had changed and nothing was measuring them. A blurry screenshot announces itself. A diagram that is merely too small does not.

The part worth keeping is neither fix. It is that seven separate design decisions in this work were plausible, defensible, and wrong, and every one of them was caught by putting a number on it rather than by thinking harder about it. The viewport-relative height cap, the dialog that fitted both axes, the diagram that gained three percent, the selector that was inert in dev, the two clamps that were assumed to behave alike, the button that erased the accessibility tree, and the property that returned the wrong width. None of them looked wrong. The height cap in particular read as obviously correct right up until the browser said 188.

A green build reported none of them. Type checking, cross-locale consistency, a production build and a figure-fit pass all came back clean while the accessibility tree was empty and the magnifier was returning images smaller than the ones it magnified.

References

Share this article