Docs

Images and assets

The image element (image(..) in Rust, <Image> in TSX) shows a bitmap or an SVG drawing: a file shipped with the app, or an image downloaded from a URL. Images are read and decoded off the main thread, downsampled to the size the view shows them at, and cached; the main thread only displays the decoded result.

const LOGO: ImageAsset = asset!("logo@2x.png");

image(LOGO)
image("https://picsum.photos/seed/nsh/600/400").w_full().h(px(200.0)).rounded(px(12.0))

The element’s props are listed on image.

#Images in the app

A Rust crate keeps its files in its assets/ directory: asset!("logo@2x.png") (the path relative to assets/) is an ImageAsset, { url, size, scales }, read when the crate compiles, and registry.assets(assets!()) in the crate’s #[native_module] function ships every file of the directory with the app. In TSX, import an image file like a module: the import gives an ImageAsset, { url, size, scales? }, and the bundler ships the file with the app:

const LOGO: ImageAsset = asset!("logo@2x.png");

// LOGO.url: "asset:/@my-app/logo@2x.png", LOGO.size: 64 × 64 points

#[native_module("app/native")]
fn native(registry: &mut Registry) {
    registry.assets(assets!());
}

Supported files: .png, .jpg, .jpeg, .gif, .webp, .heic, and .svg (SVG images).

Sizes are in points. An @2x or @3x suffix in the file name says the file has 2 or 3 pixels per point, so logo@2x.png at 128×128 pixels is a 64×64 point asset. Without a suffix, one pixel is one point. A file named with a suffix is the one that shows; a file named without one shows its pixel-density variants when it has them.

Natural size. An image from an asset lays out at its natural size when its style leaves the width or height open, so image(LOGO) and <Image src={logo} /> need no style. Set one dimension and the aspect ratio, or both, to size it differently.

In Rust, a missing or broken file fails the build, and a changed one builds the crate again. In TSX, assets are resolved relative to the importing module, and their url is relative to the app’s root (the entry’s directory). nsh build copies them to out/assets/, nsh package puts them in the app, and during development the dev server serves them.

#Pixel densities

Name the file without a suffix and put its denser versions next to it, and the app loads the one that fits the screen:

assets/
├── logo.png       64×64 pixels
├── logo@2x.png    128×128
└── logo@3x.png    192×192
const LOGO: ImageAsset = asset!("logo.png");

// LOGO.size: 64 × 64 points, LOGO.scales: [1.0, 2.0, 3.0]
  • asset! and the bundler look for @1.5x, @2x, @3x and @4x siblings of an image named without a suffix. scales lists them, with 1 for the named file; an image without siblings has no scales (in TSX it imports as { url, size }). The bundler ships the siblings it finds, and assets!() ships the whole directory.
  • The image loads the smallest scale at least the screen’s pixel ratio, otherwise the largest, as React Native picks: logo.png on a 1× screen, logo@2x.png on a 2× one, logo@3x.png on a 3× phone (and at 2.625). On the web only that file is downloaded.
  • When the pixel ratio changes (a window moved to another screen, browser zoom), the images whose choice changes load again, with their load events. Their size stays the named file’s size, in points.
  • A variant whose pixels are not the named file’s size times its scale gets a build warning in TSX and fails the build in Rust.
  • A file named with a density suffix (./logo@3x.png, asset!("logo@3x.png")) is that file alone.
  • An SVG image is drawn for the screen at any ratio, so it ships no variants.

One file is fine too: export it at the highest density you need (@3x for the sharpest result on every phone) and name it with its suffix; images are downsampled to the screen when decoded. Variants let each screen download, decode and keep the file that fits it.

#SVG images

Import an .svg file like any image. Its size in points is its width and height (or one of them, with the other from the viewBox’s aspect ratio), else its viewBox: a CSS pixel of the SVG is a point, and @2x suffixes leave SVG files as they are.

import logo from "./assets/logo.svg"; // <svg width="120" height="40" …>
import star from "./assets/star.svg"; // <svg viewBox="0 0 24 24" …>

logo; // { url: "asset:/assets/logo.svg", size: { width: 120, height: 40 } }

<Image src={logo} />
<Image src={star} style={{ width: 36, height: 36, tintColor: "#0969da" }} />

An SVG is sharp at the size it is shown. The browser draws it as vectors; iOS, macOS and Android draw it at the size the view shows it, in pixels, when the image loads (as they downsample bitmaps; a view that grows afterwards scales that drawing, until the image loads again); Windows, Linux and GPUI draw it once at four pixels per point (up to 2048 pixels a side) and scale it to each view. Everything else works as for bitmaps: resizeMode, tintColor (through the SVG’s opaque shapes, so one-color icons follow the theme), filters, blurRadius, placeholders, the load events, Image.getSize and Image.prefetch. onLoad and Image.getSize report an SVG’s size in whole points; the image lays out at its exact size. Remote SVG files load the same way, through the same downloads and capability checks; NSH recognizes an SVG by its content (also gzip-compressed, .svgz), whatever its URL ends with.

What native platforms draw. iOS, macOS, Android, Windows, Linux and GPUI draw SVGs with one renderer (resvg), so a drawing looks the same on each: shapes and paths, fills and strokes, gradients and patterns, clip paths and masks, opacity, filters and transforms. Text (<text>), animation (SMIL and CSS), embedded images (<image>) and HTML (<foreignObject>) show in the browser; native platforms draw the rest of the SVG, still, and nsh build names the files that use them. Export text as outlines (paths) for the same result everywhere.

A self-contained picture. On native platforms an SVG draws only what it holds: files, URLs, data: images and other documents it references are left out. Native platforms draw SVGs, remote ones included, within bounds that keep memory in check: a document of up to 16 MiB (a compressed one once inflated), a drawing of up to 8192 pixels a side and 4096 × 4096 pixels in all (64 MiB; a larger one is drawn smaller), and layers (groups with opacity, masks, clip paths and filters, which the renderer draws apart) of up to 256 MiB at once. An SVG over the document or layer bound fails to load, with onError.

Reported sizes on the web. The browser measures SVGs itself, imported or remote: onLoad and Image.getSize report an SVG’s width and height when it has them, and otherwise the size the browser gives it, which browsers typically derive from a default of 300 × 150 and the viewBox’s aspect ratio. Imported SVGs lay out at the bundler’s size on every platform. Give SVGs a width and height for the same reported size everywhere.

#Remote images

Give the element a URL: a string in Rust (a closure for one that changes), a URL string in TSX:

image(move || user.with(|u| u.avatar_url.clone()))
    .size(px(48.0))
    .rounded(px(24.0))
    .bg(rgb(0xeaeef2))
  • Give remote images a size. Their size is unknown until they load, so without a width and height (or one of them and an aspect ratio) they collapse to no height.
  • A background color shows while the image loads, or use a placeholder (below).
  • Downloads are shared: several views showing the same URL download it once, also when they decode it at different sizes. A download nobody waits for any more is cancelled.
  • Decoded images are cached in memory (the cache is purged under memory pressure), keyed by URL, decode size and blur radius.
  • When the app declares capabilities, the image’s host must be allowed under network; a refused image fails with its error event.

#Resize modes

The resize mode (.resize_mode(..) in Rust, resizeMode in TSX) decides how an image fills a box of a different shape:

ValueResult
"cover" (ResizeMode::Cover, default)Fills the box, keeping the aspect ratio, cropping what overflows
"contain" (ResizeMode::Contain)Fits inside the box, keeping the aspect ratio, leaving empty bands
"stretch" (ResizeMode::Stretch)Fills the box exactly, distorting the image
"center" (ResizeMode::Center)Natural size, centered
image(photo).resize_mode(ResizeMode::Contain).size(px(140.0)).bg(rgb(0xeaeef2))

The Tailwind classes object-cover, object-contain, … do the same.

#Styling images

Images are views: they take corner radii (also per corner), borders, shadows, opacity and transforms. Corners clip the image.

image(avatar).size(px(90.0)).rounded(px(45.0)).border(px(3.0)).border_color(rgb(0x8250df))
  • Tint (.tint_color(..) in Rust, the tintColor style in TSX) recolors the image’s opaque pixels: use it for one-color icons that follow the theme.
  • Filters (.grayscale(..), .sepia(..), .blur(..); filter in TSX) apply to images on both platforms (Filters).
image(ICON).size(px(24.0)).tint_color(AdaptiveColor::rgb(0x0969da, 0x58a6ff))

#Placeholders, fading and blur

PropRustDescription
placeholder.placeholder(..)An image shown until src has loaded, a URL or an asset; best a local asset
fadeDuration.fade_duration(ms)Milliseconds the loaded image fades in over. Default 0 on iOS, 300 on Android
blurRadius.blur_radius(px(..))Blurs the image by this radius in points, applied once when decoding
image(move || format!("https://picsum.photos/seed/{}/300/300", id.get()))
    .placeholder(PLACEHOLDER)
    .fade_duration(250.0)
    .size(px(100.0))
    .rounded(px(12.0))
image(cover).blur_radius(px(16.0)).absolute().inset_0()

#Animated images

Animated GIF, WebP and APNG files play automatically, as assets or from URLs. They take the same styles and resize modes; a tinted animated image shows its first frame on iOS.

#Load events

EventRustPayloadWhen
onLoadStart.on_load_start(..)Loading starts
onProgress.on_progress(..){ loaded, total } in bytes (total 0 when unknown)During a download, at most every 100 ms
onLoad.on_load(..){ width, height }The image is ready; the source’s size in pixels (an SVG’s in points, on the web as the browser measures it)
onError.on_error(..){ message } (a &str in Rust)Loading or decoding failed
onLoadEnd.on_load_end(..)After onLoad or onError
let status = signal(SharedString::from("loading…"));

image(url)
    .on_load(move |size: &Size<Points>| status.set(format!("{}×{}", size.width.0, size.height.0).into()))
    .on_error(move |message: &str| status.set(message.to_owned().into()))
    .w(px(90.0))
    .h(px(60.0))

The load event reports the source image’s size, not the size it was decoded at.

An image with pixel-density variants loads one of its files, and the load event reports that file’s pixels: 192×192 for logo@3x.png on a 3× screen, where Image.getSize(logo) resolves with the imported file’s 64×64. When the pixel ratio changes and the image loads another variant, that load reports its events too: onLoadStart, then onLoad with the new file’s size (or onError), then onLoadEnd.

#An image’s size and prefetching

Images that are not on screen can be measured and loaded ahead: image_size(..) and prefetch_image(..) in Rust, Image.getSize and Image.prefetch (on the same Image that renders the element) in TSX:

spawn(async move {
    // Ok(ImageSize { width, height }), in pixels.
    let size = image_size("https://example.com/photo.jpg").await;
    // true once the bytes are in memory.
    let ok = prefetch_image("https://example.com/next.jpg").await;
});
RustTSXReturns
image_size(src)Image.getSize(src)The image’s size in pixels (an SVG’s in points, on the web as the browser measures it); an error when it cannot be loaded
prefetch_image(src)Image.prefetch(src)true once the image’s bytes are loaded

In TSX, src is a URL string or an imported image. For an imported image with variants, both load the variant the views load on this screen, and getSize gives the imported file’s size. Both keep the downloaded bytes for the views that show the image next, so an image prefetched before it is shown appears without a download.

A common use is sizing a remote image by its aspect ratio:

fn remote_image(url: &'static str) -> impl IntoElement {
    let ratio = signal(1.0_f32);
    spawn(async move {
        if let Ok(size) = image_size(url).await {
            ratio.set(size.width.0 as f32 / size.height.0 as f32);
        }
    });
    image(url).w_full().aspect_ratio(ratio)
}

#Other assets

FileRust (asset!)TSX importPage
.ttf, .otfA FontAsset with its family nameThe font’s family nameFonts
.json and other filesA FileAsset with its URI.json: the parsed value, bundled into the scriptProject structure

Fonts are registered before the first frame in both.

#A module’s own URL

In TSX, import.meta.url is the URL of the module it is written in, so new URL("./data.json", import.meta.url) names a file beside it. A local or packaged desktop app’s modules have file: URLs in the app’s directory, which fs/promises reads under a ${pluginDir} grant; a web app’s are the page’s; a packaged mobile app’s are asset:/ URLs like those of imported images. Import an image to ship it: a URL made this way names the file, it does not package it.

Bitmap images are also drawn on a canvas with draw_image / drawImage (Canvas), and gradients replace many decorative images (Styling).

  • image element: every prop, with getSize and prefetch.
  • Lists: long lists of images recycle their views.