# 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.

- Source: https://oharu121.com/blog/astro-figure-intrinsic-width-native-dialog-2x-density/
- Published: 2026-08-15T09:17:03+09:00
- Tags: Astro, CSS, Web API

---
## 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:

```css title="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:

```html
<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.

*Figure — StretchedRaster: 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:

```css title="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:

```css title="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.**

*Figure — ScaleCollapse: 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:

```css title="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);
}
```

*Figure — DialogRules: 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.

*Figure — A11yTree: 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

- [MDN: the dialog element, including showModal and the top layer](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog)
- [WAI-ARIA 1.2 on presentational children, which is why a button around a figure hides it](https://www.w3.org/TR/wai-aria-1.2/#childrenArePresentational)
- [HTML Standard: naturalWidth, defined as the density-corrected intrinsic width](https://html.spec.whatwg.org/multipage/embedded-content.html#dom-img-naturalwidth)
- [Astro images, covering the width and height attributes emitted at build time](https://docs.astro.build/en/guides/images/)
- [CSS Viewport Module: the zoom property](https://drafts.csswg.org/css-viewport/#zoom-property)
