Liquid Glass: tokens and building blocks
Liquid Glass: tokens and building blocks
Section titled “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 numberlib/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 chromelib/presentation/widgets/components/floating_nav_bar.dart the nav barlib/presentation/pages/search_screen.dart capsule, chips, fadeslib/presentation/widgets/search_filter/search_filter_sheet.dart glass sheetWhat a glass surface is made of
Section titled “What a glass surface is made of”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 downIn 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) └─ childSaturating 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.
The tokens
Section titled “The tokens”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.
The building blocks
Section titled “The building blocks”GlassSurface: any floating panel
Section titled “GlassSurface: any floating panel”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 callsGlassChip: the chip everyone should use
Section titled “GlassChip: the chip everyone should use”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.
GlassCapsule: a sliding selection
Section titled “GlassCapsule: a sliding selection”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 itfinal 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 itGlassCapsule.paint(canvas, rect, lift: liftController.value);
// 3. Moving itpositionController.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 nothingKeeping 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.
Floating accessories above the nav bar
Section titled “Floating accessories above the nav bar”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 shellValueListenableBuilder<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 spotfloating_nav_bar_metrics_test.dart checks at every progress and inset that
the accessory never overlaps the sliding pill.
Adopting it incrementally
Section titled “Adopting it incrementally”Roughly in order of payoff:
- Floating chrome over scrolling content, such as a mini player or the
cast control strip: wrap it in
GlassSurface, and add aGlassEdgeFadebehind it if it sits at the screen edge. - “One of N” selectors, such as the home category chips
(
nav_chip_row.dart), segmented toggles and season pickers: paint aGlassCapsulebehind the selected item and move it withGlassCapsule.glide. - Sheets and dialogs over artwork: a
GlassSurfacewith aBorderRadius.circular(…)instead of a solidappTheme.popUpfill. The filter sheet shows the whole pattern:shadow: false, a pinned header that is clear at rest and takes onscrollEdgeTintonce content scrolls under it, and a footer on ahairline.
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.
Rules and traps
Section titled “Rules and traps”- Never put an
Opacity(orFadeTransition,AnimatedOpacity) above aBackdropFilter. It inserts a save layer between the filter and the backdrop it samples, so the blur misbehaves while fading.GlassSurfacefades inside the filter for this reason, andglass_test.dartfails if that ever flips. - One
BackdropFilterper 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
RepaintBoundaryaround 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.disableAnimationsis 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.
How it is tested
Section titled “How it is tested”test/presentation/widgets/glass/glass_test.dartcovers 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.dartcovers 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.dartcovers 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 aFloatingNavScope.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.