Appearance
Image
A picture. Reach for it whenever the thing on screen is a bitmap somebody produced elsewhere: a photograph, a thumbnail, an avatar, a piece of album art. When the thing is a glyph the interface drew for itself, use an icon, which rasterises a path instead of decoding a document, and when it moves, use a video.
Image is a box with a bitmap in it. It does not fetch anything itself: it asks the resolver in the runtime's media store for its source and draws whatever comes back, which is what puts the fetch and the decode on the thread the application already runs on rather than on the shell.
tsx
/**
* One picture, four boxes, and the four ways a bitmap can meet one.
*
* The swatch is 3:1 and every box is 112 by 72, so `cover` crops the
* sides, `contain` leaves a gap above and below, `fill` squashes it,
* and `none` draws it at its own size and lets the box clip. Nothing
* here fetches anything: the resolver above paints the swatch, and all
* four `Image`s name the same source, so between them they cause one
* decode and share one bitmap.
*
* The fifth names a source that fails, and the sixth has no `alt`, so
* it is decorative: no role, no name, and nothing in the semantics
* tree at all.
*
* The resolver is not installed here. It is declared by the worker
* entry that renders this page, with `renderRoot(...).useMedia(...)`,
* because an `Image` asks for its bitmap the moment it is built and
* the tree is built inside the runtime's constructor.
*/
export function Gallery(_inputs: Inputs<{}>, _ctx: ComponentContext) {
return (
<column gap={16} padding={20} width={percent(100)} height={percent(100)}>
<row gap={12}>
<Fitted fit="cover" />
<Fitted fit="contain" />
<Fitted fit="fill" />
<Fitted fit="none" />
</row>
<row gap={10} y="center">
<Image
src="missing.png"
alt="A swatch that failed"
placeholderColor="danger"
width={112}
height={72}
borderRadius={8}
/>
<column gap={4}>
<text text="A source that fails keeps the placeholder tint, here danger." fontSize={12} />
<text text="There is no error slot: draw your own beside it." fontSize={12} color="textMuted" />
</column>
</row>
<row gap={8} y="center">
<Image src="swatch.png" width={12} height={12} borderRadius={6} />
<text text="The bullet has no alt, so it is decorative." fontSize={12} color="textMuted" />
</row>
</column>
);
}
/** One box, captioned with the fit it was given. */
function Fitted(inputs: Inputs<{ fit: 'cover' | 'contain' | 'fill' | 'none' }>) {
const fit = inputs.fit.value;
return (
<column gap={6}>
<Image src="swatch.png" alt={fit} width={112} height={72} objectFit={fit} borderRadius={8} />
<text text={fit} fontSize={12} color="textMuted" />
</column>
);
}The swatch is three times as wide as it is tall and every box is 112 by 72, so the four fits have something to disagree about. All four name one source, so between them they cause one fetch and one decode; the spec beside the example asserts that they are handed the same bitmap object.
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
src | string | required | What the resolver is asked for. Read once, when the component is built. |
alt | string | none | What a screen reader reads. Omitting it makes the picture decorative. |
objectFit | 'fill' | 'cover' | 'contain' | 'none' | 'cover' | How the bitmap meets a box that is not its shape. |
borderRadius | number | 0 | Rounds the box, and clips the picture to it. |
placeholderColor | UiColorValue | controlBackground | The tint while the bitmap decodes, and after it fails. |
ref | UiNodeRef | none | Receives the node the picture is drawn on, for measuring it or anchoring something to it. |
src is read once because a component's body runs once, and an image whose source changed is a different image: give it a key that changes with the source and let the old node go, which is also what releases the old bitmap.
placeholderColor takes a palette name or a colour outright, and the default is the theme's controlBackground. Reach for it only when the picture is going somewhere the theme cannot know about: over a photograph, or in a panel of its own colour, where the control background would be a rectangle of the wrong shade until the bitmap arrives. It is read once, like src, because a placeholder that changed after the picture landed would have nothing left to tint.
Every prop takes a plain value or an Observable of one, and the layout props on the library page apply here too. Image also attaches rootModifiers to the node it draws on, so a sharedElement or a motion can be put on a picture without wrapping it in a box.
The resolver
The bitmap comes from an ImageResolver, which the runtime holds on its media store, one per runtime. The default one fetches the source, decodes it with createImageBitmap, keeps one entry per source however many pictures asked for it, reference counts what is on screen and evicts what is not.
An application that fetches through its own stack replaces it, where its root is declared. In a render worker that is the worker entry:
ts
// app.render.worker.ts
renderRoot(AppRoot).useMedia({ resolver: new DefaultImageResolver({ capacity: 128 }) });On the single thread the builder takes the same object, and a spec hands it to the runtime directly:
ts
createApp(AppRoot).useMedia({ resolver: myResolver }).mountSync('#app');
renderTest(root, { media: { resolver: myResolver } });Declaring it there rather than setting it afterwards matters more than it looks. The tree is built inside the runtime's constructor and an Image in it asks for its bitmap the moment it is built, so a resolver installed once there is an app to install it on has already missed the first screen. MediaService.setResolver is still there for a resolver that has to change with the screen, and it is subject to that same rule: call it from the body of a component above the first picture, because a body runs before the elements it returns are built.
The resolver this page's pictures come from is below, and the worker entry beside the example declares it:
tsx
/**
* The picture this page draws, made rather than fetched.
*
* A documentation page has no business going to the network, so the
* swatch is painted into an `OffscreenCanvas` and handed over as an
* `ImageBitmap`, which is exactly what `createImageBitmap` would have
* produced from a downloaded PNG. It is 180 by 60, three times as wide
* as it is tall, so the fits below have something to disagree about.
*
* Under a spec there is no `OffscreenCanvas`, and nothing draws
* anything: a stub with a size stands in, because the paint state
* reads a bitmap's width and height and the recording canvas never
* looks at its pixels.
*/
async function swatch(): Promise<UiImage> {
const size = { width: 180, height: 60 };
if (typeof OffscreenCanvas === 'undefined') {
return { ...size, close: () => {} } as unknown as UiImage;
}
const canvas = new OffscreenCanvas(size.width, size.height);
const context = canvas.getContext('2d');
if (context === null) {
return { ...size, close: () => {} } as unknown as UiImage;
}
const wash = context.createLinearGradient(0, 0, size.width, size.height);
wash.addColorStop(0, '#2f6f97');
wash.addColorStop(1, '#8f4f9c');
context.fillStyle = wash;
context.fillRect(0, 0, size.width, size.height);
context.fillStyle = 'rgba(255, 255, 255, 0.35)';
for (let x = -60; x < size.width; x += 24) {
context.beginPath();
context.moveTo(x, size.height);
context.lineTo(x + 30, 0);
context.lineTo(x + 40, 0);
context.lineTo(x + 10, size.height);
context.fill();
}
return canvas.transferToImageBitmap();
}
/**
* The resolver every `Image` on this page is served by.
*
* `DefaultImageResolver` with both of its seams replaced: `fetch`
* answers with an empty blob instead of a request, and `decode`
* ignores it and paints the swatch. Everything in front of those two
* is the real thing, which is what the page is about: one decode for
* however many `Image`s name a source, reference counting so the
* bitmap outlives none of them, and a rejection that is not cached.
*
* `missing.png` fails, because a page that only ever showed the happy
* path would not say what a broken source looks like.
*/
export function swatchResolver(): ImageResolver {
return new DefaultImageResolver({
fetch: source =>
source === 'missing.png' ? Promise.reject(new Error(`'${source}' is not there.`)) : Promise.resolve(new Blob()),
decode: () => swatch()
});
}Nothing on this page goes to the network: the swatch is painted into an OffscreenCanvas and handed over as an ImageBitmap, which is exactly what a decoded PNG would have been. Images and the resolver is where the caching, eviction and reference counting are described in full.
A limit worth knowing before you hit it: createImageBitmap takes a blob, and Chrome refuses an SVG one, so an Image pointed at an .svg fails where the same file in an <img> would have worked. A vector glyph belongs in Icon.
Loading and failure
The picture arrives late by construction, so the box has to look like something in the meantime. It is filled with placeholderColor, which is the theme's controlBackground unless the call site named another, and the fill is dropped when the bitmap arrives, so a list of thumbnails holds its shape instead of jumping as they land.
A source that fails keeps that same tint and nothing else happens: there is no error slot, no retry, and no callback to hang one on. The broken picture in the example above is tinted danger, which is a placeholder colour doing the only thing a placeholder colour can do about a failure. What to do instead is draw your own beside it, and decide with the resolver whether to ask again. A rejection is not cached, so the next component that names the source does try again.
Fits
objectFit | What it does |
|---|---|
cover | Fills the box and crops what does not fit. The default, and usually right. |
contain | Fits the whole picture inside the box and leaves the rest empty. |
fill | Stretches to the box, changing the picture's shape. |
none | Draws at the bitmap's own size, clipped by the box. |
The box's size is the layout's, not the picture's: an Image given no width or height is a box with no content size, so give it one, or a flex, or let a parent stretch it.
Keyboard
None. A picture is not a tab stop and binds no keys. It is marked unselectable, so a drag that starts on it belongs to the list it sits in rather than becoming a text selection.
Semantics
| What | Value |
|---|---|
| Role | image, when alt was given, on the box the picture is drawn on |
| Name | alt |
| States | None. A picture has nothing to be |
| Value | None |
An Image with no alt has no role and no name, so it is not in the semantics tree at all. That is ARIA's rule for a decorative picture and it is the right default for a bullet or a divider glyph, but it is a decision rather than an oversight: there is no way to get a nameless image record, because a screen reader announcing "image" and nothing else is worse than silence.
Neither the loading tint nor the failure is announced. If the difference matters to the reader, say it in text beside the picture, where everybody gets it.
Next
Video is the same rectangle with a moving picture in it, and Icon is the one to reach for when the picture is a glyph.