Skip to main content
Capacitor packages one web app — HTML, CSS and JavaScript — as an iOS app and an Android app, and renders it in the operating system’s web view. Ionic Framework is the component library usually paired with it, supplying tab bars, toolbars and lists as web components styled after each platform. Duet, Modaal’s cross-platform parity framework for native iOS and Android apps, takes the opposite position on both questions the framework survey asks: each platform’s toolkit draws the screen, and the shared logic is Kotlin compiled into both apps. This page compares the two on those questions and on a third — what proves the two apps agree — and uses a public lab repository that built one four-tab app in Ionic 9 and Capacitor 8 to show what the differences cost in lines of code, app size, build time and agent time. Last reviewed: September 18, 2026. Capacitor and Ionic claims were checked against their official documentation on this date. The measurements come from the lab repository’s builds of September 13–14, 2026; its Capacitor app was built and measured on iOS only.

How do Duet and Ionic Capacitor differ?

Who draws the screen?

A Capacitor app is a native shell around one web view. On iOS the shell’s view controller hosts a WKWebView, which renders with the WebKit of the installed iOS version. On Android, Capacitor’s documentation states that Chrome provides the WebView on Android 7–9 and the Android System WebView does on Android 10 and later, and it requires a WebView with Chrome version 60 or later; that WebView has been updatable from Google Play separately from the OS since Android 5.0. Capacitor 8 supports iOS 15 and Android 7.0 (API 24) and later. It loads the app’s bundled web assets into the web view and connects JavaScript to native code through plugins. Every screen, list and control the user sees is an HTML element: Ionic’s ion-tab-bar and ion-toolbar are elements in the page, laid out by CSS, and neither is a UITabBar or a UINavigationBar. Screen readers reach the page through the web engine’s accessibility support; Ionic’s accessibility guide states that its components “provide accessibility features through ARIA”. The lab did not test VoiceOver. Ionic’s components choose a mode per platform: ios on iOS devices, and md (Material Design) on Android and every other platform. Ionic’s documentation states that styling the components to each platform’s guidelines “allows the application to be written once but look and feel native to the user”. Each mode is a stylesheet that re-implements the platform’s look in CSS, so a new platform design reaches an Ionic app when Ionic or a community theme writes it. iOS 26’s Liquid Glass is the current example. A SwiftUI TabView built with the iOS 26 SDK draws the floating glass tab bar with no styling code; the lab’s SwiftUI build, on the iOS 26.5 SDK, showed it from a plain TabView. Ionic’s 8.8 announcement (March 6, 2026) states: “While we are not planning to ship an official Liquid Glass theme right now, the goal is to provide the underlying architecture that enables developers to implement these types of design systems themselves”. In @ionic/core 9.0.3, grep -ril "liquid\|glass" over dist/collection prints nothing. The lab used the community theme @rdlabo/ionic-theme-ios26, which draws glass with CSS backdrop-filter blur and saturation. The lab found two parts of the iOS look that web content cannot draw, on the iOS 26.5 simulator:
  • Glass refraction. An SVG displacement filter renders under filter and does not render under backdrop-filter in WKWebView, although CSS.supports reports the value as supported; WebKit bug 245510, which tracks it, had status NEW when the lab read it on September 13, 2026. The web build’s glass has blur and saturation and no lens distortion.
  • SF Symbols. The page cannot draw Apple’s symbols by name, so the lab substituted Ionicons: people for person.3, open-outline for arrow.up.forward.
Duet renders nothing itself. The iOS app is SwiftUI and the Android app is Jetpack Compose. In the Duet tutorial app, Foyer, the iOS tab bar is a SwiftUI TabView and the Android one is a Material 3 NavigationBar; both render the same activeTab value from the shared state, and a tap on either sends the same TabSelected action. The cost is that the two view layers are written twice.

What does a native look cost in a Capacitor app?

The lab repository builds four tabs of a school-messaging app — Home, Calendar, Chat with a conversation screen, and Settings — with Ionic 9.0.3, React 19, Capacitor 8.5.2 and the same placeholder data throughout. Its ionic-capacitor/ directory carries two styling variants built from one codebase:
  • stock uses Ionic’s iOS mode as the tabs starter ships it, with one accent color and no CSS written to match anything.
  • matched adds the Liquid Glass theme and its own CSS, styled to match a SwiftUI build of the same screens in the same repository. That SwiftUI build is a plain SwiftUI app — no shared core, no recordings, no Android half — so this page uses it as the reference matched was measured against and as the native baseline for app size and build time, and quotes no Duet figures from it.
Claude Code wrote both variants at the repository owner’s direction, and the repository’s spec records every count below (§14–§16, §20): Three findings from those passes bear on the choice of stack:
  • The look needed a native reference. Every value in matched’s stylesheet was measured from the SwiftUI build. The spec does not estimate how long the same pass takes without one; the alternative sources it names are Apple’s design resources and measurements of Apple’s own apps.
  • The look depends on framework internals. matched styles five of Ionic’s shadow parts and depends on Ionic’s internal class names (tab-bar-hidden, footer-toolbar-padding, header-collapse-condense), and the theme’s peer range ends below @ionic/core 10. An Ionic or theme upgrade that changes any of them needs a new screenshot round; stock does not.
  • The look is per platform. matched restyles Ionic’s ios mode. The lab built no Android app; a Capacitor app matched to Material 3 on Android would need a second styling pass in md mode, which the lab did not measure.
In a Duet app the corresponding work is the second view layer: each screen is written once in SwiftUI and once in Compose, from the platform’s own components, and the shared state they render is recorded and verified. The iOS screens use SwiftUI’s containers and controls — Foyer’s tab bar is a TabView and its lists are Lists — and those take the current iOS design when the app is built with the current SDK.

What ships in the app binary?

Capacitor ships no JavaScript engine: the app’s code runs in the system web view’s engine, so its version is the device’s — the WebKit of the installed iOS, and the WebView Google Play has installed on Android. The binary carries the web bundle, Capacitor’s frameworks and each plugin’s native code. Simulator Release .app sizes from the lab, against the repository’s SwiftUI build of the same screens (a plain SwiftUI app, not a Duet app): Every Capacitor build carries 4,480 KiB of Capacitor.framework and Cordova.framework, the layer for Cordova plugins that the Capacitor 9 roadmap makes optional. The rest is the web bundle: 3,068 KiB in stock, about half of it a legacy copy of the JavaScript that the Ionic starter’s @vitejs/plugin-legacy generates, and 6,252 KiB in matched, whose theme pulls in 2,108 KiB of @ionic/core chunks. The SwiftUI build’s code is its 1.2 MB binary. Duet’s shared core is compiled Kotlin — Kotlin/Native on iOS, JVM bytecode on Android’s ART — and adds about 3 MB to the Android app, measured on the shipped app Modaal’s Duet pages quote. A Duet iOS app in the Kotlin Multiplatform flavor is a SwiftUI app plus the core’s Kotlin/Native framework, for which this site publishes no size.

How long does a clean build take?

A Capacitor iOS build runs the web build and npx cap sync ios, then Xcode, which links Capacitor’s prebuilt XCFrameworks and compiles plugin sources. The SwiftUI build is one Xcode step. Clean Release builds at the phone-shell stage, one run of each on September 14, 2026 (spec §15.3): These are single runs on one machine, at a load average of 2.86 to 7.08. In a separate before-and-after run, adding the UIKit tab bar plugin took matched’s clean Release xcodebuild from 4.81 s to 9.75 s; that build compiles the plugin’s 3,170 lines of Swift from source. A Duet iOS build in the Kotlin Multiplatform flavor adds a Gradle step for the Kotlin/Native framework; the lab did not time one.

What proves the two platforms agree?

Capacitor’s answer is one implementation. Logic in the JavaScript bundle is shared by construction; two different engines render and run it — WebKit on iOS, Chromium on Android — so layout and CSS differences between the platforms are found by testing on both, as in cross-browser web work. Plugin code in Swift and Kotlin is written per platform and kept equal by review and QA. The lab’s Capacitor build ran on iOS only, so it measured nothing on this question. Duet’s answer is verification: each feature’s behavior is recorded as fixture files, both apps replay them, and CI compares the replay byte-for-byte — in the adoption measurement, 6 of 6 recordings were byte-identical across the Swift/Kotlin boundary. The guarantee covers what the fixtures cover: state, actions, requested effects and their ordering. The two view layers sit outside it, and Handling platform-specific UI describes how their deliberate differences are recorded.

What if part of the screen must be native?

Capacitor’s route to native is a plugin: Swift or Objective-C in the iOS project and Kotlin or Java in the Android one, called from JavaScript. The documented ways to show native UI place it outside the page’s layout:
  • Over the web view. On iOS the bridge’s view controller “can be used to present native views over the app” (iOS bridge API); on Android a plugin starts a native Activity.
  • Under or into the web view, aligned with an HTML element. The official Google Maps plugin renders the map “beneath the entire webview” on Android, which requires every layer of the page above it to be transparent, and “directly into the webview” on iOS. The plugin moves the native view to follow an HTML element’s position as the page scrolls.
  • Around the web view. A plugin can put native bars around the web view, which is what the lab did.
In each case the native view is not a DOM element: CSS does not lay it out, and the page’s own components cannot contain it. The lab measured one such case: replacing the CSS tab bar with UIKit’s (§20 of the spec).
  • The first plugin tried did not build. stay-liquid ships a CocoaPods podspec and no Package.swift. The iOS project Capacitor 8 generates uses Swift Package Manager, and npx cap sync ios printed “stay-liquid does not have a Package.swift” and left the plugin’s Swift out of the build. A plugin in that state needs its maintainer to add a Swift package; the Capacitor 9 roadmap notes that CocoaPods trunk becomes read-only on December 2, 2026.
  • The second plugin draws the bar by moving the web view. @capgo/capacitor-native-navigation 8.3.1 creates a UITabBarController, moves the Capacitor web view into the selected tab’s content controller, and sends a tabSelect event to JavaScript. The app’s router still owns the routes: the lab’s handler clicks the hidden ion-tab-button of the same tab.
  • The cost. 154 hand-written lines and no Swift: 91 lines of TypeScript connecting the routes to the bar, 41 lines of CSS, 22 for the accent color. The build compiles 3,170 lines of the plugin’s Swift from node_modules, and the app binary grew from 154,800 to 567,808 bytes. The work took 142 agent tool calls in 33 minutes.
  • The side effects. Inside the tab controller the web view’s bottom safe-area inset changed from 34 pt to 83 pt. The theme computed positions from the 34 pt value, so the chat’s ”+” button sat 49 pt too high until 41 lines of CSS replaced that arithmetic. The keyboard’s background turned from dark to light grey, because the plugin’s container takes the web view’s background color. On iPad the bar ignored the plugin’s tint and needed an AccentColor in the app target.
  • What stays HTML. The tab bar is UIKit’s; the navigation bars, lists and buttons on each page are still Ionic’s elements.
Do you write and compile native code for it? Yes, unless an npm plugin covers the case exactly. A plugin from npm spares the writing, not the compiling: its Swift and Kotlin are built into the app by Xcode and Gradle, as the 3,170 lines above were. Can the island be SwiftUI or Compose? Only as a native view a plugin places over, around or under the page, written in Swift or Kotlin — a SwiftUI view through UIHostingController, a Compose view in a native Activity or view. The page’s HTML cannot contain one. In a Duet app the island is ordinary platform code — AVFoundation and Metal in the SwiftUI app, CameraX and MediaCodec in the Compose app — with nothing between it and the OS. The reducer decides when capture starts, which filter applies and what happens with the result; a worker runs the pipeline. What is recorded and byte-compared is that seam: the requests the reducer makes and the results that come back. The pipeline’s internals are outside the verified surface, and the island is written twice, once per platform: Swift compiled by Xcode, Kotlin compiled by Gradle, by design. A tab bar in a Duet app needs no plugin: it is the platform’s own control, bound to shared state, as the Foyer example above shows.

What happens when the platform changes its layout rules?

Apple announced iPhone Duo on September 9, 2026, available October 23 with iOS 27.1. On it, “app navigation and controls now appear on the side to maximize vertical space for content”, and Split View puts two apps side by side. Per Apple’s guidance, the system moves bars to the side only when they come from its own containers:
  • Preparing your app for iPhone Duo says to “prefer system-provided layouts and containers, such as split views, tab bars, arrangement views, and navigation stacks”, and, if bars do not present vertically, to check that the app uses “the bar support that navigation containers, such as a tab view or navigation stack, provide”.
  • The tech talk Raise the bar with iPhone Duo says content from custom bars built from UIToolbar, UINavigationBar or UITabBar “won’t be considered”, and names UINavigationController and UITabBarController as the containers to use.
  • The tech talk Prepare your app for iPhone Duo gives the inner display regular horizontal and vertical size classes, and warns that safe areas “are often asymmetric”.
An HTML tab bar and HTML navigation bars are none of those containers. The lab’s September 14 review (in its DISCOVERY.md) found no fold, segment or reserved-region API in the iOS 27.0 SDK’s WebKit headers, so the web view receives a resized viewport and its safe-area insets and nothing about the fold. It also found a layout the web build would get wrong. The Capacitor build switches to two columns at (min-width: 672px), the width at which SwiftUI’s size class changed in the lab’s iPad windows. The inner display in portrait is 669 points wide, taking the 2,007 × 2,853 px screenshot size App Store Connect lists for it at 3x, so the web build would show its phone layout on a display Apple gives regular width. Unfolding from the outer display (466 points wide) to the inner display in landscape (951 points) crosses the same breakpoint. In the lab’s iPad windows each crossing of 672 points mounted a new router outlet, and state held in page components started again: keeping a typed message draft across the change took a separate store, Drafts.tsx, 46 lines. The lab’s recommendations for a Capacitor app, in the same entry: pass the size class from native code to the web view and choose the layout from it; let a UITabBarController own the tab bar, as the plugin above does; handle each safe-area inset per side; and keep navigation state in the URL or a store rather than in components that remount. None of it could be run on iPhone Duo: Xcode 27.1 beta, which carries the iPhone Duo simulator, is listed as “coming later this month” on Apple’s iPhone Duo page as of September 18, 2026. No Duet app has been run on it either, for the same reason. A Duet app’s iOS screens are SwiftUI, so they can use the containers Apple’s guidance names — Foyer’s tab bar is a TabView — and a rebuild with the iOS 27.1 SDK is the documented step for the side placement. Feature state lives in stores outside the view tree, as the Duet overview’s lifecycle answer describes, so a relayout on fold or unfold redraws the views over state the stores still hold.

What does Ionic Capacitor do better?

  • One interface implementation, plus the web. The same HTML, CSS and TypeScript run in the iOS app, the Android app and a browser, and Capacitor’s documentation states “first-class support for Progressive Web Apps”, with many plugins offering the same API on the web. The lab’s code uses the native tab bar only on iOS and keeps Ionic’s HTML tab bar in a browser (nativeTabBar.ts). Duet targets iOS and Android only, with two view layers.
  • Web skills and the npm ecosystem. A team that builds web apps can ship mobile apps without learning Swift or Kotlin until it needs a plugin, and an existing web app can be packaged rather than rewritten.
  • The iteration loop. The web app runs in a desktop browser under the web tooling’s dev server, and live reload (npx cap run ios --live-reload) loads it from that server into the web view on a device on the same Wi-Fi network, reloading on each change. The lab’s clean web builds took 2.7 to 5.2 seconds, and npx cap sync ios under one second.
  • A usable default look. stock reached an iOS-styled four-tab app with 275 lines of the app’s own CSS; everything else came from Ionic’s iOS mode.
  • Updates without a store release. The interface and logic ship as web assets, which can be replaced over the air within Apple’s rules on downloaded interpreted code, quoted in the over-the-air answer below. A compiled core cannot be replaced this way, and Duet ships every change through the App Store and Google Play.

When should you pick which?

Pick Capacitor when you have a web app or a web team, when the product also ships on the web, when the interface can follow Ionic’s platform modes or one brand design system shared by every platform, and when the native features you need are covered by maintained plugins. Pick Duet when users expect each platform’s current controls — a Liquid Glass tab bar, bars that move to the side on iPhone Duo — without re-implementing them in CSS, or when the domain is rules-dense and “do both apps do the same thing?” needs a mechanical answer. Duet targets iOS and Android only; a product whose web version must run the same code as its mobile apps needs a web stack such as Capacitor. The framing note this site puts on every comparison: Modaal builds native-first, with both interfaces in the platforms’ own toolkits and no shipped runtime, and that constraint was fixed before any framework evaluation. Capacitor was ruled out by the constraint. The lab measured the effort to make a Capacitor app look like a SwiftUI app on iOS; it measured no frame rates or startup times, and this page quotes none.

Common questions

No. Capacitor packages any web app — React, Vue, Angular or plain JavaScript — and Ionic Framework is one component library you can put in it. The lab used both, because Ionic supplies the tab bar, toolbars and iOS-styled lists that a native look starts from.
As native views outside the page’s layout, yes: a plugin can present a native screen, draw a native view under or into the web view as the Google Maps plugin does, or, like @capgo/capacitor-native-navigation, put a UITabBarController or UINavigationBar around the web view. As page elements, no: every list, button and field that CSS lays out is HTML. In the lab, the native tab bar replaced one element of the shell; the navigation bars and every list stayed Ionic’s.
No. The app’s code runs in the system web view’s engine — WebKit on iOS, the Chromium-based WebView on Android — so the binary carries no engine. The engine version is the device’s: the WebKit of the installed iOS, and the WebView version Google Play has installed on Android, which Capacitor requires to be Chrome 60 or later.
Web assets can be replaced without a store release. Apple’s Developer Program License Agreement allows it in section 3.3.1(B): “Interpreted code may be downloaded to an Application but only so long as such code” does not change the app’s primary purpose, does not bypass the OS’s signing, sandbox or other security features, and does not create a store for other apps. Ionic’s own service for this, Appflow, stopped taking new customers on February 11, 2025, and existing users keep access through December 31, 2027; Ionic’s migration article names Capawesome Cloud and Capgo for live updates. A Duet app’s compiled core cannot be replaced this way: every change ships through the App Store and Google Play.
There is no Capacitor-to-Duet converter, and this site does not claim one. A move to Duet means re-authoring each feature against recorded specs, one feature at a time, with a SwiftUI and a Compose screen per feature. If the web app must keep running in browsers, it stays a web app beside the native ones.
The lab is itself an agent measurement: Claude Code built both Capacitor variants in 343 tool calls. Matching the native look took techniques an agent uses when the system does not supply the values — temporary outlines on toolbars, accessibility frames read from both apps, hit-test probes, pixel comparisons. UI automation also sees less of a web view: axe describe-ui listed no web content in its tree dump and returned web elements only for queries at a point. Duet gives an agent machine-checkable gates: a screen cannot wire to logic without a passing recording, and CI replays the corpus on every push.
Duet projects are scaffolded by Modaalstarting a Duet project walks through the two template cards.

Sources and further reading

The best cross-platform frameworks in 2026

The full survey — Ionic/Capacitor’s cell on the quadrant, beside Flutter, and every other framework on the same three questions.

Duet vs React Native

The other JavaScript stack: the same language, but native views drawn by Fabric in place of a web view.

Handling platform-specific UI

How Duet keeps two native interfaces honest: the presentation contract and recorded divergence.

Duet overview

What Duet is, the wizard cards that scaffold it, and the six-step loop every feature follows.
Last modified on September 18, 2026