DEV Community

Cover image for I Wanted Real Native UI in My WebView. So I Built Projection. [Capacitor/Cordova]
Masahiko Sakakibara
Masahiko Sakakibara

Posted on Edited on AI-assisted

I Wanted Real Native UI in My WebView. So I Built Projection. [Capacitor/Cordova]

When you build a WebView app with Capacitor or Cordova, you often recreate native UI on the web to make the app feel at home on the device.

I've spent more than a year working on this, releasing my work as an open-source Ionic Theme that recreates native UI with web technologies.

But did I really recreate Liquid Glass, introduced in iOS 26? That's harder to answer. I could get the appearance close, but the background refraction and the way it responds to touch were beyond what I could reproduce on the web.

The same tab held down in the web theme and native UI, with the glass enlarged for comparison.
Both show a long press. Look at the edges of the glass and the background showing through it.

Then why not use real native UI?

That brought me to a different problem: making it coexist with the WebView. Web content and native UI live in separate rendering layers. I couldn't make them feel like one natural interface.

Recently, I found a way. Take a look!

As I slowly close this web-based sheet modal, a native tab bar emerges from underneath it.

A web sheet opens over a native tab bar, slides closed, and is followed by a long press on the restored native tab bar.

The web modal appears above the native tab bar. Close it, and the tab bar is interactive native UI again.

This tab bar isn't an HTML or CSS recreation. It's real native UI rendered by UIKit on iOS.

I call this approach Projection.

The boundary a WebView can't cross

A WebView and native UI don't share a stacking order or a scrolling system.

The WebView renders HTML and CSS. No matter how high you set a CSS z-index, a web modal cannot cover a native view placed above the WebView itself.

Scrolling has the same problem. When content inside the WebView scrolls, a native view doesn't automatically move with it. To place native ads between posts, as you see in an X feed, you need to synchronize their positions—and clip the parts that pass behind the header.

They're separate rendering systems. Of course they behave differently.

But to the person using the app, it's all one screen.

If native UI can't go into the DOM, project its appearance

The turning point was thinking about when native UI can't be interacted with.

When a modal is open, you can't use the tab bar it covers. The same goes for an alert: you can't tap the components behind it.

So, while that UI is unavailable for interaction, does it actually need to stay in the native layer?

Turn it into an image temporarily and place it within the WebView's stacking order. When it becomes interactive again, bring back the real native UI.

The same idea works for scrolling. Switch to an image when scrolling starts, then back to the real view when it stops. Web content and native UI can coexist without compromising the user experience at all.

That realization was pretty exciting.

The native UI itself doesn't need to be in the DOM. Only its appearance does.

That's the idea behind Projection.

Three stages: live native UI, its image projected into the WebView while the sheet is open, and live native UI again.
In the middle, the tab bar is an image.

In practice, there are three ways to display the UI:

Mode What it shows
Live Native UI The real native view, ready for interaction.
Web Projection An image of the native UI inside a DOM container.
Web Fallback HTML or another fallback supplied by the plugin when an image isn't available.

Of course, you can't interact with the native UI while it's projected as an image. That makes the timing of the switch—in both directions—essential.

It feels like magic

The idea was simple. Getting the handoff right was hard.

Hide the native UI before showing its image, and you get a blank frame. Show the image before hiding the native UI, and the translucent UI appears twice, layered over itself.

Neither works.

So I capture the image on the native side and decode it on the web side during idle time, before the switch. But a UI element can still be changing shape just after a long press ends. On iOS, I check its position and size at intervals roughly equal to one frame at 60fps, and capture it only after three consecutive checks find no change.

When Projection starts, I temporarily show the same image on the native side to bridge the handoff. I place the image, captured with its background, and hide the real native UI in the same native-side operation. This prevents both the blank gap and the doubled translucent UI.

Then I show the image on the web side and use requestAnimationFrame to notify the native side to remove its temporary copy. The appearance stays the same. Only the rendering layer changes.

Just watching the screen, you can't tell when the native UI becomes an image.

It feels like magic!

I've also tuned scrolling separately on iOS and Android so the native UI follows the content smoothly.

On iOS and Android, native ads scroll between posts and disappear behind the fixed header.
Watch the top edge of each ad. On both platforms, it disappears behind the header.

And you can use it today

The first things I wanted to support were iOS navigation—toolbar buttons and a tab bar—and native ads that scroll within web content.

This approach is already available in @rdlabo/capacitor-native-ui and @capacitor-community/admob.

But keeping it limited to the plugins I work on felt like a missed opportunity.

Toolbars, mini-player controls, UI provided by native SDKs—there must be more places where this could help.

So I extracted the shared pieces into WebView Native UI Engine, an open-source library under the MIT license.

It's for Capacitor and Cordova plugin authors, with support for iOS and Android.

Register a native view and its destination DOM container, then notify the Engine of the relevant state changes. It coordinates the display transitions. I designed it to fit into existing plugins while preserving their APIs and business logic.

I'd love for you to try it in one of two ways.

If you build plugins

Whether you're building a new plugin with native UI or extending an existing one, try integrating the Engine.

The Getting Started guide includes a minimal setup and integration instructions.

If you use plugins

Wouldn't it be interesting to have this in a plugin you already use?

Suggest it to the plugin's author. Or, if you think you can implement it, send a pull request.

There must be uses for this that I'd never think of on my own.

I can't wait to see what you build with it!

WebView Native UI Engine on GitHub

Top comments (0)