# Liquid glass in Flutter: Impeller shader and widget

Source: https://meniscus.site/liquid-glass/flutter · Updated 2026-10-01

`BackdropFilter` with a blur gives Flutter frost, not liquid glass. The bend needs a fragment shader applied with `ImageFilter.shader`, which Impeller supports on iOS and Android; everywhere else the same widget falls back to blur.

This is the Flutter recipe the Meniscus MCP server gives coding agents: the shader, the widget that feeds it the body's rect in device pixels, grouping, and the fallback. It analyzes clean on Flutter 3.44 and renders aligned under Impeller.

Ask your assistant (with Meniscus connected): "Add a liquid glass bottom navigation bar to this Flutter app that refracts the photo behind it, not just blurs it. Use the meniscus tools."

**In short:** `ClipRSuperellipse` + `BackdropFilter` per glass body; refraction through `ImageFilter.shader` (Impeller).

**Limits:** the bend needs Impeller (`ImageFilter.shader` on iOS and Android; check `ImageFilter.isShaderFilterSupported`); elsewhere `BackdropFilter` blur gives frost only. Spec numbers are logical px, but the shader works in device px from the screen origin: multiply the body's global rect, radius, bezel and displacement by the device pixel ratio.

Structure per glass body:

```dart
import 'dart:ui';                                        // ImageFilter
import 'package:flutter/material.dart';

ClipRSuperellipse(
  borderRadius: BorderRadius.circular(24),
  child: BackdropFilter(
    filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),     // frost; refraction version below
    child: DecoratedBox(
      decoration: ShapeDecoration(
        color: Colors.white.withValues(alpha: .15),       // fill
        shape: RoundedSuperellipseBorder(
          borderRadius: BorderRadius.circular(24),
          side: BorderSide(color: Colors.white.withValues(alpha: .45), width: 1)),
      ),
      child: child,                                        // flat content
    ),
  ),
)
```

- Wrap it in a `DecoratedBox` with the ambient `BoxShadow` and paint the directional rim with a `CustomPainter`
  (`Paint()..shader = LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [white 96 %,
  white 20 %, white 20 %, white 56 %]).createShader(rect)`, stroke width 1) over the shape, and the edge line as a
  second 1 px stroke just outside it (`BorderSide(color: black 12 %, dark theme 32 %, strokeAlign:
  BorderSide.strokeAlignOutside)`).
- Keep children flat, gaps ≥ 8 px, and one `BackdropFilter` per glass body (each is a render pass). Bodies that share
  one backdrop (a tab bar and its search lens) read it once inside a `BackdropGroup` with `BackdropFilter.grouped`.

**Refraction** — `BackdropFilter(filter: ImageFilter.shader(...))` with this port of the web shader (Impeller only).
Register it in `pubspec.yaml` (`flutter: shaders: - shaders/glass.frag`). Built into an Android APK (Vulkan stage)
and probed under Impeller on Flutter 3.44.2.

```glsl
// shaders/glass.frag
#include <flutter/runtime_effect.glsl>
precision highp float;

uniform vec2 uRes;               // set by the engine: backdrop size, device px
uniform vec4 uRect;              // glass body: x, y, w, h in device px from the screen origin
uniform float uRadius, uBezel, uDisp, uFrost, uProfile;   // device px; profile 0 bevel, 1 convex, 2 rim
uniform sampler2D uBackdrop;     // set by the engine: the backdrop

out vec4 fragColor;

float sdRoundBox(vec2 p, vec2 b, float r) { vec2 q = abs(p) - b + r; return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; }
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
vec3 tex(vec2 px) {
  vec2 uv = clamp(px / uRes, 0.0, 1.0);
#ifdef IMPELLER_TARGET_OPENGLES
  uv.y = 1.0 - uv.y;
#endif
  return texture(uBackdrop, uv).rgb;
}

float bendAt(float s) {                                                 // shift across the bezel, edge = 1
  float a = 1.0 - s;
  if (uProfile > 0.5 && uProfile < 1.5) return a * (1.0 + a) * 0.5;    // convex dome
  if (uProfile > 1.5) { float u = max(1.0 - s / 0.35, 0.0); return u * u; } // rim: outer 35 %
  return a * a;                                                         // bevel
}

void main() {
  vec2 px = FlutterFragCoord().xy;
  vec2 c = uRect.xy + uRect.zw * 0.5, hb = uRect.zw * 0.5;
  float d = sdRoundBox(px - c, hb, uRadius);
  vec3 back = tex(px), col = back;
  if (d < 1.0) {
    float depth = clamp(-d / uBezel, 0.0, 1.0);
    vec2 e = vec2(0.5, 0.0);
    vec2 n = normalize(vec2(sdRoundBox(px - c + e.xy, hb, uRadius) - sdRoundBox(px - c - e.xy, hb, uRadius),
                            sdRoundBox(px - c + e.yx, hb, uRadius) - sdRoundBox(px - c - e.yx, hb, uRadius)));
    float bend = depth < 1.0 ? bendAt(depth) : 0.0;
    vec2 off = -n * bend * uDisp;                                       // inward
    off += (vec2(hash(px), hash(px + 17.0)) - 0.5) * 1.5 * uFrost;
    float k = 0.02 * bend * bend;                                       // dispersion stays at the rim
    vec3 bent = vec3(tex(px + off * (1.0 - k)).r, tex(px + off).g, tex(px + off * (1.0 + k)).b);
    vec3 blur = (tex(px + off + vec2(2.0, 0.0)) + tex(px + off - vec2(2.0, 0.0)) +
                 tex(px + off + vec2(0.0, 2.0)) + tex(px + off - vec2(0.0, 2.0))) * 0.25;
    vec3 g = mix(bent, blur, 0.35 * uFrost);
    g = mix(g, vec3(dot(g, vec3(0.299, 0.587, 0.114))), -0.4);          // saturation ×1.4
    g = mix(g, vec3(1.0), 0.10);                                        // fill
    vec2 L = normalize(vec2(-1.0, -1.0));                               // key light, top-left
    float facing = dot(n, L);
    float rim = 1.0 - smoothstep(0.0, 1.5, -d);
    g += rim * mix(0.5, 0.9, max(facing, 0.0)) * (facing > 0.0 ? 1.0 : 0.55);
    g += (1.0 - smoothstep(0.0, 0.35, depth)) * max(facing, 0.0) * 0.18;
    col = mix(back, g, clamp(0.5 - d, 0.0, 1.0));
  }
  fragColor = vec4(col, 1.0);
}
```

```dart
import 'dart:ui' as ui;
import 'package:flutter/material.dart';

ui.FragmentProgram? _glass;                         // await loadGlass() before runApp
Future<void> loadGlass() async => _glass ??= await ui.FragmentProgram.fromAsset('shaders/glass.frag');

class RefractiveGlass extends StatefulWidget {
  const RefractiveGlass({super.key, required this.radius, required this.child, this.bezel = 22,
      this.displacement = 9, this.frost = 0.3, this.profile = 0, this.fallbackSigma = 4, this.grouped = false});
  final double radius, bezel, displacement, frost, profile;  // logical px: bezel ≤ radius, displacement 0.4 × bezel
                                                             // (bevel) or 0.5 × radius (convex circle)
  final double fallbackSigma;   // blur where the shader is unavailable: lens 2, regular 4, frost 10
  final bool grouped;           // inside a BackdropGroup: share one backdrop read with the other bodies
  final Widget child;

  @override
  State<RefractiveGlass> createState() => _RefractiveGlassState();
}

class _RefractiveGlassState extends State<RefractiveGlass> {
  Rect? _rect;                                      // this body on screen, device px
  bool _pending = false;

  // The shader needs where the body is, which is known only after layout: measure after the frame, rebuild on change.
  void _measure(Duration _) {
    _pending = false;
    final box = mounted ? context.findRenderObject() as RenderBox? : null;
    if (box == null || !box.hasSize) return;
    final dpr = MediaQuery.devicePixelRatioOf(context);
    final rect = (box.localToGlobal(Offset.zero) * dpr) & (box.size * dpr);
    if (rect != _rect) setState(() => _rect = rect);
  }

  @override
  Widget build(BuildContext context) {
    if (!_pending) {
      _pending = true;
      WidgetsBinding.instance.addPostFrameCallback(_measure);
    }
    final dpr = MediaQuery.devicePixelRatioOf(context), r = _rect, w = widget;
    ui.ImageFilter filter = ui.ImageFilter.blur(sigmaX: w.fallbackSigma, sigmaY: w.fallbackSigma);   // frost fallback
    if (_glass != null && ui.ImageFilter.isShaderFilterSupported && r != null) {
      final shader = _glass!.fragmentShader();
      var i = 2;                                    // floats 0–1 are uRes, set by the engine
      for (final v in [r.left, r.top, r.width, r.height, w.radius * dpr, w.bezel * dpr, w.displacement * dpr, w.frost, w.profile]) {
        shader.setFloat(i++, v);
      }
      filter = ui.ImageFilter.shader(shader);
    }
    final body = w.grouped ? BackdropFilter.grouped(filter: filter, child: w.child) : BackdropFilter(filter: filter, child: w.child);
    return ClipRSuperellipse(borderRadius: BorderRadius.circular(w.radius), child: body);
  }
}

// A tab bar and its search lens reading one backdrop: separate bodies that never overlap.
BackdropGroup(
  child: Row(children: [
    Expanded(child: RefractiveGlass(radius: 30, grouped: true, child: tabs)),
    const SizedBox(width: 12),
    SizedBox.square(dimension: 60,                              // a circle: bezel = radius, displacement 0.5 × radius
        child: RefractiveGlass(radius: 30, bezel: 30, displacement: 15, profile: 1, grouped: true, child: searchIcon)),
  ]),
)
```

- Uniforms are `float`, `vecN` and `sampler2D` only: an `int` passes the Metal stage and fails the Android Vulkan
  stage (`Non-floating-type struct member`). Porting from WebGL: `varying`/`gl_FragCoord` → `FlutterFragCoord()`,
  `texture2D` → `texture`, `gl_FragColor` → an `out vec4`.
- The first frame is frost; the measured rect arrives one frame later. A body that moves without rebuilding (it
  scrolls, or an ancestor animates its position) keeps its old rect until it rebuilds, so rebuild it from the scroll
  or animation listener. Content scrolling under a fixed bar needs nothing.

**Liquid metal accent** (not glass; one metal element per view): a sweep-gradient rim painted over an opaque face,
`CustomPaint(foregroundPainter: MetalRim(), child: …)`.

```dart
class MetalRim extends CustomPainter {
  MetalRim({this.width = 3, this.turn = 0});
  final double width, turn; // turn 0..1; animate slowly (about 12 s per turn), hold still under reduced motion

  @override
  void paint(Canvas canvas, Size size) {
    final rect = Offset.zero & size;
    final rrect = RRect.fromRectAndRadius(rect.deflate(width / 2), Radius.circular(size.height / 2));
    canvas.drawRRect(
      rrect,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = width
        ..shader = SweepGradient(
          transform: GradientRotation((200 / 360 + turn) * 2 * math.pi),
          colors: const [Color(0xFF14151A), Color(0xFFF7F8FC), Color(0xFF8A8D96), Color(0xFFFFFFFF),
                         Color(0xFF2A2B30), Color(0xFFD9DBE2), Color(0xFF14151A), Color(0xFFF7F8FC)],
          stops: const [0.0, 0.10, 0.30, 0.45, 0.55, 0.70, 0.81, 0.92],
        ).createShader(rect),
    );
  }

  @override
  bool shouldRepaint(MetalRim old) => old.turn != turn || old.width != width;
}
```

- `import 'dart:math' as math;` for `math.pi` and `import 'package:flutter/widgets.dart';`.
- Flowing bands and red/blue fringes: port the web `liquidMetal` shader to a `FragmentProgram`
  (`#include <flutter/runtime_effect.glsl>`, `FlutterFragCoord()`) and paint the shape with
  `Paint()..shader = program.fragmentShader()`; it samples nothing behind, so no `BackdropFilter`. Replace its
  `discard` with `fragColor = vec4(0.0); return;` — SkSL rejects `discard`, so the shader would not load on Skia.

**Border beam** (one beamed element per view, `border-beam` effect spec): `PathMetric.extractPath` along the shape's
outline, so the beam keeps one speed and length on any `ShapeBorder`. Analysed and golden-rendered on Flutter 3.44.

```dart
import 'dart:math' as math;
import 'dart:ui' as ui;

import 'package:flutter/widgets.dart';

/// A short arc of light travelling along [shape] at constant speed
/// (PathMetric slices, not a rotating gradient).
class BorderBeam extends StatefulWidget {
  const BorderBeam({
    super.key,
    required this.shape,
    required this.child,
    this.lap = const Duration(seconds: 5), // 4–6 s buttons, 6–10 s cards, 2–3 s while working
    this.tail = 0.25, // fraction of the perimeter
    this.width = 1.5, // = rim width
    this.color = const Color(0xFFFFFFFF), // accent on light glass
    this.bloom = 4, // blur sigma of the glow copy; 0 = none
    this.starts = const [0.0], // [0.0, 0.5] for twin beams
  });

  final ShapeBorder shape;
  final Widget child;
  final Duration lap;
  final double tail, width, bloom;
  final Color color;
  final List<double> starts;

  @override
  State<BorderBeam> createState() => _BorderBeamState();
}

class _BorderBeamState extends State<BorderBeam> with SingleTickerProviderStateMixin {
  late final AnimationController _lap = AnimationController(vsync: this, duration: widget.lap);

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (MediaQuery.disableAnimationsOf(context)) {
      _lap.stop();
    } else if (!_lap.isAnimating) {
      _lap.repeat();
    }
  }

  @override
  void dispose() {
    _lap.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (MediaQuery.disableAnimationsOf(context)) return widget.child; // reduced motion: no beam
    return CustomPaint(foregroundPainter: _BeamPainter(_lap, widget), child: widget.child);
  }
}

class _BeamPainter extends CustomPainter {
  _BeamPainter(this.lap, this.beam) : super(repaint: lap);
  final Animation<double> lap;
  final BorderBeam beam;
  ui.PathMetric? _outline;
  Size? _size;

  @override
  void paint(Canvas canvas, Size size) {
    if (size != _size) {
      _size = size; // the outline only changes with the size
      _outline = beam.shape.getOuterPath((Offset.zero & size).deflate(beam.width / 2)).computeMetrics().first;
    }
    final outline = _outline!;
    if (beam.bloom > 0) {
      // One blur pass for all slices: a layer blurred on restore.
      final glow = Paint()
        ..imageFilter = ui.ImageFilter.blur(sigmaX: beam.bloom, sigmaY: beam.bloom)
        ..color = const Color(0xCC000000); // layer opacity 80 %
      canvas.saveLayer((Offset.zero & size).inflate(beam.bloom * 3), glow);
      _slices(canvas, outline, beam.width * 2);
      canvas.restore();
    }
    _slices(canvas, outline, beam.width);
  }

  /// The tail as n nested strokes that all end at the head: stroke k starts k/n of the tail behind it, so
  /// opacity builds up toward the head with no seams between pieces.
  void _slices(Canvas canvas, ui.PathMetric outline, double strokeWidth) {
    const n = 16;
    final len = outline.length, tail = beam.tail * len;
    double target(int k) => k < 0 ? 0 : math.pow((k + 1) / n, 2).toDouble(); // opacity in the k-th piece
    for (final start in beam.starts) {
      final head = ((lap.value + start) % 1) * len;
      for (var k = 0; k < n; k++) {
        final alpha = 1 - (1 - target(k)) / (1 - target(k - 1));
        final paint = Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = strokeWidth
          ..color = beam.color.withValues(alpha: alpha);
        for (final (from, to) in _wrapped(head - tail * (n - k) / n, head, len)) {
          canvas.drawPath(outline.extractPath(from, to), paint);
        }
      }
    }
  }

  static List<(double, double)> _wrapped(double a, double b, double len) =>
      b <= 0 ? [(a + len, b + len)] : a < 0 ? [(a + len, len), (0, b)] : [(a, b)];

  @override
  bool shouldRepaint(_BeamPainter old) => old.beam != beam;
}
```

Usage: `BorderBeam(shape: const StadiumBorder(), child: glassButton)`; a card
`BorderBeam(shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.circular(28)), lap: const Duration(seconds: 8),
starts: const [0, .5], child: glassCard)`.

- The `shape` must be the glass body's own shape, so the beam lies on its rim; it paints above the child, inside the
  outline by half its width.
- Light glass: `color: const Color(0xFF2B6BFF), width: 2`.
- Not a `SweepGradient` rotated with `GradientRotation`: that is angle-driven and crawls mid-edge, then races round the
  caps.

<!-- medium: react-native -->

## Questions

**Do I need a package for liquid glass in Flutter?** No. A fragment shader and a small widget are enough, as below; packages such as liquid_glass_renderer do the same with their own API.

**Does it work on Android?** Yes, under Impeller (the default on recent Flutter for Android). Check `ImageFilter.isShaderFilterSupported`; where it is false, the widget shows the blur fallback.

**Why does my glass sample the wrong part of the screen?** The shader works in device pixels from the screen origin. Multiply the body's global rect, radius, bezel and displacement by the device pixel ratio.
