Tutorial · 18 min · intermediate

Liquid Glass, the real one

The iOS 26 material as a widget — interactive glass with Dart children, the fluid merge, regular vs clear, under a clear large-title bar.

What you'll build — running on device.

Liquid Glass is iOS 26’s defining material — and the clearest case for native rendering. It refracts what’s behind it, flexes under touch, and fuses with neighboring glass; none of that can be faked on a Flutter canvas. In DartNative it’s just a widget, because your widgets already are native views. You’ll build two screens: one that puts the material through its signature moves under a frosted bar, and a pushed screen with the fully clear large-title bar.

What you need

  • A project from Your first DartNative app
  • An iOS 26 device to see the live material (everywhere else the same code renders a graceful translucent fallback — isIOS26 lets you annotate when you care)

Step 1 — A glass capsule with your widgets inside

The atom of this screen is a small builder that wraps an icon in glass:

Widget _glassCircle(IconData icon) => GlassEffectContainer(
      borderRadius: BorderRadius.circular(32),
      interactive: true,
      tint: const Color(0x22FFFFFF),
      brightness: playgroundPalette.brightness,
      child: SizedBox(
        width: 64,
        height: 64,
        child: Center(
          child: Icon(icon, size: 26, color: kTextPrimary),
        ),
      ),
    );

GlassEffectContainer is a real UIGlassEffect view — and its child is an ordinary Dart subtree rendered inside the glass, not behind it. The tint is a translucent wash over the material (here a whisper of white), and brightness tells the glass which appearance to render — here it follows the demo’s active palette (dark by default), so the capsule asks for the dark variant to match its dark stage. playgroundPalette and the k* colors come from the shared UI kit in lib/screens/home/demo_ui.dart.

Step 2 — The fluid merge

Wrap two capsules in a GlassEffectGroup and animate the gap between them:

_stage(
  height: 170,
  child: GlassEffectGroup(
    spacing: 40,
    child: Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        _glassCircle(CupertinoIcons.camera_fill),
        AnimatedContainer(
          duration: const Duration(milliseconds: 550),
          curve: Curves.easeInOut,
          width: _together ? 10 : 72,
        ),
        _glassCircle(CupertinoIcons.plus),
      ],
    ),
  ),
),

Tap “Bring together” and the capsules glide toward each other — and the system fuses them into one blob the moment they come within spacing of touching. The AnimatedContainer only animates the gap; the melt-together is entirely the OS’s, computed by the compositor from the real views. It works across your own Dart layout — the capsules are ordinary children of an ordinary Row.

About _stage: glass is invisible over flat black — it needs colorful content behind it to refract, so the demo places every capsule on a vivid gradient “stage” with off-center highlights. The lensing is the whole show; in a real app, glass earns its keep over photos, maps, and colorful content.

Step 3 — Interactive vs static

interactive: true adds the system’s gel response. The demo makes the difference unmistakable with an A/B pair — same glass, same content, only interactive differs:

_stage(
  height: 120,
  child: Row(
    mainAxisSize: MainAxisSize.min,
    children: [
      _glassPill('Interactive', interactive: true),
      const SizedBox(width: 20),
      _glassPill('Static', interactive: false),
    ],
  ),
),

Press and hold: the interactive pill brightens and flexes under your finger; the static one stays inert. The pill builder gives the interactive one a warm tint so the press response reads clearly:

tint: interactive ? const Color(0x44FFD60A) : const Color(0x1AFFFFFF),

The labels inside are Dart Text widgets — and the same touch that drives the gel response still reaches your Dart handlers. One touch, both worlds; there’s no platform-view boundary to fall off.

Step 4 — Regular vs clear

The material comes in two system styles, one parameter apart:

_stage(
  height: 120,
  child: Row(
    mainAxisSize: MainAxisSize.min,
    children: [
      _stylePill('Regular', GlassStyle.regular),
      const SizedBox(width: 20),
      _stylePill('Clear', GlassStyle.clear),
    ],
  ),
),

GlassStyle.regular is the standard frosted glass; GlassStyle.clear is the more transparent variant Apple uses for clear-glass buttons over media. Inside the builder it’s just style: style on the same GlassEffectContainer.

Step 5 — A frosted bar is one alpha away

The bar surface is the other half of the iOS 26 look, and it’s a single knob: the alpha of backgroundColor. An opaque color is a solid bar; a translucent color routes to the native frosted treatment — system blur plus your tint. The demo’s bar colors come from the UI kit’s palette, whose dark variant sets barBgGlass to Color(0xD91C1C1E):

/// iOS 26 glass variant of [barBg]: alpha < 1 opts the AppBar into the real
/// Liquid Glass material — the bar keeps its tint while content scrolling
/// behind it stays faintly visible through the frost. Raise the alpha for a
/// more opaque bar.
final Color barBgGlass;
extendBodyBehindAppBar: isIOS26,
appBar: AppBar(
  title: Text('Glass & Fluid Merge', /* ... */),
  backgroundColor: isIOS26 ? kBarBgGlass : kBarBg,
),

extendBodyBehindAppBar matters: the frost is a live material reacting to your content scrolling beneath it, so the body has to run under the bar for it to have anything to act on. Pre-26 (and on Android) the same AppBar takes the solid color instead.

Step 6 — The clear large-title bar

The third surface is no backgroundColor at all. The second screen, LargeTitleDemo, is built around it — a light-themed chats list under the fully clear bar (the launcher pushes it with Navigator.push and a PageRoute, and the glass rides the transition):

Scaffold(
  // Body extends under the bar so the scroll-edge ramp has content to
  // act on (a body that starts below the bar clips at its own edge).
  extendBodyBehindAppBar: true,
  appBar: AppBar(
    title: const Text('Chats', /* fontSize 17 */),
    subtitle: _showSubtitle
        ? Text('${_chats.length} chats', /* ... */)
        : null,
    actions: [
      BarButtonItem(
        fontIcon: CupertinoIcons.info_circle,
        titleStyle: const TextStyle(color: Color(0xFF111111)),
        onPressed: () => setState(() => _showSubtitle = !_showSubtitle),
      ),
    ],
    largeTitle: const Text('Chats', /* fontSize 34, bold */),
    // NO backgroundColor → the fully CLEAR iOS 26 bar: glass lives on
    // the controls, the surface stays see-through, and the system
    // scroll-edge ramp (not a frost sheet) keeps the region legible.
  ),
  body: ListView(
    padding: EdgeInsets.only(
      top: padTop + (isIOS26 ? 108 : 56),
      bottom: 40,
    ),
    // ...
  ),
)

Scroll and the large title slides under the bar and fades while the small title fades in — the native collapse, tracking your finger, driven by an ordinary Dart ListView. Rows passing beneath stay visible through the system’s progressive blur ramp. Because the body extends behind the bar, the list pads its top by the status inset plus the bar band — isIOS26 ? 108 : 56 covers the large-title band on iOS 26 and the composed-header fallback everywhere else.

Two garnishes worth stealing: the ⓘ BarButtonItem toggles a live bar subtitle fed by plain Dart state, and since this screen is deliberately light (the edge ramp needs a light surface to read), its initState pins dark status icons with SystemChrome.setSystemUIOverlayStyle — the Navigator restores the previous style on pop.

Why this is native

Flutter plugins can host glass only as a platform-view island: the island’s content must itself be native — your widgets can’t live inside it — and the sandwich leaks (the plugins document modal bleed and glass halos escaping during transitions). Here there is no island. The capsules are ordinary parents in one native hierarchy: your widgets render inside the glass, one tap drives the gel response and your Dart handler together, and the merge spans your own layout. The same goes for the bar: the audited plugins show the native large-title collapse only over native list content — here the Dart list is a native scroll view, so the collapse and the edge ramp just work. No seams to hide.

The finished code

In the public repo the two screens — lib/screens/liquid_glass/glass_merge_demo.dart (the material) and lib/screens/liquid_glass/large_title_demo.dart (the clear bar) — are byte-identical copies of the playground’s Liquid Glass demos, with the playground’s shared UI kit (lib/screens/home/demo_ui.dart) carried verbatim alongside; lib/main.dart adds only a thin two-row launcher. When the playground screens improve, this tutorial inherits it by copying the files again. dn create ., dn run.

Open the finished code →