DEV Community

Cover image for I Built JetBrains' Official Liquid Glass Setup, Then Deleted It
Shiva Thapa
Shiva Thapa

Posted on • Originally published at Medium

I Built JetBrains' Official Liquid Glass Setup, Then Deleted It

There's a moment, if you ship Compose Multiplatform to iOS, when someone opens your app on an iOS 26 device and asks why the tab bar doesn't do the glass thing.

It's a fair question with an awkward answer. Compose Multiplatform renders its own UI through Skia. It does not draw UIKit. So when Apple ships a new material — Liquid Glass, introduced in iOS 26 — a Compose app doesn't inherit it the way a native app does by recompiling.

JetBrains says so plainly in their documentation:

"Liquid Glass effects are rendered by the system through native TabView, NavigationStack, and toolbar APIs."

So you need a native surface. The question is how much of your app you're willing to hand over to get one.

I write this from a production app — a farming app used across Nepal, live on both stores, 46 Gradle modules, 93% of its code shared in commonMain, every screen drawn by Compose on both platforms. On iOS 26 its bottom bar is a real UITabBar rendering Apple's Liquid Glass. It is not a Compose approximation of glass. It's the system's.

Getting there meant building the officially documented approach first — and then throwing it away.

What the Official Recipe Does

JetBrains' guidance is coherent and, for most apps, correct. You build a native SwiftUI shell: a TabView for the tab bar, a NavigationStack per tab for pushes. iOS 26 applies Liquid Glass to both automatically, with no glass-specific code. Compose stops rendering navigation chrome and renders only screen content, told to do so by a CompositionLocal — theirs is called LocalUseNativeNavigation. Route state is mirrored across the boundary through a path binding, with callbacks going the other way.

You get a great deal for that: the glass tab bar, the glass toolbar, the native back-swipe, and the system's own transitions.

You also get something subtler. The navigation stack now lives in SwiftUI. Compose's routes are mirrored into a NavigationStack path; SwiftUI owns the presentation; the two stay in sync because you keep them in sync. That's a synchronization surface, and it exists per platform, because Android has no NavigationStack to mirror into.

For a fresh app, or one whose navigation is a handful of tabs and pushes, that's a fine price. It wasn't for ours.

The Question That Actually Decides It

The official recipe is the right call for a lot of apps. Whether it's right for yours comes down to one question: what does your navigation graph carry besides screen transitions?

If the honest answer is "not much" — a few tabs, some pushes — hand navigation to SwiftUI and enjoy the glass. But most production apps have quietly accreted cross-cutting behavior onto the back stack:

  • deep links from several sources — a custom scheme, verified universal links, push payloads — resolved in shared code,
  • an auth guard that redirects an unauthenticated user mid-navigation,
  • links that arrive before the graph is ready and have to be replayed once it is,
  • session-expired or re-login dialogs that can interrupt navigation from anywhere,
  • a one-time onboarding flow that has to run ahead of all of it.

If that logic is shared today, mirroring the navigation stack into SwiftUI gives every piece of it a second home — one that exists on a single platform. And the failure that produces isn't "the glass doesn't render." It's "the auth redirect works on Android and lands one screen too deep on iOS," found six months later by a user rather than a test.

That was the trade I didn't want. Not native navigationnative chrome, shared behavior. The bar should be Apple's. The routing behind it shouldn't know the bar exists.

That reframing is the whole article. Everything below is a consequence.

The Shape: An Overlay, Not a Container

The iOS 26 entry point hosts the unchanged App() — the same composable Android calls — and puts a bar on top of it:

ZStack(alignment: .bottom) {
    ComposeShell()                       // the whole app, unchanged
        .ignoresSafeArea()

    NativeTabBar(...)                    // a real UITabBar
        .frame(height: barHeight + geo.safeAreaInsets.bottom)
        .offset(y: model.isBarVisible ? 0 : barHeight + geo.safeAreaInsets.bottom)
}
Enter fullscreen mode Exit fullscreen mode

Two decisions carry the design.

It's a bare UITabBar, not a UITabBarController. A controller wants to own the screen and host children. We already have a host. All we need is the bar — a strip at the bottom, roughly 49 points plus the home-indicator inset. (49pt is the documented height of the classic bar; Apple doesn't publish a metric for the iOS 26 floating one, so we pin the classic number and let the bar fill its glass background down to the edge.)

The overlay covers only the bar. The Compose host is full-bleed beneath it, still visible, still taking touches everywhere the bar isn't. This matters more than it sounds: glass refracts what's behind it. A bar sitting on an opaque strip is a bar with nothing to sample. The feed has to scroll under it for the material to mean anything.

And the appearance code? There isn't any:

func makeUIView(context: Context) -> UITabBar {
    let bar = UITabBar()
    bar.delegate = context.coordinator
    bar.tintColor = UIColor(NativeTab.brand)     // brand green, that's it
    return bar
}
Enter fullscreen mode Exit fullscreen mode

No UITabBarAppearance. No selectionIndicatorImage. Built against the iOS 26 SDK and running on iOS 26, a stock UITabBar adopts Liquid Glass and the system selection capsule on its own — unless the app opts out with UIDesignRequiresCompatibility in its Info.plist. The capsule especially is not ours to style: per Apple's DTS engineers, on iOS 26 it's the system indicator and legacy overrides are ignored.

That's the point. The best code for rendering Apple's material is the code Apple already wrote. Our job was to get out of its way — and to keep Compose from noticing.

One more decision, easy to get wrong: the bar is mounted once and slides, never inserted and removed. Look at the .offset in that ZStack — when the bar should hide, it slides below the bottom edge instead of leaving the view tree. That isn't stylistic. A UIViewRepresentable is dismantled the instant it leaves a SwiftUI hierarchy, so a .transition on it has nothing left to animate out and just snaps. Keep it mounted, animate the offset, and it slides symmetrically in and out — the way hidesBottomBarWhenPushed feels natively.

Because it's a permanent view, it also carries two small jobs Compose can't hand it: it hides itself over the keyboard by observing keyboardWillShow/keyboardWillHide (Compose's IME state doesn't reach SwiftUI), and it builds its own labels — it lives outside composition, so if your app is localized it needs its own route to the same strings Compose uses.

The Bridge: One CompositionLocal, One Delegate

Shared code must not import UIKit, and the Swift bar must not import a navigation graph. Between them sit two small contracts in commonMain.

First, a switch that is false everywhere except inside the native shell:

val LocalNativeTabBarEnabled = staticCompositionLocalOf { false }
Enter fullscreen mode Exit fullscreen mode

The single container that Android and iOS both run reads it. When true, it renders no Compose bottom bar and publishes its bottom-bar state outward instead. When false — Android, iOS 18 — the file behaves exactly as it did before any of this existed.

(JetBrains' documented shell reaches for the same kind of switch — a CompositionLocal named LocalUseNativeNavigation — to tell Compose to stand down. Landing on the same seam is reassuring; where the two designs part ways is how much behavior sits on the other side of it.)

Second, the bridge itself:

interface NativeTabBarDelegate {
    fun onTabsUpdated(tabIds: List<String>, selectedTabId: String, showTabBar: Boolean)
}

object NativeTabBarBridge {
    private val bottomInsetState = mutableStateOf(0f)
    val bottomInsetDp: Float get() = bottomInsetState.value

    fun publishTabState(tabIds: List<String>, selectedTabId: String, showTabBar: Boolean)
    fun onTabTapped(tabId: String)          // native tap -> shared navigation
    fun setBottomInset(dp: Float)           // Swift reports the bar's height
}
Enter fullscreen mode Exit fullscreen mode

Swift implements NativeTabBarDelegate on an ObservableObject, so the bar's SwiftUI state is the Compose container's bottom-bar state. Compose publishes; the bar reflects. The bar reports taps; Compose routes them through the same function a Compose tab tap calls, emitting the same analytics event.

There's no mirrored back stack here, because there's no stack to mirror. The bar knows five strings and which one is selected. That's the entire contract.

And those are deliberately plain strings — "home", "plan" — not route class names. Anything that crosses a language boundary, lands in an analytics event, or gets written to disk is an API: a class name would shift the moment someone refactors or an obfuscator minifies a release build, silently breaking the contract or fragmenting your analytics. Give the things that cross a name you control.

A Native View Can Write Compose State

This is the part worth stealing even if you never touch a tab bar.

The bar floats, so its height varies — it slides away on a detail screen, it drops when the keyboard opens. Anything Compose lays out near the bottom (a floating action button, the last row of a list) has to know that height or it renders behind the bar. So the height has to travel from a native view into live Compose layout.

The move: make the bridge's height field observable — back it with mutableStateOf.

object NativeBarBridge {
    private val heightState = mutableStateOf(0.dp)
    val height: Dp get() = heightState.value          // read in composition -> subscribes
    fun setHeight(dp: Dp) { heightState.value = dp }   // the native side calls this
}
Enter fullscreen mode Exit fullscreen mode

Now any composable that reads height is subscribed to it, and when the native side calls setHeight the readers recompose — no callbacks, no event bus, no StateFlow plumbing. The Kotlin/native boundary disappears into Compose's snapshot system; a UIKit view is, in effect, writing Compose state. (One caveat: write from the main thread, which UIKit already does.)

Expose it to screens as a CompositionLocal that defaults to zero, so a screen just reads a value it doesn't have to understand:

val LocalExtraBottomInset = compositionLocalOf { 0.dp }
Enter fullscreen mode Exit fullscreen mode

A scaffold folds that into its content insets and pads a floating button with it, animated so the button rides the bar in and out instead of jumping. And because the default is zero, every one of those reads is inert on Android and on older iOS — which was the standing rule the whole feature was built to: shared code must not get worse for the platform that doesn't benefit.

What the Overlay Actually Cost

Everything above makes the design sound clean. It is — now. What it cost was three bugs, all of them the same bug wearing different clothes: a floating bar has a height, and that height is a number the rest of the app has to agree about.

Worth walking through, because if you build this, you will meet all three.

Trap 1 — who is allowed to reserve a system-bar inset.

For the glass to sample content, the app has to draw all the way to the bottom edge: the root container must stop reserving the home-indicator inset so content bleeds under the bar.

The subtlety is what that does to everything below it. Any nested scaffold that also reserves that inset — "just to be safe" — now either double-counts it, or (when the bar is hidden) reserves an inset that nothing above it did. Either way you get a dead strip of padding across the bottom of the screen. It'll be exactly the height of the home indicator, ~34pt, which is how you'll recognize it on sight.

The rule that fixes it is worth adopting even without a tab bar:

Only the root container reserves a system-bar inset. A screen-level scaffold never does.

On a consumption-aware Scaffold this costs nothing: when an ancestor consumes an inset, a descendant's contentWindowInsets already resolves it to zero. So excluding the navigation-bar inset in a nested scaffold is a no-op on the platforms where the root handles it, and the fix on the one platform where the root deliberately doesn't. One unconditional rule beats a conditional that has to know whether a bar is currently on screen.

The last-item clearance then goes where it belongs — a trailing Spacer(Modifier.navigationBarsPadding()), itself consumption-aware, so it collapses to zero wherever an ancestor already handled the inset and adds nothing on the platforms that don't need it.

(Trap inside the trap: only the windowInsetsPadding modifiers are consumption-aware. Read WindowInsets.navigationBars as a raw value and you get the full inset back, regardless of what any ancestor consumed. Hours disappear here.)

Trap 2 — a runtime value that flips a structural branch.

This is the best Compose lesson in the whole project, and it has nothing to do with iOS.

The bar hides over the keyboard, so when the keyboard opens, the height it reports drops to zero. Somewhere, a composable read that height and did the reasonable-looking thing:

// The bug, reduced to its essence.
if (extraInset > 0.dp) {
    Box(Modifier.padding(bottom = extraInset)) { content() }
} else {
    content()
}
Enter fullscreen mode Exit fullscreen mode

Tap a text field. The keyboard starts to open. The inset flips to zero. The branch flips with it — and because Compose keys nodes by position, those two content() calls are two different composition groups. Flipping the branch disposes the whole subtree and rebuilds it: the focused text field is destroyed, focus goes with it, and the keyboard closes on the very tap that opened it.

It only misfires when the value genuinely flips at runtime, so it stays hidden until exactly the wrong moment — a field near something that resizes, a layout that changes when a flag toggles.

The fix is one line of structure — make the wrapper unconditional and vary only its modifier:

Box(
    Modifier.padding(bottom = extraInset),   // 0.dp is not a special case
    propagateMinConstraints = true,          // preserve the constraints the bare slot had
) { content() }
Enter fullscreen mode Exit fullscreen mode

The rule, framed to apply anywhere:

A value that changes at runtime must never choose between structural branches that emit the same child.

If you catch yourself writing if (x) { Wrapper { content() } } else { content() }, you've scheduled a subtree teardown for whenever x flips — and it takes text state, scroll position, focus, and in-flight animations with it. Vary the modifier, not the structure.

Trap 3 — keyboard avoidance runs in opposite directions per platform.

Not strictly an overlay bug, but the overlay is how I found it, and it's the one most likely to catch you off guard.

On Android (edge-to-edge, insets model) the window doesn't resize, so the only thing that lifts a focused field is shrinking its scroll container — it needs imePadding(). On iOS, Compose Multiplatform lifts the whole scene until the focused element clears the keyboard, so that same imePadding() avoids the keys a second time: an empty band above the keyboard, and a flat background where the translucent keyboard should be revealing content.

Same modifier, correct on exactly one platform, and nothing in the build warns you. The durable fix is a small platform-branching helper instead of a bare imePadding() on scroll containers, so nobody has to remember the rule at 6pm on a Friday. Two more traps share the room: isImeVisible is Android-only in Compose Multiplatform (shared code compiles, the iOS build fails at link — derive visibility from the ime bottom inset instead), and an emulator with a hardware keyboard reports the IME as hidden, so keyboard tests pass for the wrong reason. (That split is a deep enough hole to be its own article — the last part of this series.)

None of these three are Liquid Glass bugs. They're boundary bugs — the standing cost of letting a native view own a strip of a screen that Compose is laying out around. The native bar itself is small. Teaching the rest of the app that the bottom edge had become negotiable is what took the work.

What This Costs

I gave up real things, and you should price them before copying this. The official native shell against this bar-only overlay, point by point:

  • Glass tab bar — both give you it.
  • Glass toolbar and nav bar — the shell gets those too; the overlay doesn't, because Compose still draws everything above the tab bar.
  • Native back-swipe — the shell inherits SwiftUI's; the overlay uses Compose's own transitions and predictive back.
  • Where navigation actually lives — the shell mirrors it into SwiftUI; the overlay keeps one shared NavHost and has nothing to mirror.
  • Deep links, auth guards, interrupting dialogs — the shell gives them a second, per-platform home; the overlay leaves them in shared Kotlin, untouched.
  • Cost to back out — unwind the shell, versus delete one if branch.

If your app's navigation is simple, take JetBrains' path — you get more native surface for less bridge. If your navigation carries authentication guards, deep-link replay, and interrupting dialogs that you have already made identical across platforms, the overlay buys you the chrome without putting any of that behavior on a second, platform-specific footing.

And price the inset work honestly. The bridge is small; the consequences of a floating bar are not. Budget for the three bugs above.

That's the whole decision. It isn't about glass; it's about who owns the back stack.

What It Costs to Turn Off

One boolean and an availability check:

if FeatureFlags.liquidGlassNav, #available(iOS 26.0, *) {
    LiquidGlassRootView()      // native glass shell
} else {
    ComposeView()              // the shared Compose bottom bar, exactly as before
}
Enter fullscreen mode Exit fullscreen mode

Delete the if branch and the feature is gone, with no archaeology left in shared code. iOS 18 users and Android never entered it. That property was designed in from the first commit, and it's the reason I was willing to ship a platform-specific navigation shell into a codebase whose entire thesis is shared behavior.

So Is It Still One App?

Yes — and it's the claim I'd defend hardest.

There is one NavHost. One container. Deep links resolve through the same shared router on both platforms; the public/private guard, the session-expired dialog, the pending-deep-link replay, and onboarding are the same Kotlin. Adding a screen is one declaration, and it works on iOS 26 with a glass bar, on iOS 18 with a Material bottom bar, and on Android — without anyone opening Xcode.

What the iOS 26 user gets is Apple's material, Apple's capsule, and Apple's slide behavior, on Apple's schedule — for a couple hundred lines of glue, none of which knows anything about what the app actually does.

Native feel was never a rendering problem. It's a question of where you draw the boundary — and, having drawn one, how little you're willing to move across it.


This is the opening piece of a six-part series on running Compose Multiplatform in production — next up, the rebuild that got this app from 848 shared lines to 93% shared, with every number counted from both codebases.

If you've adopted Liquid Glass in a CMP app — the official way or some third way I haven't thought of — I'd genuinely like to see it in the comments.

Drop a ❤️ if this was useful — it helps more developers find it.

Top comments (0)