The ~MOB sigil (imported automatically by use Mob.Screen) is the primary way to write Mob UI. It compiles to plain Elixir maps at compile time — there is no runtime overhead.

Sigil syntax

~MOB"""
<Column padding={16}>
  <Text text="Hello" text_size={:xl} />
  <Button text="Save" on_tap={tap} />
</Column>
"""

Expression attributes use {...} and support any Elixir expression. For on_tap and similar handler props, pre-compute the {pid, tag} tuple before the sigil to avoid nested parentheses:

def render(assigns) do
  save_tap = {self(), :save}
  ~MOB"""
  <Column padding={16}>
    <Text text={"Count: #{assigns.count}"} text_size={:xl} />
    <Button text="Save" on_tap={save_tap} />
  </Column>
  """
end

Expression child slots use {...} and accept a single node map or a list:

~MOB"""
<Column>
  {Enum.map(assigns.items, fn item ->
    ~MOB(<Text text={item} />)
  end)}
</Column>
"""

Control flow

The sigil borrows three authoring idioms from Phoenix HEEx, so screens read the way LiveView developers expect.

@assigns shorthand

Inside a {...} expression, @foo rewrites to assigns.foo at compile time. It works in attribute values, {expr} children, and the :if/:for directives below. Nested access like @user.name works too.

def render(assigns) do
  ~MOB"""
  <Column padding={16}>
    <Text text={@title} text_size={:xl} />
    <Text text={"by #{@author.name}"} />
  </Column>
  """
end

@title is exactly assigns.title — the two forms are interchangeable, so reach for whichever reads better.

@foo only works where assigns is in scope — that is, a screen's or component's render(assigns). Reusable helper functions (the function composites below) take positional arguments, and there is no assigns inside them, so interpolate the argument directly:

# Screen render — assigns is in scope:
def render(assigns), do: ~MOB(<Text text={@title} />)

# Helper — NO @; use the argument:
def label(title), do: ~MOB(<Text text={title} />)   # not @title

Reaching for @foo inside a helper is the most common mistake here. It raises a CompileError naming the fix ({title} instead of @title) rather than a cryptic "undefined variable assigns". If your render parameter is named something other than assigns (e.g. socket), @foo won't find it either — name it assigns.

:if — conditional rendering

:if={expr} renders the element only when the expression is truthy. A falsy :if drops the element entirely (it does not render an empty placeholder):

~MOB"""
<Column>
  <Badge text="New" :if={@unread > 0} />
  <Text text="All caught up" :if={@unread == 0} />
</Column>
"""

:for — comprehension

:for={x <- list} repeats the element once per item and splices the results into the parent's children:

~MOB"""
<Column>
  <Row :for={user <- @users}>
    <Text text={user.name} />
  </Row>
</Column>
"""

This is the declarative equivalent of the {Enum.map(...)} child slot shown above — use whichever is clearer for the case at hand.

Combining :for and :if

When both are present on the same element, :if acts as a comprehension filter (matching LiveView): an element is produced only for items where the condition holds.

# Renders a Text for 2 and 4 only
<Text text={to_string(n)} :for={n <- 1..4} :if={rem(n, 2) == 0} />

:if and :for each require a {expr} value — :if="true" (a string) raises a CompileError. Only :if and :for are recognised; any other :-prefixed attribute is a compile-time error.

Map syntax

The sigil compiles to plain maps. You can also write them directly — useful when building components programmatically:

%{
  type:     :column,
  props:    %{padding: 16},
  children: [
    %{type: :text,   props: %{text: "Hello", text_size: :xl}, children: []},
    %{type: :button, props: %{text: "Save",  on_tap: {self(), :save}}, children: []}
  ]
}

The two styles are fully interchangeable — you can mix them freely in the same render/1 function.


Mob.Renderer serialises the component tree to JSON and passes it to the native side in a single NIF call. Compose (Android) and SwiftUI (iOS) handle diffing and rendering.

Prop values

Props accept:

  • Integers and floats — used as-is (dp on Android, pt on iOS)
  • Strings — used as-is
  • Booleans — used as-is
  • Color atoms (:primary, :blue_500, etc.) — resolved via the active theme and the base palette to ARGB integers. See Theming.
  • Spacing tokens (:space_xs, :space_sm, :space_md, :space_lg, :space_xl) — scaled by theme.space_scale and resolved to integers.
  • Radius tokens (:radius_sm, :radius_md, :radius_lg, :radius_pill) — resolved to integers from the active theme.
  • Text size tokens (:xs, :sm, :base, :lg, :xl, :2xl, :3xl, :4xl, :5xl, :6xl) — scaled by theme.type_scale and resolved to floats.

Platform-specific props

Wrap props in :ios or :android to apply them only on that platform:

props: %{
  padding: 12,
  ios: %{padding: 20}   # iOS sees 20; Android sees 12
}

Layout components

:column

Stacks children vertically.

PropTypeDescription
paddingnumber / tokenUniform padding
padding_top, padding_bottom, padding_left, padding_rightnumber / tokenPer-side padding
gapnumber / tokenSpace between children
backgroundcolorBackground color
fill_widthbooleanStretch to fill available width (default true)
fill_heightbooleanStretch to fill available height
align:start / :center / :endCross-axis alignment of children

:row

Lays out children horizontally.

PropTypeDescription
paddingnumber / tokenUniform padding
gapnumber / tokenSpace between children
backgroundcolorBackground color
fill_widthbooleanStretch to fill available width
align:start / :center / :endCross-axis alignment of children

To distribute children evenly across a row, give each child a weight prop (analogous to flex: 1 in CSS):

save_tap   = {self(), :save}
cancel_tap = {self(), :cancel}
~MOB"""
<Row fill_width={true}>
  <Button text="Cancel" on_tap={cancel_tap} weight={1} background={:surface} text_color={:on_surface} />
  <Spacer size={8} />
  <Button text="Save" on_tap={save_tap} weight={1} />
</Row>
"""

:box

A single-child container. Use it to add background, padding, or corner radius to a child:

box_style = {self(), :box}
~MOB"""
<Box background={:surface} padding={:space_md} corner_radius={:radius_md}>
  <Text text="Card content" />
</Box>
"""
PropTypeDescription
paddingnumber / tokenUniform padding
backgroundcolorBackground color
corner_radiusnumber / tokenCorner radius
fill_widthbooleanStretch to fill available width

:scroll

A vertically scrolling container.

PropTypeDescription
paddingnumber / tokenPadding inside the scroll area
backgroundcolorBackground color

:spacer

Inserts fixed space in a row or column, or fills available space when no size is given.

PropTypeDescription
sizenumberFixed size in dp/pt. Omit to fill remaining space.
# Fixed gap:
~MOB(<Spacer size={16} />)

# Push children to opposite ends of a row:
~MOB"""
<Row>
  <Text text="Left" />
  <Spacer />
  <Text text="Right" />
</Row>
"""

List components

:list

A platform-native scrolling list optimised for rendering many rows efficiently. Prefer this over :scroll + :column for any list of more than ~20 items.

PropTypeDescription
itemslistData items. Each renders as a child.
on_select{pid, tag}Called when a row is tapped: {:select, tag, index}
select = {self(), :item_tapped}
~MOB"""
<List items={assigns.names} on_select={select}>
  {Enum.map(assigns.names, fn name ->
    ~MOB(<Text text={name} padding={:space_md} />)
  end)}
</List>
"""

:lazy_list

A virtualized list that renders rows on demand. Supports on_end_reached for pagination.

PropTypeDescription
on_end_reached{pid, tag}Fired when the user scrolls near the end: {:tap, tag}

Content components

:text

Displays a string.

PropTypeDescription
textstringThe text to display (required)
text_sizenumber / tokenFont size
text_colorcolorText color
font_weight"regular" / "medium" / "bold"Font weight
text_align"left" / "center" / "right"Horizontal alignment

:button

A tappable button. Has sensible defaults injected by the renderer (primary background, on_primary text, medium radius, fill width).

PropTypeDescription
textstringButton label
on_tap{pid, tag}Tap handler. Delivers {:tap, tag} to handle_info/2.
backgroundcolorBackground color (default :primary)
text_colorcolorLabel color (default :on_primary)
text_sizenumber / tokenFont size (default :base)
font_weightstringFont weight (default "medium")
paddingnumber / tokenPadding (default :space_md)
corner_radiusnumber / tokenCorner radius (default :radius_md)
fill_widthbooleanFill available width (default true)
weightfloatFlex weight inside a :row or :column
disabledbooleanDisable tap interaction
save_tap   = {self(), :save}
cancel_tap = {self(), :cancel}
~MOB(<Button text="Save" on_tap={save_tap} />)
~MOB(<Button text="Cancel" on_tap={cancel_tap} background={:surface} text_color={:on_surface} />)

:text_field

An editable text input. Has defaults injected by the renderer (surface_raised background, border, small radius).

PropTypeDescription
valuestringCurrent text (controlled)
placeholderstringHint text when empty
on_change{pid, tag}Fires as the user types. Delivers {:change, tag, value} to handle_info/2.
on_submit{pid, tag}Fires on keyboard return. Delivers {:tap, tag}.
on_focus{pid, tag}Fires when the field gains focus. Delivers {:tap, tag}.
on_blur{pid, tag}Fires when the field loses focus. Delivers {:tap, tag}.
securebooleanPassword masking
keyboard_type:default / :email / :number / :phoneKeyboard variant
backgroundcolorBackground (default :surface_raised)
text_colorcolorInput text color (default :on_surface)
placeholder_colorcolorPlaceholder color (default :muted)
border_colorcolorBorder color (default :border)
paddingnumber / tokenPadding (default :space_sm)
corner_radiusnumber / tokenCorner radius (default :radius_sm)

:divider

A horizontal rule. Default color is :border.

PropTypeDescription
colorcolorLine color (default :border)

:progress

An indeterminate activity indicator (spinner).

PropTypeDescription
colorcolorIndicator color (default :primary)

:toggle

A boolean switch. Delivers {:change, tag, value} to handle_info/2 where value is true or false.

PropTypeDescription
valuebooleanCurrent checked state
labelstringLabel text displayed beside the toggle
on_change{pid, tag}Fires when toggled. Delivers {:change, tag, bool}.
colorcolorThumb/track tint color
toggle_change = {self(), :notifications_toggled}
~MOB(<Toggle value={assigns.notifications_on} label="Enable notifications" on_change={toggle_change} />)

def handle_info({:change, :notifications_toggled, enabled}, socket) do
  {:noreply, Mob.Socket.assign(socket, :notifications_on, enabled)}
end

:slider

A continuous value input. Delivers {:change, tag, value} to handle_info/2 where value is a float.

PropTypeDescription
valuefloatCurrent value
minfloatMinimum value (default 0.0)
maxfloatMaximum value (default 1.0)
on_change{pid, tag}Fires as the user drags. Delivers {:change, tag, float}.
colorcolorTrack and thumb color
volume_change = {self(), :volume_changed}
~MOB(<Slider value={assigns.volume} min={0.0} max={1.0} on_change={volume_change} />)

def handle_info({:change, :volume_changed, value}, socket) do
  {:noreply, Mob.Socket.assign(socket, :volume, value)}
end

Native view components

:webview

Embeds a native web view. Communicates bidirectionally with JS via the window.mob bridge. See WebView for the full message-passing API.

PropTypeDescription
urlstringInitial URL to load (required)
allowlist of stringsURL prefixes that are allowed to navigate; others are blocked and delivered as {:webview, :blocked, url}
show_urlbooleanShow the native URL bar
titlestringStatic title label, overrides show_url
widthnumberFixed width in dp/pt
heightnumberFixed height in dp/pt
weightfloatFlex weight inside a :row or :column
~MOB"""
<WebView url="https://example.com"
         allow={["https://example.com"]}
         show_url={true}
         weight={1} />
"""

:camera_preview

Displays a live camera feed inline. The <CameraPreview> node itself ships in core, but the preview session is driven by MobCamera (the mob_camera plugin — add the dep + activate in mob.exs; see the Plugins guide). Call MobCamera.start_preview/2 before rendering and MobCamera.stop_preview/1 in terminate/2. No OS permission dialog is shown for preview alone.

PropTypeDescription
facing:back / :frontCamera to use
weightfloatFlex weight inside a :row or :column
widthnumberFixed width in dp/pt
heightnumberFixed height in dp/pt
def mount(_params, _session, socket) do
  socket = MobCamera.start_preview(socket, facing: :back)
  {:ok, socket}
end

def render(assigns) do
  flip_tap = {self(), :flip}
  ~MOB"""
  <Column>
    <CameraPreview facing={:back} weight={1} />
    <Button text="Flip" on_tap={flip_tap} />
  </Column>
  """
end

def terminate(_reason, socket) do
  MobCamera.stop_preview(socket)
  :ok
end

Defining your own components

You can build reusable components out of the built-in widgets with no native code, in two forms: function composites (a plain function you call) and tag composites (a custom <Tag> you register). Both are stateless, pure Elixir, and hot-pushable. Events raised from inside either kind route to the screen's handle_info/2, exactly like a built-in widget does.

Function composites

A function composite is a function that returns a render tree. You call it through {...} interpolation inside the sigil. This is the lightest way to factor out a chunk of UI you repeat.

Here is a complete screen that defines a stat_card/3 composite and uses it. The tap target is built in render/1 and passed in as an argument, so the button inside the composite delivers to this screen's handle_info/2:

defmodule MyApp.DashboardScreen do
  use Mob.Screen

  @impl true
  def mount(_params, _session, socket) do
    {:ok, Mob.Socket.assign(socket, :taps, 0)}
  end

  # A function composite: returns a render tree, so it drops into the screen
  # via {...}. `on_tap` is a pre-built {pid, tag} tuple passed in by the caller.
  defp stat_card(label, value, on_tap) do
    ~MOB"""
    <Box background={:surface_raised} corner_radius={:radius_md} padding={:space_md}>
      <Column gap={4}>
        <Text text={label} text_size={:sm} text_color={:muted} />
        <Text text={to_string(value)} text_size={:2xl} text_color={:on_surface} />
        <Button text="Tap me" on_tap={on_tap} />
      </Column>
    </Box>
    """
  end

  @impl true
  def render(assigns) do
    bump = {self(), :bump}

    ~MOB"""
    <Column padding={:space_lg} gap={12}>
      <Text text="Dashboard" text_size={:xl} text_color={:on_surface} />
      {stat_card("Taps", @taps, bump)}
    </Column>
    """
  end

  @impl true
  def handle_info({:tap, :bump}, socket) do
    {:noreply, Mob.Socket.update(socket, :taps, &(&1 + 1))}
  end
end

Two things to notice:

  • @taps inside {stat_card(...)} is assigns.taps (the @ shorthand works in any {...} expression, including a composite call).
  • The composite is a plain function call in render/1, which runs in the screen process, so events from the <Button> inside it reach this screen. Building the {self(), :bump} tuple in render/1 and passing it in keeps the composite reusable and follows the pre-compute-the-tuple convention.

Tag composites

A tag composite gives you custom tag syntax, like <Card title="...">. You register an expander for the tag, then write the tag in any screen.

The sigil turns a PascalCase tag into a snake_case atom (<Card> becomes :card, <LabeledButton> becomes :labeled_button), and the expander is looked up by that atom. An expander is a function expand(props, children, ctx) that returns a render tree (~MOB output).

Step 1 — write the expanders. Card wraps its children in a titled surface; LabeledButton raises a tap event:

defmodule MyApp.UI.Card do
  @moduledoc "`<Card title=\"...\">children</Card>` — a titled raised surface."
  import Mob.Sigil

  @spec expand(map(), [map()], map()) :: map()
  def expand(props, children, _ctx) do
    title = Map.get(props, :title, "")

    ~MOB"""
    <Column background={:surface_raised} corner_radius={:radius_md} padding={:space_md}>
      <Text text={title} text_size={:lg} text_color={:on_surface} />
      <Spacer size={8} />
      {children}
    </Column>
    """
  end
end

defmodule MyApp.UI.LabeledButton do
  @moduledoc ~S(`<LabeledButton label="..." on_press="save" />` — a button with an auto-injected tap target.)
  import Mob.Sigil

  @spec expand(map(), [map()], map()) :: map()
  def expand(props, _children, _ctx) do
    label = Map.get(props, :label, "")
    # `on_press` arrives already shaped as {screen_pid, :save} (see "Event
    # ergonomics" below), so we pass it straight to the button's on_tap.
    on_press = Map.fetch!(props, :on_press)

    ~MOB"""
    <Button text={label} on_tap={on_press} />
    """
  end
end

~MOB is auto-imported inside use Mob.Screen, but an expander is a plain module, so it needs import Mob.Sigil.

Step 2 — register the tags. Through a plugin manifest's ui_components:

ui_components: [
  %{tag: "Card",          atom: :card,           expand: {MyApp.UI.Card, :expand}},
  %{tag: "LabeledButton", atom: :labeled_button, expand: {MyApp.UI.LabeledButton, :expand}}
]

…or at runtime, for a plain Hex UI kit with no manifest (call from the host's on_start/0) via Mob.Composite.register/2:

Mob.Composite.register(:card, {MyApp.UI.Card, :expand})
Mob.Composite.register(:labeled_button, {MyApp.UI.LabeledButton, :expand})

Step 3 — use them in a screen. Note there is no self() anywhere in this markup:

defmodule MyApp.ProfileScreen do
  use Mob.Screen

  @impl true
  def mount(_params, _session, socket) do
    {:ok, Mob.Socket.assign(socket, :status, "not saved yet")}
  end

  @impl true
  def render(assigns) do
    ~MOB"""
    <Column padding={:space_lg} gap={12}>
      <Card title="Profile">
        <Text text="Tap save to record it." text_color={:muted} />
        <Spacer size={8} />
        <LabeledButton label="Save" on_press="save" />
      </Card>
      <Card title="Status">
        <Text text={@status} text_color={:primary} />
      </Card>
    </Column>
    """
  end

  @impl true
  def handle_info({:tap, :save}, socket) do
    {:noreply, Mob.Socket.assign(socket, :status, "saved")}
  end
end

The expander contract. expand(props, children, ctx) returns a node map or a list of nodes, which is re-expanded to a fixpoint so composites can build on other composites. ctx carries the screen process as ctx.screen.

Event ergonomics (auto-injected targets). Any on_* prop you write on a composite tag as a bare string or atom (on_press="save") arrives in the expander's props already shaped as {screen_pid, :save}. That is why ProfileScreen never writes self(), and why the screen receives {:tap, :save} in handle_info/2.

This auto-injection applies only to a composite tag's own props. A built-in widget you place directly (a <TextField> or <Button> in a screen's own markup, even one nested inside a composite's children) still needs an explicit {self(), tag} tuple, because its props are not run through an expander. That is why DashboardScreen above builds bump = {self(), :bump} for its plain <Button>, while ProfileScreen can write <LabeledButton on_press="save"> unadorned: LabeledButton is a composite tag, so its on_press is shaped for you.

For the full design see Mob.Composite and the "Pure-Elixir composite components" section of MOB_PLUGINS.md. The mob_demo_kit plugin in mob_plugin_demo (<DemoCard> / <DemoCombobox>) is a worked, device-verified example.

Using Mob.Style for reusable styles

Define shared styles as module attributes and attach them via the :style prop. Inline props override style values:

@card_style %Mob.Style{props: %{background: :surface, padding: :space_md, corner_radius: :radius_md}}
@title_style %Mob.Style{props: %{text_size: :xl, font_weight: "bold", text_color: :on_surface}}

def render(assigns) do
  %{type: :box, props: %{style: @card_style}, children: [
    %{type: :text, props: %{style: @title_style, text: assigns.title}, children: []},
    %{type: :text, props: %{text: assigns.body,  text_color: :muted,  text_size: :sm}, children: []}
  ]}
end

Tap handler conventions

Use tagged tuples for tap handlers so you can pattern-match on the tag in handle_info/2. Pre-compute the tuple before the sigil to avoid nesting parentheses inside {...}:

def render(assigns) do
  save_tap = {self(), :save}
  ~MOB"""
  <Button text="Save" on_tap={save_tap} />
  """
end

def handle_info({:tap, :save}, socket) do
  ...
end

Event routing

All events are delivered to the screen process via handle_info/2. self() inside render/1 is always the screen's GenServer pid. Every on_tap, on_change, on_select, and similar handler sends its message directly to the screen process — regardless of how deeply the component is nested in the tree.

Handler propMessage delivered to handle_info/2
on_tap: {pid, tag}{:tap, tag}
on_change: {pid, tag}{:change, tag, value}
on_select: {pid, tag} (list){:select, tag, index}
on_submit: {pid, tag}{:tap, tag}
on_focus: {pid, tag}{:tap, tag}
on_blur: {pid, tag}{:tap, tag}

Sub-component event isolation (planned, not yet implemented)

A future Mob.Component wrapper will allow a subtree of the render tree to have its own handle_info/2, routing events to that component process instead of the screen. Until then, use the tag field to distinguish events from different parts of the same screen:

top_save_tap    = {self(), :top_save}
bottom_save_tap = {self(), :bottom_save}
~MOB"""
<Button text="Top Save"    on_tap={top_save_tap} />
<Button text="Bottom Save" on_tap={bottom_save_tap} />
"""

Code formatting

mix format understands ~MOB sigils through Mob.Formatter, a first-class formatter plugin. Generated projects include a .formatter.exs that enables it automatically:

# .formatter.exs
[
  plugins: [Mob.Formatter],
  inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

Running mix format then normalises indentation, wraps long attribute lists, and aligns expression children — in a single pass alongside all other Elixir code.

See Tooling & Formatting for the full guide and Mob.Formatter for the API reference.