Skip to content

Liquid Glass: tokens and building blocks

This is the materials chapter of the design guideline. Read §6 there first to decide whether a surface should be glass at all.

The bottom nav bar follows the iOS 26 App Store tab bar: a floating glass capsule, a lighter capsule behind the selected tab, and a lens you can drag across the tabs. The look is split into tokens (the numbers) and building blocks (widgets and painters), so other screens can adopt it one at a time without copying values.

Adopters so far: the nav bar, the Search page (the floating search capsule, the facet chips and both edge fades) and the search filter sheet.

lib/presentation/theme/glass_tokens.dart GlassTokens: every number
lib/presentation/widgets/glass/
glass_surface.dart GlassSurface, GlassRimPainter, paintGlassRim
glass_pill.dart GlassPill: glass look for a control, no blur
glass_chip.dart GlassChip: a tappable GlassPill with a label
glass_capsule.dart GlassCapsule: selection capsule, lens, spring
glass_edge_fade.dart GlassEdgeFade: the fade under floating chrome
lib/presentation/widgets/components/floating_nav_bar.dart the nav bar
lib/presentation/pages/search_screen.dart capsule, chips, fades
lib/presentation/widgets/search_filter/search_filter_sheet.dart glass sheet

A GlassSurface is five layers, drawn from the bottom up:

┌──────────────────────────────────────────────┐
5 child │ ⌂ ▶ ♪ ⌕ ◉ │ your content
4 rim + sheen │▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔│ bright top edge, faint top gradient
3 tint │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ navBar colour at 45 %
2 backdrop │▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│ whatever is behind: saturated ×1.7, blurred σ20
└──────────────────────────────────────────────┘
1 shadow ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ soft drop shadow, 8 px down

In widget terms:

DecoratedBox (shadow)
└─ ClipRRect (the shape)
└─ BackdropFilter (GlassTokens.backdropFilter: saturate, then blur)
└─ Opacity (GlassSurface.opacity) ← inside the filter, see "Rules"
└─ DecoratedBox (GlassTokens.tint)
└─ CustomPaint (GlassRimPainter)
└─ child

Saturating before blurring matters. A plain blur averages bright posters into a muddy grey, while the saturation boost keeps their colour bleeding through the glass, which is most of what makes it read as Liquid Glass rather than frosted plastic.

Everything lives in GlassTokens. Change a value there and every adopter follows.

Group Token Value What it controls
Material blurSigma 20 How soft the backdrop is
saturation 1.7 Colour boost before the blur (1 = none)
tintAlpha / tint 0.45 of appTheme.navBar How dark the glass is
Rim rimWidth 1 Hairline stroke width
rimTopAlpha → rimBottomAlpha 0.35 → 0.06 Light catching the top edge
sheenAlpha, sheenStop 0.07, 0.5 Faint gradient over the top half
Shadow shadowAlpha, shadowBlur, shadowOffset 0.28, 24, (0, 8) Lift off the content
Selection capsuleFillAlpha 0.14 The lighter capsule behind the selected item
capsuleRimTopAlpha → capsuleRimBottomAlpha 0.22 → 0.04 Its own rim
Lens lensFillAlpha, lensRimTopAlpha 0.08, 0.45 Clearer and brighter than the capsule while pressed
lensWidthScale 1.3 How much the capsule swells when pressed
lensContentScale 1.08 How much the item under the lens grows
liftDuration 140 ms Press / release swell
Motion spring stiffness 380, damping ratio 0.72 About a 4 % overshoot, visually settled in ~300 ms
stretchPerVelocity, maxStretch 0.025, 0.3 Jelly stretch while the capsule travels
stretchSquash 0.25 How much it flattens as it stretches
Edge fade edgeFadeAlpha, edgeFadeColor 0.75, black Darkening under floating chrome
edgeFadeOverhang 40 (.v) How far above the chrome it starts
Scroll edge scrollEdgeAlpha / scrollEdgeTint 0.9 of appTheme.navBar A pinned header over glass, once content scrolls under it
Hairline hairlineAlpha / hairline white at 0.12 Dividers on glass (sheet footer, scrolled header)
Shape stadium radius 999 Default fully-rounded shape

The alphas and durations are plain numbers. Lengths that should scale with the screen (the edge fade overhang, the nav bar’s metrics) get .v / .h applied where they are used, the same way the rest of the app uses sizer.

GlassSurface(
child: Padding(
padding: EdgeInsets.all(12.h),
child: Text('Now casting to Living Room TV'),
),
)
Parameter Default Notes
borderRadius GlassTokens.stadium Pass BorderRadius.circular(16) for a card-like panel
opacity 1 Fades the tint, rim and child, but not the blur (see Rules)
shadow true Turn it off for glass sitting on glass

It sizes to its child, so give it a size or let its child decide.

GlassRimPainter / paintGlassRim: just the edge light

Section titled “GlassRimPainter / paintGlassRim: just the edge light”

Use these when you already paint your own shape and only want the rim. GlassRimPainter is a CustomPainter for a box. paintGlassRim(canvas, rrect) draws onto any RRect inside a painter you already have.

GlassPill: glass for a control that sits on glass

Section titled “GlassPill: glass for a control that sits on glass”

A stadium that paints the tint, the sheen and the rim, and when selected, the white selection capsule on top. It has no BackdropFilter, so a row of twenty chips costs nothing extra. Use it for anything that already sits on a glass surface or on an edge fade: chips, tabs, the search field inside the filter sheet.

GlassPill(selected: isActive, child: …)
resting tint ─► sheen ─► rim 3 drawRRect calls
selected tint ─► capsule fill ─► capsule rim ─► sheen ─► rim 5 calls

A GlassPill wrapped in everything a chip needs: a label in AppText.label (textSecondary at rest, textPrimary w600 when selected), optional leading / trailing widgets, an ink ripple, a 44-point hit area around a 36-point pill, and one merged semantics node (button + label + selected).

GlassChip(
label: 'Drama +1',
selected: true,
onTap: openGenres,
trailing: const Icon(Icons.keyboard_arrow_down_rounded),
)
GlassChip(label: 'Clear All', foreground: AppColors.accentText, onTap: clear)

The facet chips on Search, the facet tabs, the option chips and the picked strip in the filter sheet are all GlassChip.

This is for anything with “one of N is selected”: tab bars, segmented controls, the filter chip rows. It has three static helpers:

// 1. Where to draw it
final rect = GlassCapsule.rect(
center: Offset((position + 0.5) * slotWidth, height / 2),
rest: Size(slotWidth - gap * 2, height - 8),
lensHeight: height - 2,
lift: liftController.value, // 0 = resting, 1 = pressed
stretch: GlassCapsule.stretch(positionController.velocity),
);
// 2. Drawing it
GlassCapsule.paint(canvas, rect, lift: liftController.value);
// 3. Moving it
positionController.animateWith(
GlassCapsule.glide(positionController.value, targetIndex, positionController.velocity),
);

The pattern the nav bar uses is worth copying:

position: one AnimationController.unbounded, in "slots"
(2.4 = between slot 2 and slot 3)
tap ──► glide(current, target) spring from where the eye sees it
press ──► lift 0 → 1 the capsule swells into the lens
drag ──► position = finger x / slotWidth the lens follows the finger
release ──► glide(position, round(position)) then report the choice
cancel ──► glide back to the selected slot, report nothing

Keeping the position as a single number, rather than “from index” and “to index”, is what lets a quick second tap retarget mid-flight without popping backwards. The spring picks up the current value and velocity.

The pill’s width gathers the way the iOS tab bar does: on phones it hugs its items — each cell sized to max(icon, widest label) + padding, clamped to the min/max cell metrics — and centres in the shell. When the labels are too long to fit even gathered, it falls back to spreading the full width, and wide screens keep their fixed itemMaxWidth cells. Either way the cells stay equal-width, because the capsule, the drag and the lens all tile equal slots from the track’s left edge.

GlassEdgeFade: legibility under floating chrome

Section titled “GlassEdgeFade: legibility under floating chrome”
Positioned(
left: 0, right: 0, bottom: 0,
child: GlassEdgeFade(height: 120, opacity: 1 - hideProgress),
)

It is a vertical gradient that ignores touches, and it goes behind the floating chrome. iOS 26 fades content under a tab bar rather than blurring it. Tie its opacity to the chrome’s visibility, or you are left with a dark band at the bottom and nothing on it (the nav bar does this through FloatingNavEdgeFade).

Pass top: true for a header: the gradient flips so it is darkest at the top. Pass solidExtent to hold scrollEdgeAlpha (0.9) across the band the header occupies, fading only over the overhang. At the plain 0.75, titles scrolled under the Search chips stayed readable behind them.

Search uses one behind its facet chips: solidExtent is the header extent, and its opacity follows the scroll offset over the first edgeFadeOverhang points. At rest nothing is under the header, so nothing is dimmed. Once content scrolls under, it fades in with the finger. The header band also absorbs taps, so nobody opens a poster they can’t see.

The shell’s fade paints above every tab. A tab that draws its own chrome near the bottom, as Search does with its capsule, must opt out in main_scaffold.dart and draw its own fade behind that chrome. Otherwise the shell’s fade darkens it.

An accessory is something that floats above the pill and follows it, like iOS 26’s tab-bar accessory. The Search capsule is the first one; a mini player would be the next.

final progress = FloatingNavScope.maybeProgressOf(context); // null outside the shell
ValueListenableBuilder<double>(
valueListenable: progress ?? const AlwaysStoppedAnimation(1),
builder: (_, drag, child) {
final p = progress == null ? 1.0 : FloatingNavBar.visualProgress(context, drag);
final bottom = math.max(
keyboard + AppSpace.sm.v,
FloatingNavBarMetrics.accessoryBottomOffset(context, p),
);
return Positioned(bottom: bottom, …, child: child!);
},
child: GlassSurface(…),
)
p = 0 ╭ accessory ╮ p = 1 (pill gone)
╰───────────╯ ← gap ╭ accessory ╮
╭── pill ───╮ ╰───────────╯ ← pill's old spot

floating_nav_bar_metrics_test.dart checks at every progress and inset that the accessory never overlaps the sliding pill.

Roughly in order of payoff:

  1. Floating chrome over scrolling content, such as a mini player or the cast control strip: wrap it in GlassSurface, and add a GlassEdgeFade behind it if it sits at the screen edge.
  2. “One of N” selectors, such as the home category chips (nav_chip_row.dart), segmented toggles and season pickers: paint a GlassCapsule behind the selected item and move it with GlassCapsule.glide.
  3. Sheets and dialogs over artwork: a GlassSurface with a BorderRadius.circular(…) instead of a solid appTheme.popUp fill. The filter sheet shows the whole pattern: shadow: false, a pinned header that is clear at rest and takes on scrollEdgeTint once content scrolls under it, and a footer on a hairline.

Not done yet: the filter sheet’s facet tabs are a one-of-N selector, so they could glide a GlassCapsule between tabs the way the nav bar does. Their widths vary, so the capsule would interpolate between measured rects rather than use equal slots.

Each step is self-contained. A screen that adopts the kit does not need any other screen to have adopted it.

When you adopt it somewhere, add a test like test/presentation/widgets/glass/glass_test.dart does for the kit: assert that the adopter uses GlassSurface or paints a GlassCapsule, not the exact pixel values. The kit’s own tests already pin those.

  • Never put an Opacity (or FadeTransition, AnimatedOpacity) above a BackdropFilter. It inserts a save layer between the filter and the backdrop it samples, so the blur misbehaves while fading. GlassSurface fades inside the filter for this reason, and glass_test.dart fails if that ever flips.
  • One BackdropFilter per surface, and not many on screen at once. Each one re-reads and blurs what is behind it every frame. A few floating surfaces are fine. Glass on every card in a scrolling list is not.
  • Keep a RepaintBoundary around animated glass. The nav bar wraps itself in one so its spring and lens frames do not repaint the feed.
  • The blur needs something behind it. Over a flat black background glass looks like a dark grey pill. That is expected, not a bug.
  • Reduce motion: when MediaQuery.disableAnimations is on, jump instead of springing, and snap the lift to 0 or 1. The nav bar shows how.
  • Android: the glass relies on Impeller, which is enabled in AndroidManifest.xml. Skia also draws it, but with more cost per frame.
  • test/presentation/widgets/glass/glass_test.dart covers the kit: the saturation matrix leaves greys grey, the filter order is saturate-then-blur, the rim follows the radius, the fade sits inside the frost, the capsule swells and stretches, and the spring overshoots only a little.
  • test/presentation/widgets/components/floating_nav_bar_test.dart covers the nav bar: the spring and its retarget, the lens press, drag, cancel and second finger, semantics, and typography.
  • test/presentation/pages/home_pages/main_scaffold_test.dart covers the edge fade in the shell: present on Home, absent on Bytes and Search, gone when the bar hides. It also checks that tabs sit under a FloatingNavScope.
  • test/presentation/pages/search_screen_test.dart → “the layout”: the capsule docks above the pill, drops into its place, rides the keyboard, never sits flush on the edge, is the page’s only blur, and results scroll clear of it.
  • test/presentation/widgets/search_filter_sheet_test.dart → “its glass”: one frosted surface, the scroll-edge header, and selected glass chips with merged semantics.