BEAM-on-device mobile framework for Elixir. OTP runs inside your iOS and Android apps — embedded directly in the app bundle, no server required. Screens are GenServers; the UI is rendered by Compose and SwiftUI via a thin NIF.
Status: Early development. Confirmed on the iOS simulator, the Android emulator, and real iOS and Android devices.
flowchart TD
A["Your Elixir app<br/>(GenServers, OTP supervision, pattern matching, pipes)"]
B["Mob.Screen.Server<br/>(one GenServer per live screen — your logic lives here)"]
C["Mob.Renderer<br/>(component tree → JSON → NIF call)"]
D1["Compose (Android)<br/>native rendering, gestures"]
D2["SwiftUI (iOS)<br/>native rendering, gestures"]
A --> B --> C
C --> D1
C --> D2
You write Elixir. The native layer handles rendering. The BEAM node runs on the device — connect your dev machine to the running app over Erlang distribution, inspect state, and hot-push new bytecode without a restart.
Add to mix.exs:
defdepsdo[{:mob,"~> 0.7"}]endThe mob_new package (separate) provides project generation, deployment tooling, and will import mob_dev which is a live dashboard. Install it as a Mix archive:
mix archive.install hex mob_newdefmoduleMyApp.CounterScreendouseMob.Screendefmount(_params,_session,socket)do{:ok,Mob.Socket.assign(socket,:count,0)}enddefrender(assigns)do%{type: :column,props: %{padding: :space_md,gap: :space_md,background: :background},children: [%{type: :text,props: %{text: "Count: #{assigns.count}",text_size: :xl,text_color: :on_background},children: []},%{type: :button,props: %{text: "Increment",on_tap: {self(),:increment}},children: []}]}enddefhandle_info({:tap,:increment},socket)do{:noreply,Mob.Socket.assign(socket,:count,socket.assigns.count+1)}endendA tap on the button delivers {:tap, :increment} to handle_info/2 — the tag
comes from the on_tap: {self(), :increment} tuple in render/1, and self()
is this screen's own process.
defmoduleMyAppdouseMob.App,theme: Mob.Theme.Darkdefnavigation(_platform)dostack(:home,root: MyApp.CounterScreen)enddefon_startdoMob.Screen.start_root(MyApp.CounterScreen)Mob.Dist.ensure_started(node: :"my_app@127.0.0.1",cookie: :secret)endend# Push a new screenMob.Socket.push_screen(socket,MyApp.DetailScreen,%{id: 42})# Pop backMob.Socket.pop_screen(socket)# Tab bar layouttab_bar([stack(:home,root: MyApp.HomeScreen,title: "Home"),stack(:profile,root: MyApp.ProfileScreen,title: "Profile")])# Named themeuseMob.App,theme: Mob.Theme.Dark# Override individual tokensuseMob.App,theme: {Mob.Theme.Dark,primary: :rose_500}# From scratchuseMob.App,theme: [primary: :emerald_500,background: :gray_950]# Runtime switch (accessibility, user preference)Mob.Theme.set(MobThemes.Citrus)Core ships Mob.Theme.Light, Mob.Theme.Dark, and Mob.Theme.Adaptive
(follows the system light/dark setting). The preset themes —
MobThemes.Obsidian, MobThemes.ObsidianGlass, MobThemes.Citrus,
MobThemes.Birch, MobThemes.Material3 — live in the
mob_themes style package:
# mix.exs{:mob_themes,"~> 0.1"}# mob.exsconfig:mob,:styles,[:mob_themes]config:mob,:default_style,:mob_themes# boots into MobThemes.ObsidianSee the Theming guide for details.
All async — call the function, handle the result in handle_info/2:
# Haptic feedback (core; synchronous — no handle_info needed)Mob.Haptic.trigger(socket,:success)# Camera (mob_camera plugin)MobCamera.capture_photo(socket)defhandle_info({:camera,:photo,%{path: path}},socket),do: ...# Location (mob_location plugin)MobLocation.start(socket,accuracy: :high)defhandle_info({:location,%{lat: lat,lon: lon}},socket),do: ...# Push notifications (mob_notify plugin)MobNotify.register_push(socket)defhandle_info({:push_token,:ios,token},socket),do: ...Some capabilities ship as first-party plugins rather than in core — see the First-Party Packages catalog for the full set. Activating one is two lines:
# mix.exs{:mob_camera,"~> 0.1"}# mob.exsconfig:mob,:plugins,[:mob_camera]In core: Mob.Clipboard, Mob.Share, Mob.Files, Mob.Audio, Mob.Motion,
Mob.Permissions. As plugins: MobCamera (mob_camera), MobLocation
(mob_location), MobNotify (mob_notify), MobPhotos (mob_photos),
MobBiometric (mob_biometric), MobScanner (mob_scanner — also needs
mob_camera), MobBluetooth (mob_bluetooth).
For a full audit of what mob covers vs. what's missing vs. what's out of scope (compared against React Native + Expo SDK capabilities), see the Mobile Surface Matrix. Set realistic expectations before starting an app; spot plugin candidates if you want to fill a gap.
The BEAM runs on the device, but it does not keep running once the app is backgrounded. iOS suspends the whole process within seconds — schedulers stop, GenServers freeze, and any distribution / socket connections drop. Android does the same unless you run a foreground service (the persistent-notification kind). This is an OS constraint every mobile runtime lives with, not a Mob limitation.
So a server can't push straight into a long-lived GenServer — the OS has to wake you first, via APNs (iOS) or FCM (Android). The shape is:
# Register for a push token; your server stores it and sends through APNs/FCM.# MobNotify ships in the mob_notify plugin; see the mob_push package for the# server side.MobNotify.register_push(socket)defhandle_info({:push_token,:ios,token},socket),do: ...# React to the OS suspending / resuming the app. A push wakes the app, the BEAM# resumes, your handler runs in a short window, then the OS suspends you again.Mob.Device.subscribe([:app])defhandle_info({:mob_device,:did_enter_background},socket),do: ...defhandle_info({:mob_device,:will_enter_foreground},socket),do: ...Mob.Device.foreground?/0 reports the current state. For true always-on (e.g. a
live connection held open), an Android foreground service is the only path; iOS
will not allow it. Otherwise treat the device as push-driven: server → APNs/FCM →
OS wakes app → BEAM handles the event → BEAM suspends again.
The pre-built OTP runtime that ships with each app includes:
- Real
:crypto— OpenSSL 3.x, statically linked into the app's native lib. ECDH (incl. x25519, secp256r1), AEAD (ChaCha20-Poly1305, AES-GCM), SHA-2 hashes, HMAC, PBKDF2, HKDF, realstrong_rand_bytes/1. No insecure shim, no dlopen. :public_key+:ssl— cert parsing, HTTPS clients, TLS sockets. The whole standard:sslAPI is available.- Phoenix-compatible — Phoenix, LiveView, plug_crypto, jose, joken,
guardian, oban, and anything else using
:crypto/:sslworks unmodified. - Erlang distribution —
mix mob.connectopens an IEx session on-device. Hot-push individual modules withnl/1.
Native APIs (above) cover audio, files, clipboard, share, motion sensors, and permissions in core, with camera, location, push, photos, biometrics, and scanning available as first-party capability plugins.
The OTP runtime tarball is ~80 MB compressed; sliced per-arch by App Thinning (iOS) and App Bundle (Android) so each user only downloads ~25 MB of native runtime, on top of the BEAM bytecode for your app.
mix mob.connect # tunnel + connect IEx to running device
nl(MyApp.SomeScreen) # hot-push new bytecode, no restart# In IEx:
Mob.Test.screen(:"my_app_ios@127.0.0.1") #=> MyApp.CounterScreen
Mob.Test.assigns(:"my_app_ios@127.0.0.1") #=> %{count: 3, ...}
Mob.Test.tap(:"my_app_ios@127.0.0.1", :increment)defmoduleMyApp.CounterScreenTestdouseMob.ScreenCasetest"increments count"doview=mount_screen(MyApp.CounterScreen)view=render_info(view,{:tap,:increment})assertassigns(view).count==1endend| Package | Purpose |
|---|---|
mob_dev | Dev tooling: mix mob.new, mix mob.deploy, mix mob.connect, live dashboard |
mob_push | Server-side push notifications (APNs + FCM) |
Full documentation at hexdocs.pm/mob, including:
- Getting Started
- Architecture & Prior Art — comparison to LiveView Native, Elixir Desktop, React Native, Flutter, and native development
- Screen Lifecycle
- Components
- Theming
- Navigation
- Device Capabilities
- DNS on iOS — required reading if your app makes HTTPS calls; one-line fix for a non-obvious iOS-only failure mode
- Testing
Clone, then run once:
mix setupThat fetches deps and activates the repo's git hooks (.githooks/pre-push):
mix format --check, mix credo --strict (incl. ExSlop), and mix compile --warnings-as-errors run on every push, plus the full test
suite when mix.exs changes — the same gate CI enforces before publishing.
MIT