UI content elements — text, images, icons and video

The leaf elements that show something: UIText for text, UIImage for a photo, a picture or an icon, UIVideo for a video; what they show lives on the element (.text, .src, .player), never in a style. Typed text is UIInput (interactive.md); play and pause are the VideoPlayer's (media.md).

  • show a label or a paragraph and change it when a value changes
  • make text bigger, bold, colored or centered
  • cut a long description after a few lines with "…"
  • keep a label on one line, or balance a two-line headline
  • show a photo from a URL or from the project, with rounded corners
  • show an icon in a button and color it (assetIcon)
  • play the frames of a sprite sheet inside the UI
  • play a video inside a screen
  • show a video full screen

Platforms: iOS, Android, desktop, the web and the web player, with no guard to check; a video shows on desktop only on Windows and macOS, a full-screen video is not on desktop or in the web player, a Scene2D image source is not on the web or in the web player, and headless tests (lecodes render / lecodes test) draw neither (video, image sources).

At a glance

TypeScript
const photo = asset('./photo.jpg')    // a project file; a "https://…" URL string works the same way

const caption = UIText("Loading…").style({ color: "white", fontSize: 16 })

const screen = UIScreen(
  UIImage(photo).style({ width: "100%", height: 220, borderRadius: 16, objectFit: "cover" }),
  caption,
).style({ bgColor: "black", p: 16, pt: "safe-top", gap: 12 })

screen.open()
caption.text = "Sunset over the harbor"     // re-renders immediately

Show text: UIText

A label, a title or a paragraph is a UIText: pass the string, or a function that reads signals so the text follows a value.

TypeScript
UIText(text: string | (() => string)): UIText   // a function = reactive text (signals)

label.text = "Updated"      // get / set the displayed string

A counter whose label follows the value:

TypeScript
const count = signal(0)

UIScreen(
  UIText(() => `Taps: ${count.value}`).style({ fontSize: 24, color: "white" }),   // re-renders on every change
  UIButton(UIText("Add one").style({ color: "black" }))
    .style({ bgColor: "white", px: 16, py: 8, borderRadius: 8 })
    .onClick(() => { count.value++ }),
).style({ bgColor: "black", p: 16, pt: "safe-top", gap: 12, alignItems: "flex-start" }).open()
Note

text with no color of its own takes the theme's text color, and is white when the app sets no theme (theme.md). On a light background, set theme({ text: "#131A17" }) once, or a color on the text.

Note

layouts are always left to right. Arabic and Hebrew text is shaped and ordered within a line on iOS, Android and the web; desktop draws it unshaped, left to right (Not available).

Font, size, weight and color: text styles

The look of a text — size, weight, color, alignment, font — is set with .style() on the UIText, on top of the common element styles:

TypeScript
textAlign:      "start" | "center" | "end" | "left" | "right"   // default "start"
fontFamily:     string            // system: "serif", "sans-serif", "monospaced"; custom via font() (see fonts.md)
fontSize:       UIValue           // logical px, default 14
lineHeight:     UIValue | "normal"             // default "normal" = 1.2 × fontSize
fontWeight:     number | "normal" | "bold"     // 400, 700, …
fontStyle:      "normal" | "italic"
color:          Color             // unset = the theme's `text` (white without a theme)
textDecoration: "underline" | "line-through" | "none"
letterSpacing:  UIValue
lineClamp:      number            // cap at N lines; 0 / omitted = unbounded
textOverflow:   "ellipsis" | "clip"            // default ellipsis
textWrap:       "wrap" | "nowrap" | "balance" | "pretty"   // default wrap
TypeScript
UIScreen(
  UIText("Weekly report").style({ fontSize: 28, fontWeight: "bold", color: "white" }),
  UIText("Updated 5 minutes ago").style({ fontSize: 13, fontStyle: "italic", color: "#9AA0A6" }),
  UIText("Sales grew in every region: the north led with 18%, the south followed with 11%.")
    .style({ fontSize: 16, lineHeight: "1.5em", textAlign: "center", color: "white" }),   // 24 px lines
  UIText("SKU-10442").style({ fontFamily: "monospaced", letterSpacing: 1, color: "#15A34A" }),
  UIText("Read more").style({ textDecoration: "underline", color: "#5B8CFF" }),
).style({ bgColor: "black", p: 16, pt: "safe-top", gap: 8 }).open()
Note

lineHeight: 1.5 means 1.5 px — a bare number is px everywhere. A multiplier is "1.5em" or "150%" (resolved against this element's own fontSize).

UIText also takes the drawable styles — background, border, radius — so a text chip is one element: UIText("New").style({ px: 8, py: 2, bgColor: "#15A34A", borderRadius: 8, color: "white" }). Custom fonts come from the font() macro — see fonts.md.

Cut long text after N lines: lineClamp

To show a long description in a few lines ending with "…", set lineClamp: the text stops at N lines, and the element is measured to that height — the layout around it reflows around the clamped box, not around the full string.

TypeScript
const summary = "A walk through the narrow streets before the shops open: the bakeries are already busy, " +
  "the trams are nearly empty and the light on the river changes every few minutes. Bring a jacket."

UIScreen(
  UIColumn(
    UIText("Morning in the old town").style({ fontSize: 18, fontWeight: 700, color: "white" }),
    UIText(summary).style({ fontSize: 14, color: "#C8CCD0", lineClamp: 3 }),   // 3 lines, then "…"
    UIText("Read more").style({ fontSize: 14, color: "#5B8CFF" }),
  ).style({ bgColor: "#1E2126", borderRadius: 12, p: 12, gap: 6 }),
).style({ bgColor: "black", p: 16, pt: "safe-top" }).open()

textOverflow: "clip" cuts the last line instead of ending it with "…". textOverflow only takes effect together with lineClamp (or textWrap: "nowrap") — with no clamp the box is sized to the whole string, so nothing overflows. "clip" is approximate on the web: it cuts on the line-height box rather than on the glyph, so a descender on the last line may be shaved differently than on iOS. "ellipsis" is exact on both.

Keep text on one line or balance a headline: textWrap

textWrap says how a paragraph breaks into lines — one line with "…" for a label in a narrow cell, even lines for a headline. A \n in the text is a hard break in every mode; the modes decide the soft breaks (at spaces):

TypeScript
UIScreen(
  UIText("Fresh bread every morning, delivered before eight")
    .style({ fontSize: 26, fontWeight: 700, color: "white", textWrap: "balance" }),   // lines of even length
  UIRow(
    UIText("Free delivery on every order this week, no minimum")
      .style({ color: "white", textWrap: "nowrap", flexShrink: 1 }),               // one line, ends with "…"
    UIText("New").style({ px: 8, py: 2, bgColor: "#15A34A", borderRadius: 8, color: "white" }),
  ).style({ gap: 8, alignItems: "center" }),
).style({ bgColor: "black", p: 16, pt: "safe-top", gap: 12 }).open()
  • "wrap" (the default) is the greedy wrap: each line takes as many words as fit.
  • "nowrap" never breaks at a space. The element is measured to the whole line, so in a row it pushes its siblings or overflows its parent, as CSS white-space: nowrap does; a line wider than the box the element finally gets is ended with "…" (textOverflow: "clip" lets it run past the box instead). The usual shapes: a label in a fixed-width cell (an ellipsis instead of a second line), a chip whose width is its text, a ticker inside a horizontal scroll.
  • "balance" wraps at the narrowest width that keeps the greedy line count, so a two-line headline gets two half-width lines instead of a full line and a word. It applies to paragraphs of 2 to 10 lines; longer ones wrap greedily.
  • "pretty" moves the last word of the line before down when the last line would be a single word and the pair fits the width.

balance and pretty wrap greedily on a paragraph a lineClamp cuts, so the ellipsis lands on a full line.

Show an image: UIImage

A photo, a picture or an icon is a UIImage: give it a URL or a project file (or a downloaded or picked file, an SVG, a canvas, a 2D scene), a size, and swap the picture later through .src.

TypeScript
UIImage(src: ImageSource): UIImage

img.src            // get the (resolved) source / set a new one — the picture changes in place

A photo from a URL with rounded corners, and a button that shows the next one:

TypeScript
const photos = ["https://example.com/photos/harbor.jpg", "https://example.com/photos/market.jpg"]
let shown = 0

const photo = UIImage(photos[0]).style({ width: "100%", height: 240, borderRadius: 16, objectFit: "cover" })

UIScreen(
  photo,
  UIButton(UIText("Next photo").style({ color: "black" }))
    .style({ bgColor: "white", px: 16, py: 8, borderRadius: 8 })
    .onClick(() => { shown = (shown + 1) % photos.length; photo.src = photos[shown] }),   // swaps in place
).style({ bgColor: "black", p: 16, pt: "safe-top", gap: 12, alignItems: "flex-start" }).open()

Where the picture comes from: image sources

TypeScript
// ImageSource:
//   string          — a remote URL, or a project file: asset('./photo.jpg') or the path itself
//   FetchResponse   — a downloaded body used directly as pixels
//   File            — from openFilePicker() (files-share.md) or a CameraView photo
//   SvgSource       — wrapped raw SVG XML; assetIcon() and asset('./icon.svg') return one
//   Canvas          — a 2D Canvas, baked to a texture on assign; canvas.update() refreshes the image
//   Scene2D         — a live 2D scene, drawn into the box every frame (a minimap); no intrinsic
//                     size — give the element a width and height

A photo the user picks from the library is a File: UIImage(file) — the complete form is in Files & share. A Scene2D source needs no open() — see Scene2D — Inside UI.

Note

a project file by its path as a string — UIImage("images/photo.png"), "./photo.png" from the file — is the bundled asset, the same as asset('./images/photo.png'); such a path that names no file fails the compile (file not found). Plain https://… strings are remote URLs.

Note

a Scene2D source draws on iOS, Android and desktop; not on the web or in the web player, and headless tests leave its box empty.

Fill the box, round the corners, tint: image styles

How the picture fills its box, its corners and an icon's color are styles of the UIImage:

TypeScript
objectFit:    "cover" | "contain" | "fill"   // how the source maps into the box; default "cover"
tintColor:    Color                          // SVG sources only (any color or var())
borderRadius: number                         // px (a number only on UIImage)
Note

tintColor applies only to SVG sources, and it replaces all fill and stroke colors in the SVG — it's for monochrome icons, not multicolored artwork. (The desktop host tints a raster image too, into a flat silhouette; the phones and headless renders don't — don't rely on it.)

Add an icon to a button or a list row: assetIcon("pack:name")

To add an icon to a button, a tab or a list row, use the assetIcon() compile macro rather than hand-written SVG: it resolves one icon from the icon registry at build time and inlines it as an image source — an SvgSource, the one type asset('./x.svg') and SvgSource(xml) give too, so a component that takes an icon is typed (icon: SvgSource) — and it renders offline and identically on every host, with no network at render time.

TypeScript
UIScreen(
  UIButton(
    UIImage(assetIcon("lucide:bell")).style({ width: 20, height: 20, tintColor: "white" }),
    UIText("Notifications").style({ color: "white" }),
  ).style({ bgColor: "#15A34A", px: 16, py: 10, gap: 8, borderRadius: 10 }),
  UIImage(assetIcon("lucide:check", { color: "#15A34A" })).style({ width: 24, height: 24 }),   // baked green
).style({ bgColor: "black", p: 16, pt: "safe-top", gap: 12, alignItems: "flex-start" }).open()

The "pack:name" id must be a string literal (an unknown id is a compile error; browse packs at https://icon-registry.jt3.ru). Recolor with the { color } option — a color literal is baked into the SVG at compile time, a token/expression (a theme var() too — it follows the live theme) is applied as the source's own tint — or with the tintColor style prop, which wins over the source's tint.

Show one frame of a sprite sheet: setSourceRect

TypeScript
img.setSourceRect(x: number, y: number, w: number, h: number): this   // texture pixels

To show one frame of an atlas or a sprite sheet, crop the source to a sub-rectangle. The rect's w/h become the element's intrinsic size (one frame, not the whole atlas), and the cropped frame always fills the box, overriding objectFit. Chainable and safe to call before the element is on screen. A sprite animation swaps the rect per frame:

TypeScript
// coins.png: 8 frames of 128×128 side by side (1024×128)
const coin = UIImage(asset('./coins.png')).style({ width: 64, height: 64 }).setSourceRect(0, 0, 128, 128)

let frame = 0
const timer = setInterval(() => { frame = (frame + 1) % 8; coin.setSourceRect(frame * 128, 0, 128, 128) }, 100)

UIScreen(coin)
  .style({ bgColor: "black", p: 16, pt: "safe-top" })
  .onClose(() => clearInterval(timer))
  .open()

Play a video in a screen: UIVideo

A video played inside a screen is a UIVideo showing a VideoPlayer: the element is only the picture, playback lives on the player — create the player first and control it directly.

TypeScript
UIVideo(player: VideoPlayer): UIVideo

video.player       // read-only — the VideoPlayer passed at construction
TypeScript
const movie = new VideoPlayer(asset('./intro.mp4'))   // or a "https://…" URL
movie.loop = true

const video = UIVideo(movie).style({ width: "100%", height: 220, borderRadius: 12, objectFit: "cover" })

UIScreen(
  video,
  UIButton(UIText("Play / pause").style({ color: "black" }))
    .style({ bgColor: "white", px: 16, py: 8, borderRadius: 8 })
    .onClick(() => { movie.playing = !movie.playing }),
).style({ bgColor: "black", p: 16, pt: "safe-top", gap: 12, alignItems: "flex-start" })
  .onOpen(() => movie.play())
  .onClose(() => movie.stop())     // stop it when the screen closes
  .open()

objectFit: "cover" | "contain" | "fill" (default "cover") maps the video into the box, and UIVideo takes the drawable styles (border*, bgColor, radius) for framing. The player is released by itself once nothing references it and it is not playing.

Note

the video shows on iOS, Android, the web, the web player and desktop on Windows and macOS. Linux desktop keeps a placeholder box, and headless tests lay out an empty box — nothing plays there.

Play a video full screen: Router.push(video) or open()

To play a video full screen, push its UIVideo: it is a destination, like a screen. Router.push(video) shows it over everything, and back — Android's back button, the iOS swipe, Router.pop() — returns it to its place in the screen, with the same player. Its widgets (video.widgets(...)) float over it while it is full screen — a close button, a HUD:

TypeScript
const movie = new VideoPlayer("https://example.com/videos/trailer.mp4")
const video = UIVideo(movie).style({ width: "100%", height: 200, objectFit: "contain" })

// what floats over the video while it is full screen
video.widgets(
  UIWidget(UIButton(UIText("Close").style({ color: "white" })).onClick(() => Router.pop()))
    .style({ top: "safe-top", right: 16, bgColor: "#00000080", px: 12, py: 6, borderRadius: 8 }),
)

const details = UIScreen(
  video,
  UIButton(UIText("Full screen").style({ color: "black" }))
    .style({ bgColor: "white", px: 16, py: 8, borderRadius: 8 })
    .onClick(() => Router.push(video)),   // the same player moves full screen; back brings it back here
).style({ bgColor: "black", p: 16, pt: "safe-top", gap: 12, alignItems: "flex-start" })

Router.init(details)
movie.play()
TypeScript
video.open(options?: PresentOptions): void      // show it without the Router (suspends it until Router.restore())
video.close(options?: DismissOptions): void     // dismiss it if it is the one shown
video.onOpen(cb: () => void): this              // became the visible destination
video.onClose(cb: () => void): this             // stopped being visible
video.onBack(cb: () => void): this              // hardware / system back while it is shown

The screen it covers gets its onClose — a screen that stops its player in onClose (the example above) stops the full-screen video too; stop it where the user leaves instead. A transition ({ transition }) defaults to "none" for open(); see Screens & Router for transitions, Router.restore() and Presentable.current.

Note

full screen works on iOS, Android and the web. Desktop drops the video destination (with a log line) and the web player draws nothing for it. Push a UIVideo that is in a screen: Android shows nothing for one that was never on screen.

Pitfalls

TypeScript
const img = UIImage("")                  // ✗ an empty source as a placeholder, filled in later
const img = UIImage(firstUrl)            // ✓ the source at construction… img.src = nextUrl swaps it

UIBox().style({ width: 64, height: 64, bgImage: avatarUrl })   // ✗ a picture as an empty box's background
UIImage(avatarUrl).style({ width: 64, height: 64 })            // ✓ content is a UIImage (fit, tint, crop)

import photo from './photo.jpg'          // ✗ bundles, but the type check fails
const photo = asset('./photo.jpg')       // ✓ or the path as a string: UIImage("./photo.jpg")

See also

  • UI element model — content props vs styles, capturing refs
  • Styling — common styles, units, em resolution, bgImage behind children
  • Theme — the default text color and font of the whole app
  • Fonts — the font() macro and registerFont for custom fontFamily
  • Media players — VideoPlayer control surface
  • Interactive elements — UIInput / UITextArea for editable text