Flutter: Layering Widgets with Stack and Positioned
9 min read

Row lays its children out side by side and Column stacks them vertically. Stack puts them on top of each other: the first child in the list is painted at the bottom and the last one on top. A notification count on the corner of an icon, a title over a photo, a loading layer covering the screen while saving, a profile picture overlapping the edge of a cover image: all of these are built on this idea. In this post I cover how a Stack determines its size, the fit, alignment and clipBehavior parameters, the Positioned family, common recipes and the layout errors you'll run into most.
Basic Usage
Stack(
alignment: Alignment.center,
children: [
Container(width: 200, height: 120, color: Colors.indigo),
Container(width: 120, height: 70, color: Colors.amber),
const Text('On top'),
],
)All three children are centered and pile up in order: first the blue box, then the yellow box on it, and the text on top. Flutter has no z-index property; the order of the children list decides which widget is on top.
The Stack's Size and fit
A Stack's children fall into two groups. Non-positioned children (the ones not wrapped in Positioned) determine the Stack's size: the Stack becomes as big as its largest non-positioned child. Positioned children don't take part in that calculation; they're placed relative to the Stack once its size is known.
fit decides which constraints are passed to the non-positioned children:
fit |
What happens to non-positioned children |
|---|---|
StackFit.loose (default) |
They can be as small as they want; the upper limit is the space given to the Stack |
StackFit.expand |
They must fill the largest space given to the Stack |
StackFit.passthrough |
The constraints the Stack receives are passed on unchanged |
StackFit.expand is especially useful for "the background image and the layers on top should cover the same area". One more detail: if the Stack has no non-positioned children at all, meaning every child is Positioned, the Stack chooses the largest size it can get. The mistakes section below explains why that causes trouble.
alignment: Where Non-Positioned Children Go
alignment (default AlignmentDirectional.topStart, the top-left corner in left-to-right languages) says where non-positioned children are placed inside the Stack. Positioned children aren't affected by it, but if you leave out the values for one axis, alignment is used on that axis too: a Positioned child with only top: 8 is placed horizontally according to alignment.
To put a single child at a different spot you can wrap it in Align; I explain the difference between Align and Positioned inside a Stack in detail in the Align post.
The Positioned Family
Positioned places its child in pixels relative to the Stack's edges:
SizedBox(
width: 300,
height: 200,
child: Stack(
children: [
const Positioned.fill(child: ColoredBox(color: Colors.black12)),
const Positioned(top: 8, right: 8, child: Icon(Icons.close)),
const Positioned(left: 16, right: 16, bottom: 16, child: Text('Bottom strip')),
const PositionedDirectional(top: 8, start: 8, child: Icon(Icons.star)),
Positioned(top: 60, width: 100, height: 40, child: Container(color: Colors.teal)),
],
),
)left,top,right,bottom: Distance from an edge. Ifleftandrightare both given, the child's width is the space between them; that's how the bottom strip works.width,height: A direct size. At most two values per axis:left,rightandwidthcan't all be set at once.Positioned.fill: All four edges at 0; the child covers the entire Stack. It's the most common shortcut for layers.PositionedDirectional: Takesstart/endinstead ofleft/rightand follows the text direction. In right-to-left languages like Arabic or Hebrew,startbecomes the right side. If your app supports more than one language, positioning icons and buttons with it is a good habit.
clipBehavior: Overflowing Children
The default clipBehavior: Clip.hardEdge clips anything that overflows the Stack's bounds. For a badge sticking slightly out of an icon's corner or an avatar hanging below a cover image, you pass clipBehavior: Clip.none. There's an important trap here: the overflowing part is visible, but it doesn't receive taps. Flutter performs hit testing within the parent's bounds; a tap on a pixel outside the Stack never reaches that child. If the overflowing part needs to be tappable, make the Stack large enough to include it. The mini scenario below does exactly that. For the gesture side, see the GestureDetector post.
Recipe 1: A Notification Badge
class CartIconWithBadge extends StatelessWidget {
const CartIconWithBadge({super.key, required this.count});
final int count;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.shopping_cart_outlined, size: 28),
if (count > 0)
Positioned(
top: -6,
right: -8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: colors.error,
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(minWidth: 18),
child: Text(
count > 99 ? '99+' : '$count',
textAlign: TextAlign.center,
style: TextStyle(color: colors.onError, fontSize: 11),
),
),
),
],
);
}
}The icon determines the Stack's size; the badge sticks out of the corner with negative top and right values and isn't clipped thanks to Clip.none. For a standard notification count you don't need to write this by hand: Material 3's built-in Badge widget does the same job and uses the theme colors on its own: Badge.count(count: count, isLabelVisible: count > 0, child: const Icon(Icons.shopping_cart_outlined)). Building it yourself with a Stack makes sense when the badge's shape or content is non-standard.
Recipe 2: A Title over an Image
class ImageCaptionCard extends StatelessWidget {
const ImageCaptionCard({super.key, required this.image, required this.title});
final ImageProvider image;
final String title;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: [
Image(image: image, fit: BoxFit.cover),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
stops: const [0.5, 1.0],
),
),
),
Positioned(
left: 16,
right: 16,
bottom: 12,
child: Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
),
),
),
],
),
),
);
}
}StackFit.expand stretches the image and the gradient across the whole card, AspectRatio fixes the card's proportions and ClipRRect rounds the corners. The gradient that darkens toward the bottom keeps the white text readable whatever the image's colors are. We set the text color to fixed white on purpose here: the text always sits on a dark gradient, not on the theme's surface. As the ImageProvider you can pass AssetImage('assets/cover.jpg') or NetworkImage(url); the details of adding images are in the adding images post.
Recipe 3: A Layer That Locks the Screen While Saving
class SaveOverlay extends StatelessWidget {
const SaveOverlay({super.key, required this.isSaving, required this.child});
final bool isSaving;
final Widget child;
@override
Widget build(BuildContext context) {
return Stack(
children: [
child,
if (isSaving) ...[
Positioned.fill(
child: ModalBarrier(
dismissible: false,
color: Theme.of(context).colorScheme.scrim.withValues(alpha: 0.3),
),
),
const Positioned.fill(
child: Center(child: CircularProgressIndicator()),
),
],
],
);
}
}The page content (child) determines the Stack's size; the layers cover exactly that area with Positioned.fill. ModalBarrier catches taps meant for the content underneath, so the user can't press the button a second time while saving. Positioned.fill isn't a matter of taste here, it's required: ModalBarrier wants to be as large as possible; added as a non-positioned child, it would enlarge the Stack too, and inside a scrollable area it throws an infinite height error.
IndexedStack: One Child at a Time
IndexedStack also keeps its children on top of each other, but shows only the one at index. The others stay in the tree, so their State isn't lost: scroll down a list in one tab, switch to another tab, and when you come back you're where you left off. That's the standard way to preserve page state when switching tabs with a bottom navigation bar; there's an example in the BottomNavigationBar post.
Mini Scenario: A Profile Header
A layout you see often in social apps: a colored cover area at the top, a cover edit button in the top right, a round avatar overlapping the cover's edge at the bottom left, and an online indicator on the avatar's corner.
class ProfileHeader extends StatelessWidget {
const ProfileHeader({super.key});
static const _coverHeight = 140.0;
static const _avatarRadius = 44.0;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return SizedBox(
height: _coverHeight + _avatarRadius,
child: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
height: _coverHeight,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [colors.primary, colors.tertiary],
),
),
),
),
PositionedDirectional(
top: 8,
end: 8,
child: IconButton.filledTonal(
onPressed: () {},
tooltip: 'Change cover photo',
icon: const Icon(Icons.edit_outlined),
),
),
PositionedDirectional(
start: 16,
bottom: 0,
child: Stack(
clipBehavior: Clip.none,
children: [
CircleAvatar(
radius: _avatarRadius,
backgroundColor: colors.surface,
child: CircleAvatar(
radius: _avatarRadius - 4,
backgroundColor: colors.primaryContainer,
child: Text(
'AB',
style: TextStyle(
fontSize: 24,
color: colors.onPrimaryContainer,
),
),
),
),
Positioned(
right: 4,
bottom: 4,
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: colors.surface, width: 3),
),
),
),
],
),
),
],
),
);
}
}The key decision in this layout is making the Stack as tall as cover + avatar radius instead of pushing the avatar out with a negative bottom value. The cover takes the top 140 pixels, the avatar is pinned to the bottom of the Stack and half of it overlaps the cover. That way the avatar stays entirely inside the Stack: it's tappable, it doesn't overlap the content below, and the next widget in a Column starts at the right place. For the role of the SizedBox that gives the Stack its fixed height, see the SizedBox post. Because the edit button and the avatar are placed with PositionedDirectional, they swap sides automatically in a right-to-left language. The surface-colored border on the online indicator separates the dot from the avatar and looks right in both themes; the idea behind color roles is in the Theme and ThemeData post.
When to Use Stack, and When Something Else
Because Stack is powerful, it becomes the first tool people reach for whenever things overlap, but in many cases there's a simpler way. If you only need a color, gradient or border behind a widget, Container's decoration or a DecoratedBox is enough. To shift a single child by a few pixels, Transform.translate does it with less code, and to pin a single child to a corner, Align does. For an action button in the bottom right of the screen, use the Scaffold's floatingActionButton instead of building a Stack; the Scaffold handles its interaction with the keyboard and SnackBars. And for a standard notification count there's the Badge we saw above. Save Stack for cases where several layers genuinely share the same area: an image and the text on it, content and the loading layer over it, a cover and the avatar overlapping it.
Common Mistakes
1. Not making Positioned a direct child of the Stack
If you put a Positioned inside a Padding, Center or Column, or use it outside a Stack, you get this error:
Incorrect use of ParentDataWidget.Positioned only works as a direct child of a Stack. The same error appears when you put an Expanded inside a Stack; Expanded only makes sense inside Row, Column and Flex (see the Expanded post).
2. Putting a Stack with only Positioned children inside a Column or ListView
A Stack with no non-positioned children picks the largest size it can get. Inside a ListView or a scrollable Column, which offers unlimited vertical space, that means "infinite", and layout fails. The error text depends on the version: from Flutter 3.38 on it starts with A Stack requires bounded constraints from its parent; in older versions you see RenderStack object was given an infinite size during layout. The fix is to give the Stack a size with a SizedBox or to add at least one non-positioned child.
3. Setting left, right and width together
You can't set three values on the same axis. Positioned(left: 0, right: 0, width: 100, ...) triggers an assertion error; give either both edges or one edge plus a size.
4. The overflowing part not receiving taps
A button sticking out with Clip.none can't be tapped. The reason is the hit testing rule described above: taps outside the parent's bounds aren't detected. Make the Stack large enough to include the overflowing part.
5. Align or Center enlarging the Stack
If you used a non-positioned Align to put a badge in a corner, the Stack stretches to the screen width because Align asks for as much space as possible, and the badge ends up in the corner of the screen. Position with Positioned or give the Stack a size; the Center version of the same problem is in the Center post.
Frequently Asked Questions
How do I bring a widget to the front or send it to the back later?
Flutter has no z-index; the paint order is the order of the children list. Move the child you want in front to the end of the list. If the order changes through user interaction (say, the selected card should come to the front), keep the list in state, change its order and give every child a Key; otherwise Flutter may match the children's State objects incorrectly.
What's the difference between Stack and Overlay?
A Stack keeps its layers inside the area it occupies. For a dropdown menu, a tooltip or a suggestion box that needs to escape an area and float over the rest of the page, Flutter's Overlay infrastructure is used; the OverlayPortal widget is its widget-tree-friendly form. For layers within one part of the screen, Stack is the right tool; for elements floating above everything in the app, Overlay is.
Can Positioned values be given as percentages?
Positioned only takes pixels. If you want relative placement, a non-positioned Align (for example Alignment(0.8, -0.6)) works, or you can wrap the Stack in a LayoutBuilder and calculate pixels from the incoming width and height.
Related Posts
Flutter: Fine Alignment Operations with Align Widget
Precise placement with Align: the Alignment(x, y) coordinate system, FractionalOffset, widthFactor and heightFactor, Align vs Positioned in a Stack.
Flutter: Expanded vs Flexible vs Spacer, With Recipes
The difference between Expanded, Flexible and Spacer, loose vs tight fit, and four recipes that combine them: toolbar, card footer, form rows, split screen.
Flutter: ListView Widget Usage
A practical Flutter ListView guide: lazy lists with builder, separated, itemExtent, the real cost of shrinkWrap and fixing the unbounded height error.