Flutter InkWell Localization: Material Touch Feedback for Multilingual Apps
InkWell is a Flutter widget that provides Material Design ripple effects when touched. In multilingual applications, InkWell creates consistent, visually appealing touch feedback that communicates interactivity across all languages through animation rather than text.
Understanding InkWell in Localization Context
InkWell renders a splash effect that spreads from the touch point, following Material Design guidelines. For multilingual apps, this enables:
- Universal touch feedback that needs no translation
- Consistent interaction patterns across all locales
- Direction-aware splash animations for RTL layouts
- Accessible touch responses for all users
Why InkWell Matters for Multilingual Apps
InkWell provides:
- Visual communication: Ripples indicate tappable areas without text
- Consistent feedback: Same animation across all languages
- Material compliance: Follows platform conventions globally
- Accessible interactions: Clear visual response for all users
Basic InkWell Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedInkWellExample extends StatelessWidget {
const LocalizedInkWellExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.itemTapped)),
);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.touch_app),
const SizedBox(width: 12),
Text(l10n.tapMeLabel),
],
),
),
);
}
}
Custom Ripple Colors
Themed Ripple Effects
class LocalizedThemedInkWell extends StatelessWidget {
final Widget child;
final VoidCallback? onTap;
final Color? splashColor;
final Color? highlightColor;
const LocalizedThemedInkWell({
super.key,
required this.child,
this.onTap,
this.splashColor,
this.highlightColor,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
splashColor: splashColor ??
Theme.of(context).colorScheme.primary.withOpacity(0.2),
highlightColor: highlightColor ??
Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
child: child,
);
}
}
class LocalizedActionCards extends StatelessWidget {
const LocalizedActionCards({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
LocalizedThemedInkWell(
splashColor: Colors.green.withOpacity(0.3),
onTap: () {},
child: ListTile(
leading: const Icon(Icons.check_circle, color: Colors.green),
title: Text(l10n.approveAction),
subtitle: Text(l10n.approveDescription),
),
),
LocalizedThemedInkWell(
splashColor: Colors.red.withOpacity(0.3),
onTap: () {},
child: ListTile(
leading: const Icon(Icons.cancel, color: Colors.red),
title: Text(l10n.rejectAction),
subtitle: Text(l10n.rejectDescription),
),
),
LocalizedThemedInkWell(
splashColor: Colors.orange.withOpacity(0.3),
onTap: () {},
child: ListTile(
leading: const Icon(Icons.schedule, color: Colors.orange),
title: Text(l10n.postponeAction),
subtitle: Text(l10n.postponeDescription),
),
),
],
);
}
}
Interactive List Items
InkWell List Tiles
class LocalizedInkWellListTile extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final Widget? trailing;
const LocalizedInkWellListTile({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.onTap,
this.onLongPress,
this.trailing,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
],
),
),
trailing ?? const Icon(Icons.chevron_right),
],
),
),
);
}
}
class LocalizedSettingsList extends StatelessWidget {
const LocalizedSettingsList({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListView(
children: [
LocalizedInkWellListTile(
icon: Icons.person,
title: l10n.profileSetting,
subtitle: l10n.profileSettingDesc,
onTap: () {},
),
const Divider(height: 1),
LocalizedInkWellListTile(
icon: Icons.notifications,
title: l10n.notificationSetting,
subtitle: l10n.notificationSettingDesc,
trailing: Switch(value: true, onChanged: (_) {}),
onTap: () {},
),
const Divider(height: 1),
LocalizedInkWellListTile(
icon: Icons.language,
title: l10n.languageSetting,
subtitle: l10n.currentLanguage,
onTap: () {},
),
const Divider(height: 1),
LocalizedInkWellListTile(
icon: Icons.dark_mode,
title: l10n.themeSetting,
subtitle: l10n.themeSettingDesc,
onTap: () {},
),
],
);
}
}
Card Interactions
InkWell Cards
class LocalizedInkWellCard extends StatelessWidget {
final String title;
final String description;
final IconData icon;
final VoidCallback? onTap;
const LocalizedInkWellCard({
super.key,
required this.title,
required this.description,
required this.icon,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 32,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 12),
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
description,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
);
}
}
class LocalizedFeatureGrid extends StatelessWidget {
const LocalizedFeatureGrid({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(16),
mainAxisSpacing: 16,
crossAxisSpacing: 16,
children: [
LocalizedInkWellCard(
icon: Icons.cloud_upload,
title: l10n.featureUpload,
description: l10n.featureUploadDesc,
onTap: () {},
),
LocalizedInkWellCard(
icon: Icons.share,
title: l10n.featureShare,
description: l10n.featureShareDesc,
onTap: () {},
),
LocalizedInkWellCard(
icon: Icons.analytics,
title: l10n.featureAnalytics,
description: l10n.featureAnalyticsDesc,
onTap: () {},
),
LocalizedInkWellCard(
icon: Icons.security,
title: l10n.featureSecurity,
description: l10n.featureSecurityDesc,
onTap: () {},
),
],
);
}
}
Custom Shaped InkWell
Circular InkWell
class LocalizedCircularInkWell extends StatelessWidget {
final Widget child;
final VoidCallback? onTap;
final double size;
const LocalizedCircularInkWell({
super.key,
required this.child,
this.onTap,
this.size = 56,
});
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.primaryContainer,
shape: const CircleBorder(),
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(
width: size,
height: size,
child: Center(child: child),
),
),
);
}
}
class LocalizedQuickActions extends StatelessWidget {
const LocalizedQuickActions({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Column(
children: [
LocalizedCircularInkWell(
onTap: () {},
child: const Icon(Icons.call),
),
const SizedBox(height: 8),
Text(l10n.callAction),
],
),
Column(
children: [
LocalizedCircularInkWell(
onTap: () {},
child: const Icon(Icons.message),
),
const SizedBox(height: 8),
Text(l10n.messageAction),
],
),
Column(
children: [
LocalizedCircularInkWell(
onTap: () {},
child: const Icon(Icons.video_call),
),
const SizedBox(height: 8),
Text(l10n.videoAction),
],
),
Column(
children: [
LocalizedCircularInkWell(
onTap: () {},
child: const Icon(Icons.email),
),
const SizedBox(height: 8),
Text(l10n.emailAction),
],
),
],
);
}
}
Stadium Shaped InkWell
class LocalizedStadiumInkWell extends StatelessWidget {
final String label;
final IconData? icon;
final VoidCallback? onTap;
final bool isSelected;
const LocalizedStadiumInkWell({
super.key,
required this.label,
this.icon,
this.onTap,
this.isSelected = false,
});
@override
Widget build(BuildContext context) {
return Material(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surface,
shape: const StadiumBorder(),
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: 18,
color: isSelected
? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8),
],
Text(
label,
style: TextStyle(
color: isSelected
? Theme.of(context).colorScheme.primary
: null,
fontWeight: isSelected ? FontWeight.bold : null,
),
),
],
),
),
),
);
}
}
class LocalizedFilterChips extends StatefulWidget {
const LocalizedFilterChips({super.key});
@override
State<LocalizedFilterChips> createState() => _LocalizedFilterChipsState();
}
class _LocalizedFilterChipsState extends State<LocalizedFilterChips> {
String _selected = 'all';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Wrap(
spacing: 8,
children: [
LocalizedStadiumInkWell(
label: l10n.filterAll,
icon: Icons.apps,
isSelected: _selected == 'all',
onTap: () => setState(() => _selected = 'all'),
),
LocalizedStadiumInkWell(
label: l10n.filterRecent,
icon: Icons.access_time,
isSelected: _selected == 'recent',
onTap: () => setState(() => _selected = 'recent'),
),
LocalizedStadiumInkWell(
label: l10n.filterFavorites,
icon: Icons.favorite,
isSelected: _selected == 'favorites',
onTap: () => setState(() => _selected = 'favorites'),
),
],
);
}
}
Navigation Menu Items
InkWell Navigation
class LocalizedNavItem extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback? onTap;
const LocalizedNavItem({
super.key,
required this.icon,
required this.label,
this.isSelected = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: null,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
icon,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: isSelected ? FontWeight.bold : null,
),
),
],
),
),
);
}
}
class LocalizedDrawerMenu extends StatefulWidget {
const LocalizedDrawerMenu({super.key});
@override
State<LocalizedDrawerMenu> createState() => _LocalizedDrawerMenuState();
}
class _LocalizedDrawerMenuState extends State<LocalizedDrawerMenu> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final items = [
(Icons.home, l10n.navHome),
(Icons.search, l10n.navSearch),
(Icons.favorite, l10n.navFavorites),
(Icons.person, l10n.navProfile),
(Icons.settings, l10n.navSettings),
];
return ListView(
padding: const EdgeInsets.all(8),
children: [
for (var i = 0; i < items.length; i++)
LocalizedNavItem(
icon: items[i].$1,
label: items[i].$2,
isSelected: i == _selectedIndex,
onTap: () => setState(() => _selectedIndex = i),
),
],
);
}
}
Image with InkWell Overlay
Tappable Image Card
class LocalizedTappableImage extends StatelessWidget {
final ImageProvider image;
final String title;
final VoidCallback? onTap;
const LocalizedTappableImage({
super.key,
required this.image,
required this.title,
this.onTap,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
Image(
image: image,
height: 200,
width: double.infinity,
fit: BoxFit.cover,
),
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Container(
alignment: Alignment.bottomLeft,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.7),
],
),
),
child: Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
],
),
);
}
}
class LocalizedImageGallery extends StatelessWidget {
const LocalizedImageGallery({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListView(
padding: const EdgeInsets.all(16),
children: [
LocalizedTappableImage(
image: const NetworkImage('https://picsum.photos/400/200?1'),
title: l10n.galleryImage1,
onTap: () {},
),
const SizedBox(height: 16),
LocalizedTappableImage(
image: const NetworkImage('https://picsum.photos/400/200?2'),
title: l10n.galleryImage2,
onTap: () {},
),
const SizedBox(height: 16),
LocalizedTappableImage(
image: const NetworkImage('https://picsum.photos/400/200?3'),
title: l10n.galleryImage3,
onTap: () {},
),
],
);
}
}
ARB File Structure
English (app_en.arb)
{
"@@locale": "en",
"tapMeLabel": "Tap me",
"itemTapped": "Item tapped!",
"approveAction": "Approve",
"approveDescription": "Approve this request",
"rejectAction": "Reject",
"rejectDescription": "Reject this request",
"postponeAction": "Postpone",
"postponeDescription": "Delay decision",
"profileSetting": "Profile",
"profileSettingDesc": "Manage your account",
"notificationSetting": "Notifications",
"notificationSettingDesc": "Configure alerts",
"languageSetting": "Language",
"currentLanguage": "English",
"themeSetting": "Theme",
"themeSettingDesc": "Light or dark mode",
"featureUpload": "Upload",
"featureUploadDesc": "Upload files to cloud",
"featureShare": "Share",
"featureShareDesc": "Share with others",
"featureAnalytics": "Analytics",
"featureAnalyticsDesc": "View statistics",
"featureSecurity": "Security",
"featureSecurityDesc": "Protect your data",
"callAction": "Call",
"messageAction": "Message",
"videoAction": "Video",
"emailAction": "Email",
"filterAll": "All",
"filterRecent": "Recent",
"filterFavorites": "Favorites",
"navHome": "Home",
"navSearch": "Search",
"navFavorites": "Favorites",
"navProfile": "Profile",
"navSettings": "Settings",
"galleryImage1": "Mountain Landscape",
"galleryImage2": "Ocean View",
"galleryImage3": "City Skyline"
}
German (app_de.arb)
{
"@@locale": "de",
"tapMeLabel": "Tippen Sie hier",
"itemTapped": "Element angetippt!",
"approveAction": "Genehmigen",
"approveDescription": "Diese Anfrage genehmigen",
"rejectAction": "Ablehnen",
"rejectDescription": "Diese Anfrage ablehnen",
"postponeAction": "Verschieben",
"postponeDescription": "Entscheidung verzögern",
"profileSetting": "Profil",
"profileSettingDesc": "Konto verwalten",
"notificationSetting": "Benachrichtigungen",
"notificationSettingDesc": "Warnungen konfigurieren",
"languageSetting": "Sprache",
"currentLanguage": "Deutsch",
"themeSetting": "Design",
"themeSettingDesc": "Hell- oder Dunkelmodus",
"featureUpload": "Hochladen",
"featureUploadDesc": "Dateien in die Cloud hochladen",
"featureShare": "Teilen",
"featureShareDesc": "Mit anderen teilen",
"featureAnalytics": "Analysen",
"featureAnalyticsDesc": "Statistiken anzeigen",
"featureSecurity": "Sicherheit",
"featureSecurityDesc": "Daten schützen",
"callAction": "Anrufen",
"messageAction": "Nachricht",
"videoAction": "Video",
"emailAction": "E-Mail",
"filterAll": "Alle",
"filterRecent": "Neueste",
"filterFavorites": "Favoriten",
"navHome": "Startseite",
"navSearch": "Suchen",
"navFavorites": "Favoriten",
"navProfile": "Profil",
"navSettings": "Einstellungen",
"galleryImage1": "Berglandschaft",
"galleryImage2": "Meeresblick",
"galleryImage3": "Stadtsilhouette"
}
Arabic (app_ar.arb)
{
"@@locale": "ar",
"tapMeLabel": "انقر هنا",
"itemTapped": "تم النقر على العنصر!",
"approveAction": "موافقة",
"approveDescription": "الموافقة على هذا الطلب",
"rejectAction": "رفض",
"rejectDescription": "رفض هذا الطلب",
"postponeAction": "تأجيل",
"postponeDescription": "تأخير القرار",
"profileSetting": "الملف الشخصي",
"profileSettingDesc": "إدارة حسابك",
"notificationSetting": "الإشعارات",
"notificationSettingDesc": "تكوين التنبيهات",
"languageSetting": "اللغة",
"currentLanguage": "العربية",
"themeSetting": "المظهر",
"themeSettingDesc": "الوضع الفاتح أو الداكن",
"featureUpload": "رفع",
"featureUploadDesc": "رفع الملفات إلى السحابة",
"featureShare": "مشاركة",
"featureShareDesc": "المشاركة مع الآخرين",
"featureAnalytics": "التحليلات",
"featureAnalyticsDesc": "عرض الإحصائيات",
"featureSecurity": "الأمان",
"featureSecurityDesc": "حماية بياناتك",
"callAction": "اتصال",
"messageAction": "رسالة",
"videoAction": "فيديو",
"emailAction": "بريد",
"filterAll": "الكل",
"filterRecent": "الأحدث",
"filterFavorites": "المفضلة",
"navHome": "الرئيسية",
"navSearch": "بحث",
"navFavorites": "المفضلة",
"navProfile": "الملف الشخصي",
"navSettings": "الإعدادات",
"galleryImage1": "منظر جبلي",
"galleryImage2": "إطلالة على المحيط",
"galleryImage3": "أفق المدينة"
}
Best Practices Summary
Do's
- Set borderRadius to match container for proper ripple clipping
- Use Card's clipBehavior for cards with InkWell
- Customize splash colors to match your theme
- Provide customBorder for non-rectangular shapes
- Test ripple animations on actual devices
Don'ts
- Don't wrap opaque containers that hide the ripple
- Don't forget Material ancestor - InkWell needs Material
- Don't use for non-interactive elements - misleads users
- Don't make touch targets too small for accessibility
Conclusion
InkWell is essential for creating Material Design touch interactions in multilingual Flutter applications. The ripple effect provides universal visual feedback that communicates interactivity without relying on text. By customizing splash colors and shapes, you can create consistent, accessible touch interactions that feel native across all languages and locales.