A tappable card can execute its callback perfectly while still feeling broken because its ripple is missing, hidden beneath a colour, or spilling beyond rounded corners. In this tutorial, you will build touch surfaces whose visual response follows their actual shape and remains clear across tap, hover, focus, and disabled states.
The examples explain where Flutter paints ink, why a Material ancestor is essential, and how the Ink and InkWell combination solves coloured-surface problems. You will also compare InkWell with InkResponse, configure Material 3 splashes, and diagnose the common reasons a ripple disappears.
You only need a standard Flutter project with the material library; no package dependency or platform configuration is required. The key idea is that an ink reaction belongs to a Material surface rather than being an overlay that InkWell can paint anywhere in the widget tree.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowUnderstand where the ink is actually painted
InkWell recognises gestures and asks the nearest Material ancestor to draw the highlight and animated splash. It does not paint the ripple directly over its child. This separation lets several interactive descendants share one physical Material surface, but it also explains most invisible-ripple bugs.
A Scaffold normally supplies Material higher in the tree, so a basic InkWell may appear to work without an immediately visible Material widget. Relying on that distant surface is fragile: another route, dialog, or reusable component might not provide the same ancestry. Give a self-contained interactive component its own Material.
import 'package:flutter/material.dart';
void main() {
runApp(const RippleDemo());
}
class RippleDemo extends StatelessWidget {
const RippleDemo({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('InkWell basics')),
body: Center(
child: Material(
color: Colors.indigo,
child: InkWell(
onTap: () {
debugPrint('Continue tapped');
},
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 28,
vertical: 16,
),
child: Text(
'Continue',
style: TextStyle(color: Colors.white),
),
),
),
),
),
),
);
}
}
The callback being non-null also determines whether the control is enabled. Setting onTap to null disables tap handling and suppresses its normal interactive response, which is preferable to keeping an empty callback on an unavailable action.
Use Ink when the surface has a colour or decoration
A frequent attempt is Material → Container → InkWell, with the Container supplying a background colour or gradient. The Container paints an opaque layer after the Material has painted its ink features, so the splash runs underneath that layer and cannot be seen. Moving InkWell above the Container does not change where its splash is drawn.
Ink solves this ordering problem. It paints its colour, image, or Decoration onto the Material itself; ink reactions registered with that Material can consequently appear over the decoration. The following complete example creates a gradient action tile without obscuring its feedback.
import 'package:flutter/material.dart';
void main() {
runApp(const InkCardApp());
}
class InkCardApp extends StatelessWidget {
const InkCardApp({super.key});
@override
Widget build(BuildContext context) {
const radius = BorderRadius.all(Radius.circular(20));
return MaterialApp(
home: Scaffold(
body: Center(
child: Material(
color: Colors.transparent,
borderRadius: radius,
clipBehavior: Clip.antiAlias,
child: Ink(
width: 300,
decoration: const BoxDecoration(
borderRadius: radius,
gradient: LinearGradient(
colors: <Color>[
Color(0xFF3949AB),
Color(0xFF00897B),
],
),
),
child: InkWell(
borderRadius: radius,
splashColor: Color(0x66FFFFFF),
highlightColor: Color(0x22FFFFFF),
onTap: () {
debugPrint('Workspace opened');
},
child: const Padding(
padding: EdgeInsets.all(20),
child: Row(
children: <Widget>[
Icon(Icons.workspaces_outline, color: Colors.white),
SizedBox(width: 14),
Expanded(
child: Text(
'Open workspace',
style: TextStyle(
color: Colors.white,
fontSize: 18,
),
),
),
Icon(Icons.arrow_forward, color: Colors.white),
],
),
),
),
),
),
),
),
);
}
}
Keep the Ink and InkWell associated with the same Material. Adding another opaque Material between them creates a different painting surface and can produce surprising stacking. Decoration images supplied through Ink.image follow the same principle.
Tune splash and pressed-state colours separately
splashColor controls the expanding reaction released from the contact point. highlightColor is the steadier colour visible while the pointer remains pressed. They overlap during part of the gesture, so heavily opaque values can produce a muddy block instead of a subtle state change.
InkWell(
splashColor: const Color(0x334285F4),
highlightColor: const Color(0x144285F4),
hoverColor: const Color(0x0F4285F4),
focusColor: const Color(0x244285F4),
onTap: () {
debugPrint('Saved');
},
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 14),
child: Text('Save changes'),
),
)
Choose colours against the real surface, not against a white design canvas. A white splash may read well on a dark image, whereas a theme-derived primary colour is usually clearer on a pale card. Hover and focus colours matter on desktop and web, where the interaction is not limited to fingers.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowMake rounded ripples obey the component boundary
borderRadius tells InkWell about the intended shape of its ink response, but the Material owns the actual ink canvas and clipping. For a dependable rounded component, use the same radius for Material, the Ink decoration, and InkWell, then enable clipping on Material.
const cardRadius = BorderRadius.all(Radius.circular(16));
Material(
borderRadius: cardRadius,
clipBehavior: Clip.antiAlias,
child: Ink(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: cardRadius,
),
child: InkWell(
borderRadius: cardRadius,
onTap: () {
debugPrint('Card selected');
},
child: const SizedBox(
width: 280,
height: 96,
child: Center(child: Text('Rounded account card')),
),
),
),
)
Clipping every widget indiscriminately can add rendering work, so apply it where pixels really need to stay inside a non-rectangular edge. For a custom shape, such as a stadium or bevelled outline, provide matching ShapeBorder values through Material’s shape and InkWell’s customBorder.
Choose InkResponse for radial or uncontained feedback
InkWell is the conventional choice for rectangular controls because its reaction is contained within a rectangle and can follow a border radius. InkResponse is the lower-level option when the response should be circular, use a particular radius, or travel outside the child’s bounds while still painting on Material.
Material(
color: Colors.transparent,
child: InkResponse(
radius: 30,
containedInkWell: false,
highlightShape: BoxShape.circle,
onTap: () {
debugPrint('Favourite toggled');
},
child: const Padding(
padding: EdgeInsets.all(12),
child: Icon(
Icons.favorite_border,
semanticLabel: 'Add to favourites',
),
),
),
)
An uncontained reaction can extend beyond the widget, but it cannot escape the bounds of the Material on which it is painted. Use InkResponse for an icon floating within a generous Material area; use InkWell for list rows, cards, menu entries, and button-like rectangles where a bounded response communicates the hit region more accurately.
Let Material 3 select the splash, or choose one deliberately
With useMaterial3 enabled, Flutter selects Material 3 component defaults, including the platform-appropriate splash factory. On supported Android rendering paths this can produce the sparkle-style reaction associated with Material 3. Other platforms may retain a ripple-style implementation, so do not make business logic depend on a particular animation.
MaterialApp(
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.teal,
),
home: const RippleShowcase(),
)
You can set splashFactory in ThemeData when the product requires a consistent choice. InkSparkle.splashFactory, InkRipple.splashFactory, and InkSplash.splashFactory are real Material factories. Prefer the theme-level setting for an application-wide decision; a local InkWell can override it when one component genuinely needs different feedback.
ThemeData(
useMaterial3: true,
splashFactory: InkSparkle.splashFactory,
)
Test the selected effect on target devices. A very short tap, reduced animation settings, platform rendering differences, and a low-contrast splash colour can all make the result appear subtler than it does in a design preview.
Keep the target usable beyond touch input
A ripple is feedback, not the complete accessibility contract. Make the interactive child large enough to acquire comfortably, provide meaningful text or an icon semantic label, and avoid using colour alone to identify the result. InkWell participates in focus and keyboard interaction when it is enabled, making it more suitable than a raw pointer listener for ordinary actions.
Tooltip(
message: 'Delete message',
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () {
debugPrint('Delete requested');
},
child: const SizedBox(
width: 48,
height: 48,
child: Icon(
Icons.delete_outline,
semanticLabel: 'Delete message',
),
),
),
),
)
Do not attach separate tap handlers to both a parent InkWell and a child InkWell unless two genuinely distinct actions are intended. Overlapping semantic actions and competing visual responses make the target difficult to understand, especially when the nested controls have nearly identical bounds.
Fix a ripple that is missing, hidden, or misshapen
When an onTap callback runs but no reaction appears, inspect the painting hierarchy before changing colours. The following checks isolate the usual causes:
- No Material ancestor: wrap the interactive surface in Material. A Container with a colour is not a substitute.
- Opaque paint above Material: replace the decorating Container with Ink, or move the decoration onto Material when a plain colour is sufficient.
- Unexpected surface: remove unnecessary nested Material widgets so the Ink decoration and InkWell register with the intended one.
- Rounded corners leaking: align
borderRadiusorcustomBorder, then set an appropriateclipBehavioron Material. - No enabled callback: confirm that
onTap,onLongPress, or another supported handler is non-null. - Invisible contrast: temporarily use an obvious splash colour to distinguish a painting problem from a palette problem.
Another edge case occurs when the Material changes size while a splash is expanding. The clipping boundary may not track that animated size perfectly. Prefer keeping the ink surface stable during the short reaction, or place the size animation outside the Material that owns the splash.
Avoid common InkWell composition mistakes
Do not put InkWell around a visually smaller child and assume users will understand the larger invisible hit region; make the layout show the same boundary that receives input. Conversely, placing InkWell only around a label can leave padding outside the gesture target. Put padding inside InkWell when the entire padded area should respond.
Avoid using GestureDetector merely to obtain a tap on a Material control. GestureDetector is useful for custom gesture handling, but it does not supply Material ink, focus visuals, or the same button-like conventions. Also avoid adding a second clipping widget only to repair a ripple: the Material that paints the ink must be clipped, otherwise the extra clip may affect the child while leaving the ink canvas unchanged.
Finally, keep destructive actions distinguishable through labels, icons, confirmation where appropriate, and state changes after activation. A dramatic red splash is not a replacement for communicating what will happen.
Verify both the callback and visual response
A widget test can prove that the complete InkWell target invokes its action. Hold the gesture briefly with startGesture during manual debugging to inspect the pressed state, then release it and pump until the animation settles. The self-contained test below checks a counter through the same public text a user sees.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('the InkWell increments the visible count', (tester) async {
await tester.pumpWidget(const MaterialApp(home: PressCounter()));
expect(find.text('Pressed 0 times'), findsOneWidget);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(InkWell)),
);
await tester.pump(const Duration(milliseconds: 100));
await gesture.up();
await tester.pumpAndSettle();
expect(find.text('Pressed 1 times'), findsOneWidget);
});
}
class PressCounter extends StatefulWidget {
const PressCounter({super.key});
@override
State<PressCounter> createState() => _PressCounterState();
}
class _PressCounterState extends State<PressCounter> {
int count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Material(
color: Colors.amber,
child: InkWell(
onTap: () {
setState(() {
count++;
});
},
child: Padding(
padding: const EdgeInsets.all(20),
child: Text('Pressed $count times'),
),
),
),
),
);
}
}
Automated assertions do not judge whether a translucent splash remains legible over every theme colour. Finish with a visual check in light and dark themes, activate the control with touch, mouse, and keyboard, and confirm that the ripple boundary matches the area that actually responds.
Further reads
Keep going with these related tutorials from this site.
- Flutter: The Complete Guide — follow the full Flutter learning path on this site
- Flutter Material Widgets Catalogue — compare InkWell with buttons, cards, and other Material controls
- Flutter Layout Widgets Guide — size padded touch targets and compose responsive interactive surfaces
- Flutter Animations Complete Guide — understand the animation principles behind responsive interface feedback
- Dart Language and Flutter Guide — strengthen the callback, state, and widget syntax used in the examples