In Flutter, how do Material widgets guarantee a 48x48 tap target, and what do MaterialTapTargetSize.shrinkWrap and VisualDensity change?
answer
- kMinInteractiveDimension = 48.0
- hit area larger than the visuals
- padded on mobile, shrinkWrap on desktop
- density step is 4 logical pixels
- compact density shrinks padded targets
basics
~20 sMaterial widgets pad their hit region to kMinInteractiveDimension, 48 by 48 logical pixels, when materialTapTargetSize is padded, the mobile default. shrinkWrap drops that padding, and a negative VisualDensity shrinks the padded minimum by 4 pixels per step.
solid answer
~40 s`kMinInteractiveDimension` is 48.0, and Material controls reserve at least that as their **hit region** even when the visuals are smaller: an `IconButton`'s tap area is at least 48 regardless of `iconSize`, and a checkbox or a compact button is padded around its drawing. The switch is `ThemeData.materialTapTargetSize` (or `ButtonStyle.tapTargetSize`, or a widget's own `materialTapTargetSize`): `padded`, the default on Android, iOS and Fuchsia, adds the padding; `shrinkWrap`, the default on desktop, shrinks the target to the visual size. `VisualDensity` adjusts sizes in 4-pixel steps; `ThemeData` defaults to `standard` on mobile and `compact` (-2, -2) on desktop. For buttons, the padded minimum is `48 + densityAdjustment`, so forcing `compact` on a phone gives 40-pixel targets. Custom `GestureDetector` controls get none of this and need a `ConstrainedBox` or padding.
code
dart · 26 linesimport 'package:flutter/material.dart';
final ThemeData phoneTheme = ThemeData(
// Explicit here for clarity; both are already the mobile defaults.
materialTapTargetSize: MaterialTapTargetSize.padded,
visualDensity: VisualDensity.standard, // compact would make padded buttons 40x40
);
class FavouriteToggle extends StatelessWidget {
const FavouriteToggle({super.key, required this.onTap, required this.child});
final VoidCallback onTap;
final Widget child;
@override
Widget build(BuildContext context) {
// A custom control: enforce the minimum target yourself.
return ConstrainedBox(
constraints: const BoxConstraints(
minWidth: kMinInteractiveDimension,
minHeight: kMinInteractiveDimension,
),
child: InkWell(onTap: onTap, child: Center(child: child)),
);
}
}go deeper
Recall the number: Material targets are at least 48 by 48 logical pixels, even when the icon or checkbox looks smaller.
Explain padded versus shrinkWrap, their platform defaults, and how 4-pixel density steps feed into a button's minimum target.
Catch themes that force shrinkWrap or compact density on phones, and give custom gesture controls explicit minimum constraints.
Decide the density and tap-target policy per form factor in the design system, so product teams cannot drift below the minimum.
## Why a minimum target size A tap target is the area that responds to a touch. Platform guidelines set minimums — 48 by 48 dp on Android (Material), 44 by 44 points on iOS — because small targets cause mis-taps for everyone and are unusable for people with tremors or limited dexterity. Flutter encodes the Material value as **`kMinInteractiveDimension = 48.0`** logical pixels. ## Hit region versus visual size The key idea is that the **hit region can be bigger than what is drawn**. A Material checkbox draws a small square but is padded to a 48 by 48 target; the `IconButton` docs state its hit region will, if possible, be at least `kMinInteractiveDimension` regardless of `iconSize`. The extra area is invisible, so a dense-looking toolbar can still have full-size targets. ## MaterialTapTargetSize The padding is controlled by the enum `MaterialTapTargetSize`: | Value | Effect | Default where | |---|---|---| | `padded` | expands the minimum tap target to 48 by 48 | Android, iOS, Fuchsia | | `shrinkWrap` | shrinks the target to the minimum the Material spec allows for the visuals | Windows, macOS, Linux | It can be set at three levels: 1. `ThemeData(materialTapTargetSize: ...)` for the whole app; 2. `ButtonStyle(tapTargetSize: ...)` for buttons built on `ButtonStyleButton` (`ElevatedButton`, `FilledButton`, `OutlinedButton`, `TextButton`); 3. a widget's own parameter, such as `Checkbox(materialTapTargetSize: ...)`. Desktop defaults to `shrinkWrap` because a mouse pointer is precise; on touch screens `shrinkWrap` should be a deliberate, reviewed exception. ## VisualDensity `VisualDensity` makes components more or less compact. Each unit of `horizontal` or `vertical` density changes sizes by **4 logical pixels**, the Material guideline increment: - `VisualDensity.standard` is (0, 0); `comfortable` is (-1, -1); `compact` is (-2, -2), i.e. 8 pixels smaller. - `ThemeData` defaults `visualDensity` to `VisualDensity.defaultDensityForPlatform(platform)`: `standard` on Android, iOS and Fuchsia, `compact` on desktop. `VisualDensity.adaptivePlatformDensity` gives the same choice for the current platform. Density also reaches the tap target. In `ButtonStyleButton`, the padded minimum is `kMinInteractiveDimension + densityAdjustment`, so with `compact` density a padded button's minimum target is 40 by 40. Setting `visualDensity: VisualDensity.compact` in a phone theme to "make things tighter" therefore shrinks every button's target below 48. ## Custom controls get none of this The guarantees above belong to Material widgets. A control built from `GestureDetector`, `InkWell` on a small child, or `CustomPaint` is exactly as large as its layout box. Give it a minimum size yourself: - `ConstrainedBox(constraints: const BoxConstraints(minWidth: kMinInteractiveDimension, minHeight: kMinInteractiveDimension), child: ...)`; - or padding inside the tappable widget, so the padding is part of the hit area. ## Verifying - Flutter's widget-test guidelines `androidTapTargetGuideline` (48 by 48) and `iOSTapTargetGuideline` (44 by 44) check the size of tappable semantics nodes. - Debug paint (`debugPaintSizeEnabled`) or the DevTools inspector shows the real box, which is often larger than the visuals. - Review any theme that sets `materialTapTargetSize: MaterialTapTargetSize.shrinkWrap` or a negative `visualDensity` on mobile.
- Why do Flutter desktop apps default to MaterialTapTargetSize.shrinkWrap and compact density?A mouse or trackpad pointer is precise, so the invisible 48-pixel padding mostly wastes space in dense desktop layouts. `ThemeData` picks `shrinkWrap` and `VisualDensity.compact` for Windows, macOS and Linux, and `padded` with `standard` density for Android, iOS and Fuchsia, where fingers need the larger target.
- How does VisualDensity change a padded Material button's tap target in Flutter?`ButtonStyleButton` computes the padded minimum as `kMinInteractiveDimension` plus the density's `baseSizeAdjustment`, which is 4 logical pixels per density unit. `VisualDensity.compact` is (-2, -2), so the minimum becomes 40 by 40. Positive density enlarges it; negative density on a touch device undercuts the 48-pixel guideline.
saying these in an interview costs you the question
- A Material widget's tap target is exactly its visible size
- shrinkWrap is the default materialTapTargetSize on phones
- VisualDensity only changes paddings and never the tap target
- A GestureDetector gets a 48-pixel minimum automatically
- iOS and Android guidelines ask for the same minimum size