Liquid Glass Easy

Adaptivity

Glass that reads what is behind it and changes its mind.

A floating bar over a photo has one hard problem: the photo. White icons vanish over a beach and black icons vanish over a night sky. Adaptivity is the answer iOS uses — the glass tint and the content colour switch between two palettes depending on whether the background behind that particular lens is dark or light, and both animate on every flip.

Dart
LiquidGlassLens(
  style: const LiquidGlassStyle(
    adaptivity: LiquidGlassAdaptivity(),
  ),
  child: const Icon(Icons.favorite),   // follows the verdict
)

The child is wrapped in an IconTheme and a DefaultTextStyle carrying the active content colour, so any Icon or Text that does not hardcode its own colour adapts by itself.

Something has to read the pixels#

That config on its own decides what to do with a verdict, not where the verdict comes from. Nothing reads pixels until something opens the sampler, and there are exactly two things that do — a scaffold, or a view:

Dart
// 1 — the scaffold opens it for you
LiquidGlassScaffold(
  adaptivity: const LiquidGlassScaffoldAdaptivity(),
  body: myPage,
  bottomBar: myTabBar,        // chrome adapts, OS bar icons follow
)

// 2 — a view, for glass you place yourself
LiquidGlassView(
  adaptiveSampling: const LiquidGlassAdaptiveSampling(),
  backgroundWidget: const MyPhoto(),
  child: myGlassUI,
)

// 3 — neither: it still runs, it just cannot look
LiquidGlassLens(
  style: const LiquidGlassStyle(adaptivity: LiquidGlassAdaptivity()),
  child: const Icon(Icons.favorite),
)   // → falls back to the app theme's brightness, and says so once

All three are valid. Inside a scaffold, adaptivity is the switch and a descendant cannot flip it — a tab bar carrying its own style.adaptivity will not start captures on a scaffold that did not ask for them. Inside a view, adaptiveSampling is the switch. With neither, every adaptive surface still resolves a verdict and still paints a palette; it simply resolves it from the bottom of the chain below, which in practice means one palette forever. The package prints a one-time warning naming the reason, so a page that is frozen on the wrong palette is diagnosable rather than mysterious.

The three ways to use it#

  • Real time, everywhere. Put a LiquidGlassScaffold with adaptivity, or a LiquidGlassView with adaptiveSampling, above the page. Every adaptive lens under it then judges the pixels behind itself and flips on its own, live, as the background scrolls or changes — you wire nothing per lens.
  • One lens, its own way. Give that lens a style.adaptivity of its own and it keeps the shared verdict but uses your palettes, your thresholds, your duration.
  • Not at all. LiquidGlassAdaptivity.none on a surface opts it out completely, even inside an area or a scaffold that adapts everything else: it keeps its plain appearance.color and no palette is installed over its child.
  • Pinned, with no sampling at all. permanentBrightness when you already know what is behind the glass and it is not going to change — a lens on a background you authored, or over a photo you shipped. It is not the app’s light/dark mode: it is you choosing which of the two palettes this lens wears, permanently. This one needs no scaffold, no view and no sampler — nothing reads pixels for it, because the answer was given.
Dart
// pinned: this lens is always on the dark palette, and never samples
LiquidGlassLens(
  style: const LiquidGlassStyle(
    adaptivity: LiquidGlassAdaptivity(
      permanentBrightness: Brightness.dark,
    ),
  ),
  child: const Icon(Icons.favorite),
)

Where the verdict comes from#

Where the verdict comes from#

In priority order, and the first one that answers wins:

  • permanentBrightness — a manual verdict that never yields, and the only one that stops captures for that surface. Use it when you already know what is behind the glass, or to drive the palette from your own state.
  • A followed `link` — on a consumer, a link always means follow it.
  • The enclosing `LiquidGlassAdaptiveArea` — descendants follow it with no link needed.
  • Its own sampling, when the surface sits under an open sampler: the view takes a second, tiny capture of its background, converts the pixels to perceptual CIE L* lightness, and classifies that surface’s own bounds with smoothing and hysteresis.
  • initialBrightness — a guess, not a verdict. It holds only until sampling produces something real, then yields: a matching guess means nothing moves on entry, a wrong one animates into the truth.
  • brightnessFallback — the bottom of the chain, for a surface that cannot read pixels at all. appTheme (the default) reads Theme.of(context).brightness; platform reads MediaQuery.platformBrightness, the OS switch, which MaterialApp.themeMode does not affect.

The palettes#

FieldDefaultWhat it does
glassColorOnDark0x26000000Glass tint while the background behind the lens is dark.
contentColorOnDarkwhiteIcon and text colour over a dark background.
glassColorOnLight0x3DFFFFFFGlass tint over a light background.
contentColorOnLight0xFF1C1C1EIcon and text colour over a light background.
duration300msHow long a flip animates.
initialBrightnessnullWhat to assume before the first verdict arrives — your guess at what the widget mounts over.
continuousGlassColorfalseGlides the tint through the whole range instead of switching between the two ends.

One verdict for a whole cluster#

Five adaptive lenses in a row each sample their own patch, and over a busy photo they can disagree — one flips while its neighbour does not, and the row shimmers. LiquidGlassAdaptiveArea fixes that: an invisible region that paints nothing, samples its own bounds once, and hands the single verdict to every adaptive descendant. One region, one answer, flips in lockstep.

Dart
LiquidGlassAdaptiveArea(
  adaptivity: const LiquidGlassAdaptivity(),
  systemChrome: LiquidGlassSystemChrome.statusBar,
  child: Row(children: myGlassActions),
)

A descendant with no adaptivity of its own inherits the area's palettes entirely. A descendant that has its own keeps its palettes but still follows the area's verdict — unless it carries an explicit permanentBrightness, because manual always wins, or its own link, because an explicit channel beats an ambient one.

Some followers cannot be in the area's subtree: a different branch of the tree, or a component with a render pipeline of its own — LiquidGlassTabBar in glass-pill mode. A LiquidGlassAdaptivityLink is the channel between them. On an area a link means publish; on a surface it means follow.

Dart
final link = LiquidGlassAdaptivityLink();

LiquidGlassAdaptiveArea(                         // PUBLISHER
  adaptivity: myPalettes.copyWith(link: link),
  child: MyHeader(),
)

LiquidGlassLens(                                 // FOLLOWER, anywhere
  style: LiquidGlassStyle(
    adaptivity: myPalettes.copyWith(link: link),
  ),
  child: myAction,
)

Use one publisher per link. A follower never samples — it mirrors, flipping on the same frame — so give publisher and followers the same duration and the transitions run in lockstep. The link is a plain ValueNotifier<Brightness?>, so your app can read it, or drive a whole group by setting value itself; it also carries a lightness notifier that followers running continuousGlassColor glide from. An area given no link still works — it mints a private one for its own subtree.

Holding still, and adapting on cue#

Sampling every frame of a fling is wasted work, and palettes flipping mid-scroll can look busy. LiquidGlassAdaptivityController pauses a whole group and lets you take a single look on your own cue — a scroll settling, a page entering. adaptOnce() gives every widget holding the controller exactly one look: it samples, animates to the result, and freezes again, with enabled still false throughout. enable() and disable() toggle continuous adaptation.

Dart
final adaptCtrl = LiquidGlassAdaptivityController(enabled: false);

NotificationListener<ScrollEndNotification>(
  onNotification: (_) { adaptCtrl.adaptOnce(); return false; },
  child: myFeed,
)

Bare icons and text#

A lens installs the adaptive colour for its own child only, so a hero title sitting straight on a photo has no way to follow along. LiquidGlassAdaptiveContent closes that gap — same verdict machine, same animated IconTheme and DefaultTextStyle, no glass.

Dart
LiquidGlassAdaptiveContent(
  adaptivity: const LiquidGlassAdaptivity(),
  child: Text('Reykjavík', style: TextStyle(fontSize: 34)),
)

With adaptivity left null it inherits the enclosing area's config entirely. And for content that ignores IconTheme and DefaultTextStyle — an SvgPicture, a CustomPaint, a halo that has to invert with the ink it sits under — it takes a `builder` instead, handing you the colour and the verdict directly:

Dart
LiquidGlassAdaptiveContent(
  builder: (context, color, brightness) => SvgPicture.asset(
    'assets/logo.svg',
    colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
  ),
)

Comments

Comments are GitHub Discussions — reply from either place.