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
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 immediatelyShow 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.
UIText(text: string | (() => string)): UIText // a function = reactive text (signals)
label.text = "Updated" // get / set the displayed stringA counter whose label follows the value:
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()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.
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:
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 wrapUIScreen(
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()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.
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):
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 CSSwhite-space: nowrapdoes; 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.
UIImage(src: ImageSource): UIImage
img.src // get the (resolved) source / set a new one — the picture changes in placeA photo from a URL with rounded corners, and a button that shows the next one:
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
// 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 heightA 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.
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.
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:
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)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.
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
img.setSourceRect(x: number, y: number, w: number, h: number): this // texture pixelsTo 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:
// 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.
UIVideo(player: VideoPlayer): UIVideo
video.player // read-only — the VideoPlayer passed at constructionconst 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.
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:
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()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 shownThe 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.
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
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,
emresolution,bgImagebehind children - Theme — the default text color and font of the whole app
- Fonts — the
font()macro andregisterFontfor customfontFamily - Media players —
VideoPlayercontrol surface - Interactive elements —
UIInput/UITextAreafor editable text