Part 1: Liquid Glass Components in Compose Multiplatform
When building Compose Multiplatform apps, we often want native effects that make the UI feel “right” on each platform.
One example is the liquid glass effect on iOS — the translucent blur used across Apple’s UI.
In this article, we’ll look at how I tried to integrate these native materials in a shared Compose setup — what worked, what didn’t, and where the platform boundaries stop us.
Update:
Compose Multiplatform 1.10.0-beta01 introduced a new placedAsOverlay flag that changes how UIKit and Compose layers can interact on iOS. I explored what it enables — and what still isn’t possible — in Part 2: Liquid Glass Components in Compose Multiplatform 1.10.0-beta01
Goal
I wanted to use:
- Liquid glass (blurred) components on iOS 26+,
- Native fallbacks on older iOS versions,
- And pure Compose implementations for Android.
Tabs — The Hardest Part: Getting Blur and Content to Coexist
Tabs turned out to be much harder to get right than expected — not because of logic, but because of how Compose and UIKit layers are composited together.
First Attempt — Native Tabs on Top of Compose
At first, I thought I could simply draw the native iOS tab bar (via CInterop or a small UIKit wrapper) and leave the main screen content in Compose.
That failed almost immediately.
Compose is implemented using Skia, which on iOS is rendered over the UIKit layer.
When you embed a UIKit or SwiftUI view in Compose (UIKitView), Compose effectively punches a hole in its own Skia surface to show the underlying UIKit content.
That means the UIKit view is literally behind Compose — it can’t see or blur anything that Compose drew “above” it. Even if you set backgroundColor to clear, there’s simply nothing to see through.
As a result, I couldn’t have both:
- a native blurred tab bar, and
- composable content rendered behind it.
The blur effect simply didn’t interact with the Compose layer at all — colors behind were flat or missing.
Second Attempt — SwiftUI Tabs Hosting Composable Content
The solution was to flip the rendering hierarchy.
Instead of embedding SwiftUI inside Compose, I embedded Compose inside SwiftUI.
Now, SwiftUI became the host layer, and my Compose UI was drawn inside it using ComposeUIViewController.
In this direction, SwiftUI controls the top layer — meaning its materials (like .ultraThinMaterial or .regularMaterial) can now blur and blend correctly with everything underneath, including Compose content.
In short, in the first attempt UIKit lived under Compose — invisible behind an opaque Skia layer. In the second attempt, Compose lives inside SwiftUI — visible behind the glass material, just like any native view.
This is what I imagined the view could look like to be easily used on both Android and iOS:
@Composable
expect fun NativeScaffold(
modifier: Modifier = Modifier,
tabs: List<TabItem> = emptyList(),
selectedTabPosition: Int = 0,
onTabSelected: (Int) -> Unit = {},
content: @Composable (currentTabPosition: Int?, contentPadding: PaddingValues) -> Unit,
)In Kotlin, my actual implementation maps each composable screen into a UIViewController:
fun contentMapper(index: Int): UIViewController =
ComposeUIViewController {
Theme {
content(index, padding)
}
}Then, on the Swift side, I use a full TabView that hosts those UIViewControllers:
struct NativeScaffoldView: View {
@ObservedObject var observable: NativeScaffoldObservable
var body: some View {
TabView(selection: $observable.selectedTab) {
ForEach(observable.tabs, id: \.position) { tab in
Group {
if #available(iOS 26.0, *) {
ComposeViewController { observable.content(tab.position) }
.ignoresSafeArea()
} else {
ComposeViewController { observable.content(tab.position) }
}
}
.tabItem {
if let uiImage = tab.icon.toUIImage() {
Image(uiImage: uiImage)
}
Text(tab.title)
}
.tag(tab.position)
}
}
}
}This way, the entire screen — including the tab bar — is drawn by SwiftUI.
Each tab’s content is still my composable UI, just wrapped in a ComposeUIViewController.
Now the blur works perfectly, because the Compose content is physically behind the SwiftUI glass material.
The tab bar bends colors and shapes just like native iOS.
Remaining Problem — Scroll Behavior and Gradient Overlay
This setup solved the blur issue but introduced a new one: scrolling.
When scrolling happens inside Compose, SwiftUI doesn’t know about it — meaning:
- The tab bar can’t collapse or hide during scroll.
- The native gradient overlay that appears at the bottom of tab bars on iOS never shows.
I tried solving this by using a native SwiftUI ScrollView around the composable content, but it caused:
- Incorrect height measurements (Compose couldn’t size itself properly inside ScrollView), and
- Significant performance degradation (I couldn’t use lazy composition).
So in the end, I accepted a compromise:
- Scrolling happens entirely in Compose.
- The tab bar stays fixed (no collapse).
- I manually added a gradient overlay at the bottom in Compose to simulate the native shadow.
Final Result:
Tabs rendered natively with full glass blur,
Composable content hosted inside SwiftUI,
Manual gradient overlay to match native appearance.
Toolbar — Bridging TopAppBar and SwiftUI Toolbar
Toolbars were more complex. I wanted the same API in shared code, but different rendering on each platform.
SwiftUI Toolbar + Compose Content
I added a custom Toolbar data class as a parameter in my NativeScaffold:
@Composable
expect fun NativeScaffold(
modifier: Modifier = Modifier,
toolbar: Toolbar? = null,
tabs: List<TabItem> = emptyList(),
selectedTabPosition: Int = 0,
onTabSelected: (Int) -> Unit = {},
content: @Composable (currentTabPosition: Int?, contentPadding: PaddingValues) -> Unit,
)
data class Toolbar(
val title: String? = null,
val buttons: List<ToolbarButtonData> = emptyList(),
)
data class ToolbarButtonData(
val icon: ImageResource,
val description: String? = null,
val position: ToolbarButtonPosition = ToolbarButtonPosition.Trailing,
val tint: NativeColor? = null,
val onClick: () -> Unit,
)And on iOS, I implemented it with a SwiftUI extension:
extension View {
@available(iOS 16.0, *)
func toolbar(_ toolbar: Toolbar) -> some View {
NavigationStack {
self
.navigationTitle(toolbar.title ?? "")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
let leading = toolbar.buttons.filter { $0.position == .leading }
let trailing = toolbar.buttons.filter { $0.position == .trailing }
if !leading.isEmpty {
ToolbarItemGroup(placement: .topBarLeading) {
ForEach(Array(leading.enumerated()), id: \.offset) { _, button in
toolbarButtonView(for: button)
}
}
}
if !trailing.isEmpty {
ToolbarItemGroup(placement: .topBarTrailing) {
ForEach(Array(trailing.enumerated()), id: \.offset) { _, button in
toolbarButtonView(for: button)
}
}
}
}
}
}
@ViewBuilder
private func toolbarButtonView(for button: ToolbarButtonData) -> some View {
Button {
button.onClick()
} label: {
if let uiImage = button.icon.toUIImage() {
Image(uiImage: uiImage)
.renderingMode(.template)
}
}
.tint(button.tint.map { Color(kmpColor: $0) } ?? .primary)
}
}This achieved the best of both worlds:
- Native iOS toolbar layout, spacing, and blur.
- Shared Compose content below it.
But there were still limitations:
- Just like with the tab bar, scroll events in Compose aren’t propagated to SwiftUI — so the toolbar can’t react with its native blur transition. I had to recreate that effect manually in Compose.
- ToolbarItems can’t be created dynamically inside a ForEach, so I had to use ToolbarItemGroups and pre-filter items by position (leading/trailing).
Final Result:
A hybrid scaffold — SwiftUI view with native toolbar and Compose content below.
Card / Button — The Unsolvable Blur Problem
This one’s tricky. I wanted to render a “liquid glass” card or button with composable content inside via expect/actual, reusable like any other composable.
Attempted Approaches
I tried both UIKitView and UIKitViewController, implemented through SwiftUI and CInterop.
No matter what I did, the result was the same: a solid white rectangle behind my view.
The reason lies in Compose Multiplatform’s rendering architecture. As mentioned above:
Compose is implemented using Skia, which on iOS is rendered over the UIKit layer.
When you embed a UIKit or SwiftUI view into Compose, you’re effectively punching a hole in the Skia canvas — that area is replaced by UIKit, not blended with it.
So even if my UIKit view is transparent, there’s no Compose content “below” it to see through.
That’s when I realized I’d reached a hard limitation:
it’s currently impossible to implement true translucent cards or buttons as composables with native glass blur effects.
Conclusion
Liquid glass components bring a lot of polish to iOS, but Compose Multiplatform’s current rendering stack limits how much native blending we can achieve.
Still, depending on the project’s needs, it’s possible to build a convincing hybrid UI:
- Native materials for system-level elements (tabs, toolbars)
- Shared Composable logic for layout and content
It’s not perfect — but it feels right on both platforms, and keeps the codebase unified.
Bonus chapter — Navigation
In this case, PlatformScaffold is a Composable function that wraps SwiftUI or UIKit elements under the hood. That means it’s still called inside a Composable screen, even on iOS.
How Navigation Works Here
It doesn’t matter whether we use iOS native navigation (SwiftUI’s NavigationStack or UIKit navigation) or Compose Multiplatform navigation — the entire Compose screen containing the PlatformScaffold becomes a single navigation node.
From SwiftUI’s or UIKit’s perspective, this is just one self-contained view. Inside it, all Compose content — including tabs, toolbar, and their composable pages — are rendered inside a single ComposeUIViewController.
I briefly considered adding smaller navigation inside the NativeScaffold, so each tab could switch or push composable routes independently. Technically, that would work — Compose navigation can operate normally within the Compose portion of the screen.
But practically, it would feel wrong for iOS navigation patterns, since the tabs and toolbar would stay on top without change.
Why We Keep It as a Single Node
The goal of NativeScaffold is to represent a complete “shell” — with toolbar, tab bar, and blur layers.
If we started navigating inside it (e.g., changing only the content of one tab), the outer UI elements would stay visible with the same data (titles, buttons, selected tab).
That’s rarely what users expect on iOS, where navigation usually pushes or slides an entirely new screen.
So I decided to treat the whole scaffold as one native screen:
- The toolbar and tab bar belong to that screen only.
- Any navigation beyond it (details, modals, new flows) happens at the higher level.
- Inside Compose, the content of each tab stays static.
In short:
The NativeScaffold is a full-screen unit. Regardless of whether navigation is handled by UIKit, SwiftUI, or Compose, it’s treated as a single node. Internal navigation is intentionally avoided to preserve native behavior and visual consistency.
Navigation (UIKit / SwiftUI / Compose)
├─ NativeScaffold (full-screen shell)
│ ├─ Toolbar (SwiftUI)
│ ├─ TabBar (SwiftUI)
│ └─ Compose Content (per tab)
└─ OtherScreen / Modal / DetailsWrapping up
This project started with a clear goal — to bring liquid glass components into a Compose Multiplatform app and make them feel truly native on iOS.
Along the way, I learned more about how Compose and SwiftUI actually work together — and where their boundaries are.
The key takeaway is this:
It is possible to use truly translucent UIKit/SwiftUI components in Compose, but only when Compose content is hosted inside SwiftUI, not the other way around.
When UIKit or SwiftUI views are embedded inside Compose, they sit below the Skia layer and can’t blend with it.
But when we flip the hierarchy — rendering Compose inside SwiftUI — the blur and glass effects behave just like in native iOS apps.
By treating each NativeScaffold as a full-screen unit (a single navigation node), we preserve the expected native navigation flow while still keeping the flexibility of Compose for the actual UI content.
It’s not a perfect solution, but it’s a practical one:
SwiftUI provides the native polish and effects, Compose provides the shared layout and logic — and together, they make hybrid UIs that still feel at home on both platforms.

