Flutter has two separate shadow systems, and they don't share any parameters.

  • BoxShadow describes a blurred copy of your shape. BoxDecoration and ShapeDecoration paint it behind the widget. You choose the colour, offset, blur and spread.
  • Elevation (PhysicalModel, PhysicalShape, and every Material or Card) hands a shape and a height to the engine, which draws the shadow itself. You choose the elevation and the colour, nothing else.

Most "my shadow looks wrong" problems come from expecting one system to behave like the other, or from something that has nothing to do with the shadow: a parent that clips it, a fill that shows it through, or a dark background that hides it.

BoxShadow with one and three layers, PhysicalModel at elevation 8, and a Material 3 surface with surface tint


Quick answer

BoxShadow PhysicalModel / PhysicalShape Material elevation
Used by BoxDecoration, ShapeDecoration (so Container, DecoratedBox, Ink) Used directly Material, Card, app bars, buttons, dialogs
You control colour, offset, blurRadius, spreadRadius, blurStyle elevation, shadowColor elevation, shadowColor, plus surfaceTintColor in Material 3
Several shadows Yes, it's a list No No
How it's drawn The shape, shifted and grown, filled with a Gaussian-blurred paint Canvas.drawShadow(path, color, elevation, โ€ฆ) Builds a PhysicalModel or PhysicalShape, so the same as the middle column
Clips the child No Only if you set clipBehavior (default Clip.none) Only if you set clipBehavior (default Clip.none)
Animating it AnimatedContainer interpolates the shadow list AnimatedPhysicalModel Material animates elevation changes itself (200 ms by default)

Rule of thumb: if a design gives you offset, blur and spread values, or more than one shadow, use BoxShadow. If the widget is a Material surface and should behave like the rest of your theme, use elevation.


BoxShadow: a blurred copy of your shape

Container(
  width: 160,
  height: 120,
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(16),
    boxShadow: const [
      BoxShadow(
        color: Color(0x40000000), // 25% black
        blurRadius: 16,
        spreadRadius: 0,
        offset: Offset(0, 8),
      ),
    ],
  ),
)

BoxDecoration paints in a fixed order: every shadow first, then the background colour or gradient, then the image, then the border. Each shadow is the box's own rectangle, moved by offset, grown by spreadRadius, and filled with a paint that has a Gaussian blur mask. It uses the same shape as the decoration, so a borderRadius or BoxShape.circle gives you a rounded or circular shadow. ShapeDecoration does the same with any ShapeBorder.

blurRadius is not how far the shadow reaches

Flutter turns blurRadius into a Gaussian standard deviation (sigma) with BoxShadow.convertRadiusToSigma:

sigma = blurRadius ร— 0.57735 + 0.5 (and 0 when blurRadius is 0)

A Gaussian blur fades out at about three sigma past the edge of the shape. A widget test that renders a fully black shadow with blurRadius: 20 (sigma 12.05) finds the last non-white pixel 35 px from the box edge, just under 3 ร— sigma.

blurRadius sigma Visible reach (about 3 ร— sigma)
4 2.81 ~8 px
8 5.12 ~15 px
16 9.74 ~29 px
24 14.36 ~43 px
32 18.98 ~57 px

That reach, plus the offset and spread, is the empty space a shadow needs around the widget before anything clips it.

spreadRadius

Spread grows the shape before it is blurred, so a positive value makes the shadow bigger on every side. A negative spread shrinks it. Combined with a downward offset, a negative spread pulls the shadow in at the sides, so most of it shows below the card rather than all around it.

Layer two or three shadows

One shadow with a large blur tends to look like a grey smudge. Real objects cast a tight, darker shadow where they nearly touch the surface and a wide, faint one from ambient light. You can stack both:

const layeredShadows = [
  // Contact shadow: tight and slightly darker, right under the edge.
  BoxShadow(color: Color(0x1F000000), blurRadius: 3, offset: Offset(0, 1)),
  // Mid shadow: gives the card some body.
  BoxShadow(color: Color(0x14000000), blurRadius: 10, offset: Offset(0, 4)),
  // Ambient shadow: large, soft, pulled in with a negative spread.
  BoxShadow(
    color: Color(0x1A000000),
    blurRadius: 32,
    spreadRadius: -6,
    offset: Offset(0, 16),
  ),
];

Each layer is low opacity on its own. They are painted in list order, all of them before the fill, and each one is a separate blurred draw, so two or three layers is usually enough.


PhysicalModel: the engine's elevation shadow

PhysicalModel(
  color: Colors.white,
  elevation: 8,
  shadowColor: Colors.black,
  borderRadius: BorderRadius.circular(16),
  child: const SizedBox(width: 160, height: 120),
)

PhysicalModel draws the shadow with Canvas.drawShadow, then fills its shape with color. drawShadow takes only a path, a colour and an elevation. The blur, offset and spread all follow from the elevation, so you can't tune them individually. In the screenshot above, the elevation 8 shadow is strongest below the card, lighter at the sides and almost absent above it.

Things to know:

  • Defaults: elevation is 0 (no shadow until you set it), shadowColor is opaque black, and clipBehavior is Clip.none.
  • Shapes: shape is a BoxShape with an optional borderRadius. For any other outline, PhysicalShape takes a CustomClipper<Path> instead.
  • It draws nothing without a child. The render object returns early when there is no child, so this paints no colour and no shadow, and it lays out at zero size:
const PhysicalModel(
  color: Colors.white,
  elevation: 8,
)

Give it a sized child, as in the first example, even if the child is an empty SizedBox.


Material elevation

Material doesn't have its own shadow code. When it has no shape and the default MaterialType.canvas, it builds an AnimatedPhysicalModel. With a borderRadius or shape, or as a Card, it builds a PhysicalShape clipped to its ShapeBorder. (MaterialType.transparency builds a plain clip and casts no shadow.) Either way the shadow comes from the same drawShadow call, which is why Material(elevation: 8) and PhysicalModel(elevation: 8) cast the same shadow in the screenshot.

What Material adds on top:

  • A theme-based shadow colour. In Material 3 it defaults to ColorScheme.shadow, which is black unless you change it. In Material 2 it is ThemeData.shadowColor.
  • Animation. Changing elevation, shadowColor or surfaceTintColor animates over animationDuration (200 ms by default).
  • Surface tint in Material 3.

Material 3: surface tint vs shadow

Material 3 can show elevation in two ways at once: a shadow below the surface, and a tint blended into the surface colour. They are controlled separately:

  • shadowColor sets the shadow. Colors.transparent removes it.
  • surfaceTintColor blends a colour over color, with an opacity that grows with elevation. null or Colors.transparent means no tint.

The defaults matter here. In current Flutter (this article was checked against 3.38), Material.surfaceTintColor defaults to null, and Card sets it to Colors.transparent. A widget test confirms a Material 3 Card builds with elevation 1, colour ColorScheme.surfaceContainerLow, no tint, and a black shadow. So in Material 3, elevation alone gives you a shadow and nothing else. Cards are told apart by their surface container colour instead of a tint.

If you want the tint back, set it explicitly:

Material(
  elevation: 8,
  color: Colors.white,
  borderRadius: BorderRadius.circular(16),
  // Optional, Material 3 only: tint the surface by elevation.
  surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
  child: const SizedBox(width: 160, height: 120),
)

The tint opacity comes from a fixed table in ElevationOverlay.applySurfaceTint, interpolated between entries:

Elevation 0 1 3 6 8 12 and above
Tint opacity 0% 5% 8% 11% 12% 14%

To keep the tint and drop the shadow, for a flat Material 3 look:

Material(
  elevation: 8,
  color: Colors.white,
  shadowColor: Colors.transparent, // keep the tint, drop the shadow
  surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
  borderRadius: BorderRadius.circular(16),
  child: const SizedBox(width: 160, height: 120),
)

Why your shadow looks wrong

Shadows cut off at the edges of a scrolling ListView, the same list with padding, a translucent box showing its own shadow, and the fixed translucent box

It's cut off at the edges

Something above the widget is clipping, and a shadow, drawn outside its widget, is the first thing to go. The usual suspects:

  • Scroll views. ListView, GridView, PageView and SingleChildScrollView default to clipBehavior: Clip.hardEdge, and they apply it once their content is bigger than the viewport, which for a real list is almost always. A widget test shows a list that fits on screen leaves the shadows alone, while the same items in an overflowing list are cut off at its left and right edges.
  • Explicit clips: ClipRRect, ClipPath, and any Material, Card or Container with a clipBehavior set. These clip whatever is inside them, including the shadows of their children.

Leave room for the shadow inside the clip. Using the reach table above:

Widget shadowedItem() => Container(
  height: 100,
  margin: const EdgeInsets.only(bottom: 16),
  decoration: const BoxDecoration(
    color: Colors.white,
    boxShadow: [BoxShadow(color: Color(0x40000000), blurRadius: 16, offset: Offset(0, 8))],
  ),
);
ListView(
  // 3 sigma of blurRadius 16 is ~29 px; the 8 px offset moves it down.
  padding: const EdgeInsets.fromLTRB(32, 24, 32, 40),
  children: [for (var i = 0; i < 10; i++) shadowedItem()],
)

Setting the list's clipBehavior to Clip.none also removes the cut, but then items scrolling past the edge of the list stay visible outside it.

A translucent box shows its own shadow

BoxDecoration paints the shadow first and the fill on top. With an opaque fill you never see the part of the shadow underneath. With a translucent one, typical for glass-style cards, it shows through as a grey patch:

Container(
  width: 160,
  height: 120,
  decoration: BoxDecoration(
    color: Colors.white.withValues(alpha: 0.6),
    borderRadius: BorderRadius.circular(16),
    boxShadow: const [
      BoxShadow(color: Color(0x66000000), blurRadius: 16, offset: Offset(0, 8)),
    ],
  ),
)

The fix is to clip the inside of the box out of the shadow. A small painter does it: build a path that covers everything around the box but not the box itself (the even-odd fill rule turns the inner rounded rectangle into a hole), clip to it, then draw the shadow:

class OutsideShadowPainter extends CustomPainter {
  const OutsideShadowPainter({required this.radius, required this.shadow});
  final double radius;
  final BoxShadow shadow;

  @override
  void paint(Canvas canvas, Size size) {
    final box = RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius));
    final reach = shadow.blurSigma * 3 + shadow.offset.distance + shadow.spreadRadius.abs();
    // Everything around the box, but not the box itself.
    final outside = Path()
      ..fillType = PathFillType.evenOdd
      ..addRect((Offset.zero & size).inflate(reach))
      ..addRRect(box);
    canvas.save();
    canvas.clipPath(outside);
    canvas.drawRRect(box.shift(shadow.offset).inflate(shadow.spreadRadius), shadow.toPaint());
    canvas.restore();
  }

  @override
  bool shouldRepaint(OutsideShadowPainter oldDelegate) =>
      oldDelegate.radius != radius || oldDelegate.shadow != shadow;
}

Put it behind the translucent box and remove the boxShadow from the decoration:

CustomPaint(
  painter: const OutsideShadowPainter(
    radius: 16,
    shadow: BoxShadow(color: Color(0x66000000), blurRadius: 16, offset: Offset(0, 8)),
  ),
  child: Container(
    width: 160,
    height: 120,
    decoration: BoxDecoration(
      color: Colors.white.withValues(alpha: 0.6),
      borderRadius: BorderRadius.circular(16),
    ),
  ),
)

BlurStyle.outer ("nothing inside, fuzzy outside") is documented in dart:ui for this case. In a widget test, though, a rounded box with BlurStyle.outer rendered with a solid rectangle in its middle, so check it on your target devices before relying on it.

It disappears in dark mode

On a dark background: a black shadow alone, a lighter surface with the same shadow, and an outline

A black shadow on a near-black background has almost nothing to darken. That's not a Flutter bug, and ColorScheme.shadow is still black in the default Material 3 dark scheme. The usual fixes are visual:

  • Make raised surfaces lighter. Material 3 dark themes already do this through colour roles: in the default dark scheme, surfaceContainerLow is lighter than surface, and surfaceContainerHigh is lighter again. Card uses surfaceContainerLow.
  • Use an outline (ColorScheme.outlineVariant is a good start) where the shadow was the only thing separating two surfaces.
  • In Material 2, ThemeData.applyElevationOverlayColor lightens surface-coloured Material by elevation in dark themes. It is off by default in a Material 2 ThemeData (unless you build it from a colorSchemeSeed), so turn it on if you rely on it.

It's missing in golden tests

flutter_test sets debugDisableShadows = true, which replaces blurred shadows with solid shapes. Set it to false at the start of a golden test and back to true before the test ends, or the test fails its post-test checks.


Which one should you use?

  • Matching a design with offset, blur and spread values, or layering several shadows: BoxShadow on a BoxDecoration or ShapeDecoration.
  • A Material surface that should follow the theme and animate when its elevation changes: Material or Card with elevation.
  • An elevation shadow for a custom path without the rest of Material: PhysicalShape with a CustomClipper<Path>.
  • A translucent surface: a BoxShadow painted with the inside of the box clipped out.

If you want to try layered BoxShadow values side by side before putting them in code, the shadow generator previews them and outputs the Dart list.


Related articles