← Back to Blog

Flutter InkWell Localization: Material Touch Feedback for Multilingual Apps

flutterinkwellmaterialtouchlocalizationinteraction

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

  1. Set borderRadius to match container for proper ripple clipping
  2. Use Card's clipBehavior for cards with InkWell
  3. Customize splash colors to match your theme
  4. Provide customBorder for non-rectangular shapes
  5. Test ripple animations on actual devices

Don'ts

  1. Don't wrap opaque containers that hide the ripple
  2. Don't forget Material ancestor - InkWell needs Material
  3. Don't use for non-interactive elements - misleads users
  4. 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.

Further Reading