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.
  • Raw colors — a 32-bit 0xAARRGGBB integer (alpha first, e.g. 0xFF2196F3), not a CSS "#RRGGBB" string and not alpha-last. Include the 0xFF alpha byte or the color renders transparent. See Theming → Raw colors.
  • 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>
"""

A single weighted child expands into the parent's remaining main-axis space. Multiple weighted children divide that space evenly on iOS; Android additionally honors unequal numeric ratios. Weight values must be positive. Use equal weights for cross-platform layouts.

: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
lazybooleanBuild only the rows currently on screen. Opt-in; see below

lazy: true

By default a :scroll builds every child up front. With lazy: true, a scroll whose direct content is a single column builds only what is on screen. On a 500-row screen on a Moto G Power this takes main-thread frame cost from 498.9 ms to 115.8 ms (p50), and the worst frame from 1385.9 ms to 164.8 ms. Lazy cost is flat in list length where eager grows, so short lists gain nothing and may be marginally slower: this is a long-list optimisation.

It is opt-in rather than automatic because laziness has consequences beyond speed, and all of them are silent:

  • Rows below the fold are never built, so they never register a frame. Mob.Test.element_frames/1 and Mob.Test.tap_id/2 cannot address them.
  • scroll_to(:bottom) under-scrolls, because content size reflects only what has been built.
  • screenshot_tour truncates for the same reason.
  • Scroll position becomes index-based rather than pixel-based.

:lazy_list already makes that trade explicitly, which is why it is a separate component. Applying it silently to every :scroll would change harness behaviour under apps that never asked for it.

The narrowing is deliberate: only a vertical scroll whose sole child is a plain column qualifies. A row under a horizontal scroll would be lazy on the wrong axis, and a child carrying weight needs its siblings measured, so both stay eager. Anything deeper than the scroll's direct child stays eager too.

Available on both platforms from mob 0.7.39 / mob_new 0.4.31. An app that upgrades mob alone gets it on iOS only.

: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>
"""

Giving children a stable :id

Set :id on the children of any container and their view state follows the child rather than the slot it happens to occupy.

for user <- @users do
  %{type: :text_field, props: %{id: user.id, text: user.name}, children: []}
end

Without an :id, children are identified by position. Insert a row at the top and every row below it becomes a different view to the platform, so each one adopts the previous occupant's state: typed text, scroll offset, focus, and any in-flight animation all shift by one. With an :id, they move with the row.

This is the same idea as :key in LiveView's for comprehensions, and the same failure mode when it is missing.

What it affects

Anything the platform owns rather than your socket:

  • text a user has typed into a :text_field but not submitted
  • which field holds focus, and the keyboard's position in it
  • scroll offset inside a nested scroll
  • toggle and slider positions mid-drag
  • animations that are partway through

Values you render from assigns are unaffected either way, because those come from the tree on every frame.

Rules

  • An :id is opt-in. A list without one keeps positional identity, so nothing changes for code that never asked.
  • Ids only need to be unique among siblings, not app-wide.
  • A duplicate falls back to position for the second occurrence rather than merging two rows.
  • An authored id and a positional key cannot collide. They live in separate namespaces, so a child whose id is literally "3" is distinct from the child at position 3.
  • Numbers are coerced, so id: user.id works with integer ids exactly as id: "#{user.id}" would.

Limits worth knowing

The coercion is scoped to top-level props. An id nested inside a prop valuetabs: [%{id: 1}] — is not coerced and falls back to positional.

Coverage differs by platform for one component. Column, row, box, both scroll axes, the lazy list and the sheet body key children on both platforms from mob 0.7.39 / mob_new 0.4.31. The tab bar is iOS-only: Compose's NavigationBar still iterates tabs positionally, so reordering or inserting a tab moves per-tab state on Android and not on iOS.

An app that upgrades mob without regenerating from mob_new gets the iOS half only.

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 last row appears: {:tap, tag}

on_end_reached fires when the final row becomes visible, and is latched on the row count so that replacing the list's contents does not re-fire it. That matters because children key on :id (see below): replacing the contents gives every row a new identity, which without the latch reads as a fresh arrival at the end. A search screen re-queried on each keystroke would otherwise fire one pagination request per keystroke.

The latch releases when the count changes, which is what makes pagination work: reach the end, load a page, the list grows, the callback re-arms. Three cases it does not cover, so write the handler to be idempotent:

  • a re-query whose result count differs every time still fires once per distinct count;
  • a windowed list holding a rolling buffer at constant length fires once and then never again;
  • a page load that fails or returns nothing leaves the count unchanged, so scrolling away and back will not retry it.

Content components

:text

Displays a string.

PropTypeDescription
textstringThe text to display (required)
text_sizenumber / tokenFont size
text_colorcolorText color
fonttoken / stringA named font token from Mob.Theme's fonts: map (e.g. :heading), or a raw platform font name. See Styling → Custom fonts.
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

Overlay components

:sheet

A native modal bottom sheet (iOS .sheet, Android Material 3 ModalBottomSheet) that composes ordinary Mob nodes as its content. Build one with Mob.UI.sheet/2 or the <Sheet> tag.

There is no presented boolean: presence in the render tree is presentation. Rendering the sheet node presents it, a re-render that still includes it updates its content in place, and removing it from the tree dismisses it. So sheet visibility is an ordinary assign plus :if:

def render(assigns) do
  dismiss = {self(), :sheet_dismissed}
  ~MOB"""
  <Column padding={:space_md}>
    <Text text="Main content" />
    <Sheet detents={[:medium, :large]} on_dismiss={dismiss} :if={@show_sheet}>
      <Text text="Hello from the sheet" padding={:space_md} />
    </Sheet>
  </Column>
  """
end

def handle_info({:dismiss, :sheet_dismissed}, socket) do
  # The user swiped the sheet down — mirror that in your state, or the next
  # render will present it again.
  {:noreply, Mob.Socket.assign(socket, :show_sheet, false)}
end
PropTypeDescription
detentslistStops the sheet can rest at: a subset of [:medium, :large], or the exclusive content-height detent [:content] / [{:content, max_height: n}]. Default [:medium, :large]. Invalid detents raise, both in Mob.UI.sheet/2 and again at render time.
on_dismiss{pid, tag}Delivered as {:dismiss, tag} to handle_info/2, exactly once, when the user dismisses the sheet (swipe-down, back gesture, outside tap)
backgroundcolorSheet container color
scrimcolorDimming-layer color. Applied exactly on Android; iOS cannot set the system dimming opacity and stays system-black
corner_radiusnumber / tokenTop-corner radius
drag_indicator_color / _width / _height / _rail_heightcolor / numbersCustom drag-indicator capsule. All four together, or omit all four for the platform default
ios / androidmapPer-platform overrides of the style props above

A :content detent sizes the sheet from its content's intrinsic height — it hugs short content and caps at max_height (and at live screen geometry). Because a scrollable child (scroll, lazy_list) reports its full content height, it expands inside the sheet rather than scrolling independently; use :medium/:large when the sheet's body is itself scrollable. On iOS a content sheet presents at :medium for its first frame and resizes once the content has been measured.

See Mob.UI.sheet/2 for the full option reference and validation rules.

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.

Reached for use Mob.Component? Easy mix-up: it's a different feature. Mob.Component is the behaviour for native view components, a stateful BEAM process paired with a platform-native view (declared via Mob.UI.native_view/2), whose render/1 returns a props map for a native factory rather than a ~MOB tree. That's an advanced, native-code path (see the Plugins guide). If you just want a reusable widget or custom <Tag> built out of the built-in components, with no native code, that's the Mob.Composite path below. The names are close; for pure-Elixir tags the one you want is Composite, and its module returns a ~MOB tree from expand/3.

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})

Expect a compile-time warning on a registered tag; it's harmless. Registration happens at runtime (from on_start/0 or a plugin manifest), so the ~MOB macro can't see it while compiling a screen. Every custom tag therefore prints a warning the first time it's compiled:

~MOB: <Card> is not in the Mob tag whitelist  pass-through as :card

That is informational, not an error. The sigil compiles <Card> to the atom :card and defers resolution to whatever expander is registered under that atom at render time. As long as you registered one in Step 2, the tag renders; the warning is just the compiler telling you it recognized a non-built-in tag and passed it through. (A genuinely unregistered tag renders nothing, which is the real "it doesn't work" symptom to look for.)

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}

Handle limits

The native layer stores event handlers per committed frame:

  • 4096 interactive handles per frame. Every on_tap, on_change, on_focus, etc. in the rendered tree registers one handle. The tables grow on demand — they start small and are allocated to fit — so an app that uses a few dozen handles pays for a few dozen. Past 4096 the element still renders but its handler is silently unwired, and the count of unwired elements is logged once per frame; it does not crash the screen.

    This was 256 until MOB-133, which is low enough that an ordinary long list reached it: a 200-row list with three interactive elements per row left more than half of them inert. If you are anywhere near the current limit, prefer :list / :lazy_list, or lazy: true on a :scroll, so only what is on screen registers at all.

  • 256 native component slots. Mob.UI.native_view/2 / Mob.Component instances each take a slot. A full pool returns {:error, :component_slots_exhausted}; the framework logs and fails just that one component, leaving the screen alive.

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

Per-subtree event isolation, where a render subtree owns its own handle_info/2 so its events route to a dedicated process instead of the screen, is planned but not yet implemented. (Distinct from Mob.Composite, the tag-composite mechanism under "Defining your own components" above, which exists today for reusable widgets and custom tags; and from Mob.Component, the existing native-view behaviour.) 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.