Styling & Native Rendering

Copy Markdown View Source

This page maps every Mob component and style prop to its native counterpart on iOS (SwiftUI) and Android (Jetpack Compose). Use it when you need to understand platform behaviour, look up what constraints apply, or reach for a platform-specific override.

Mob does not wrap a web renderer — you are writing directly to SwiftUI and Compose. That means native look-and-feel, native performance, and native constraints.

Component → native widget

Mob typeiOS (SwiftUI)Android (Compose)
:columnVStack(alignment: .leading, spacing: 0)Column
:rowHStack(spacing: 0)Row
:boxZStack(alignment: .topLeading)Box(contentAlignment = Alignment.TopStart)
:scrollScrollView (vertical or horizontal)Column/Row + .verticalScroll / .horizontalScroll
:textTextText
:buttonButton with Text labelButton with Text content
:text_fieldTextField (SwiftUI roundedBorder style)TextField (Material 3 filled style)
:toggleToggleSwitch inside a Row
:sliderSliderSlider
:dividerDividerHorizontalDivider
:spacerSpacer().frame(minWidth:, minHeight:)Spacer(modifier = Modifier.size())
:progressProgressView() (indeterminate spinner)CircularProgressIndicator
:imageAsyncImageAsyncImage (Coil)
:lazy_listScrollView + LazyVStackLazyColumn
:tab_barTabViewScaffold + NavigationBar
:videoAVPlayerViewController (UIKit wrapper)Stub — requires ExoPlayer (see below)

Platform docs:


Common layout props

These props apply to every component via nodeModifier (Android) and view modifiers (iOS).

PropiOSAndroidNotes
background.background(Color(argb))Modifier.background(color)Drawn before padding so it fills the full area including padding space
padding.padding(EdgeInsets)Modifier.padding(all =)Uniform on all sides
padding_top/right/bottom/left.padding(EdgeInsets)Modifier.padding(top=, end=, bottom=, start=)Per-edge; falls back to padding for unset edges
corner_radius.clipShape(RoundedRectangle(cornerRadius:))RoundedCornerShape(dp) applied to background + clipApplied after background, before children render
fill_width.frame(maxWidth: .infinity)Modifier.fillMaxWidth()Defaults true on :button; false on others unless set

iOS reference: SwiftUI Layout Android reference: Compose modifiers

Ordering note

On both platforms, background is applied before padding so the color fills the full padded box. Corner radius clips after padding so the rounded mask covers the entire padded area. Changing this order visually by hand (e.g. via platform overrides) is unsupported and will produce inconsistent results across platforms.


Typography props (:text and :button)

PropiOSAndroidValues
text_sizeFont.system(size:)fontSize =Number (sp/pt) or size token (:base, :xl, etc.)
font_weightFont.weight(_:)fontWeight ="bold", "semibold", "medium", "regular", "light", "thin"
italic.italic()fontStyle = FontStyle.ItalicBoolean
text_align.multilineTextAlignment() + .frame(alignment:)textAlign ="left" / "center" / "right" (default: "left")
letter_spacing.tracking(_:)letterSpacing =Float (sp/pt)
line_height.lineSpacing(_:) (derived as (multiplier - 1.0) × size)lineHeight =Float multiplier (e.g. 1.5 = 150%)
fontFont.custom(name, size:)FontFamily loaded from assetsPostScript name on iOS; lowercase+underscore filename on Android. Falls back to system font if not found.

iOS font reference: Font (SwiftUI) Android font reference: Typography (Compose)

line_height difference

SwiftUI's .lineSpacing adds extra space between lines, not total line height. Mob converts the multiplier to extra spacing as (multiplier - 1.0) × font_size, matching Compose's lineHeight behaviour as closely as possible. For very tight values (multiplier < 1.0) results will differ between platforms — test on both.

Custom fonts

Drop .ttf / .otf files into priv/fonts/ in your Mix project. mix mob.deploy --native copies them into the correct platform directories and patches Info.plist for iOS. Reference them by PostScript name:

%{type: :text, props: %{text: "Hello", font: "Inter-Regular", text_size: :base}, children: []}

iOS requires the PostScript name (visible in Font Book → ⌘I). Android derives the resource name automatically from the filename (Inter-Regular.ttfinter_regular), but Mob normalises the name before sending it to the NIF so you can use the same string on both platforms.


Color props

PropApplies to
backgroundAll components
text_color:text, :button
color:slider, :toggle, :progress, :divider
border_color:text_field
placeholder_color:text_field

Color values are resolved in this order:

  1. Semantic theme token (:primary, :surface, :on_surface, etc.) — resolved via the active Mob.Theme
  2. Palette atom (:blue_500, :gray_200, etc.) — resolved from the built-in palette
  3. Raw ARGB integer (0xFFFF5733) — used as-is
  4. Unknown atom — serialised as a string; the native side treats it as a no-op color

iOS color rendering: all ARGB integers are passed as UIColor(argb:) via the NIF and converted to Color(_:) in SwiftUI. Android: ARGB integers are passed as Long and converted with Color(long).

iOS reference: Color (SwiftUI) Android reference: Color (Compose)


Input props (:text_field)

PropiOSAndroid
keyboard: :default.keyboardType(.default)KeyboardType.Text
keyboard: :email.keyboardType(.emailAddress)KeyboardType.Email
keyboard: :number.keyboardType(.numberPad)KeyboardType.Number
keyboard: :decimal.keyboardType(.decimalPad)KeyboardType.Decimal
keyboard: :phone.keyboardType(.phonePad)KeyboardType.Phone
keyboard: :url.keyboardType(.URL)KeyboardType.Uri
return_key: :doneToolbarItem "Done" button dismisses keyboardImeAction.Done
return_key: :nextKeyboard stays open (next field focus handled by app)ImeAction.Next
return_key: :gon/a (treated as done)ImeAction.Go
return_key: :searchn/a (treated as done)ImeAction.Search
return_key: :sendn/a (treated as done)ImeAction.Send

iOS reference: TextField (SwiftUI) Android reference: TextField (Compose)

iOS keyboard toolbar

iOS automatically shows a "Done" button in a keyboard toolbar above the keyboard for all text fields. This lets users dismiss the keyboard without triggering on_submit. There is no equivalent on Android — the IME action button handles both submit and dismiss.


Image props (:image)

PropiOSAndroidNotes
srcAsyncImage(url:)Coil AsyncImageURL string; local file paths also accepted
content_mode: "fit".aspectRatio(contentMode: .fit)ContentScale.FitDefault
content_mode: "fill".aspectRatio(contentMode: .fill)ContentScale.CropCrops to fill
width.frame(width:)Modifier.width()Fixed width in pt/dp
height.frame(height:)Modifier.height()Fixed height in pt/dp
corner_radius.clipShape(RoundedRectangle(cornerRadius:))RoundedCornerShape + clipApplied after dimensions
placeholder_colorColor(argb) shown while loading or on errorSameDefaults to system gray

iOS reference: AsyncImage (SwiftUI) Android reference: Coil


Scroll props (:scroll)

PropiOSAndroid
axis: :vertical (default)ScrollView(.vertical)Modifier.verticalScroll on a Column
axis: :horizontalScrollView(.horizontal)Modifier.horizontalScroll on a Row
show_indicator: false.scrollIndicators(.hidden)ScrollState (indicators not natively controllable in Compose)

Android's verticalScroll modifier also applies .imePadding() so the keyboard does not obscure text fields inside a scroll view.


Video props (:video)

PropiOSAndroid
srcAVPlayer(url:)ExoPlayer (requires setup — see below)
autoplayplayer.play() on appearSame
loopAVPlayerLooperPlayer.REPEAT_MODE_ONE
controlsAVPlayerViewController.showsPlaybackControlsPlayerControlView

Android note: The Android video player is currently a stub. Full playback requires adding ExoPlayer (now bundled as androidx.media3:media3-exoplayer) to android/app/build.gradle:

implementation "androidx.media3:media3-exoplayer:1.3.1"
implementation "androidx.media3:media3-ui:1.3.1"

iOS reference: AVPlayerViewController Android reference: Media3 ExoPlayer


Tab bar props (:tab_bar)

PropiOSAndroid
tabsTabView with .tabItem { Label(...) }Scaffold + NavigationBar + NavigationBarItem
activeTabView(selection:) bindingselectedItem state
on_tab_selectBinding.set calls NIFonClick calls NIF
Tab iconSF Symbol name (string)Material icon name resolved via string lookup

iOS tab icons are SF Symbols — pass the symbol name as a string (e.g. "house", "person.fill"). See SF Symbols.

Android tab icons use Material icons. The same SF Symbol names work as a best-effort match; unrecognised names fall back to a circle. See Material Symbols.


Platform-specific overrides

Any prop can be overridden per-platform using nested :ios and :android keys:

props: %{
  padding:    12,
  ios:        %{padding: 20},       # iOS sees 20; Android sees 12
  android:    %{corner_radius: 4},  # Android gets extra radius; iOS doesn't
}

The unmatched platform's block is silently dropped before serialisation. Use this sparingly — prefer semantic tokens and let the platform render naturally.


Component defaults

The renderer injects these defaults for missing styling props. Explicit props always win.

ComponentDefault props
:buttonbackground: :primary, text_color: :on_primary, padding: :space_md, corner_radius: :radius_md, text_size: :base, font_weight: "medium", fill_width: true, text_align: :center
:text_fieldbackground: :surface_raised, text_color: :on_surface, placeholder_color: :muted, border_color: :border, padding: :space_sm, corner_radius: :radius_sm, text_size: :base
:dividercolor: :border
:progresscolor: :primary

All other components have no injected defaults — unstyled components use the platform's own defaults.


Toggle and slider props

:toggle

PropiOSAndroid
labelToggle(label, isOn:) — label textText inside a Row beside Switch
valueisOn binding initial valuechecked state
on_change.onChange(of: isOn) → NIFonCheckedChange → NIF
color.tint(Color)SwitchDefaults.colors(checkedThumbColor:)

:slider

PropiOSAndroid
valueInitial Slider valuevalue state
minin: min...max range lower boundvalueRange lower bound
maxin: min...max range upper boundvalueRange upper bound
on_change.onChange(of: value) → NIFonValueChange → NIF
color.tint(Color)SliderDefaults.colors(thumbColor:, activeTrackColor:)

Human Interface Guidelines

For design decisions not covered here, refer to the platform design systems directly: