← Back to Blog

Flutter Positioned Localization: Absolute Positioning for Multilingual Apps

flutterpositionedstackbadgeslocalizationrtl

Flutter Positioned Localization: Absolute Positioning for Multilingual Apps

Positioned is Flutter's widget for absolute positioning within a Stack. In multilingual applications, proper use of Positioned is essential for creating overlays, badges, tooltips, and floating elements that adapt correctly to different languages and text directions.

Understanding Positioned in Localization Context

Positioned places its child at specific coordinates within a Stack. For multilingual apps, this requires careful consideration because:

  • RTL languages flip the meaning of left/right positioning
  • Badge and indicator positions must adapt to text direction
  • Overlay content needs directional awareness
  • Floating elements should maintain contextual positioning

Why Positioned Matters for Multilingual Apps

Proper positioning ensures:

  • RTL compatibility: Elements appear in contextually correct locations
  • Badge placement: Notification badges position correctly in all directions
  • Overlay alignment: Tooltips and popups appear near their triggers
  • Visual consistency: Floating elements maintain proper relationships

Basic Positioned Implementation

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedPositionedExample extends StatelessWidget {
  const LocalizedPositionedExample({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Stack(
      children: [
        Card(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  l10n.cardTitle,
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                const SizedBox(height: 8),
                Text(l10n.cardDescription),
              ],
            ),
          ),
        ),
        Positioned(
          top: 8,
          right: 8,
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
            decoration: BoxDecoration(
              color: Theme.of(context).colorScheme.primary,
              borderRadius: BorderRadius.circular(12),
            ),
            child: Text(
              l10n.newBadge,
              style: TextStyle(
                color: Theme.of(context).colorScheme.onPrimary,
                fontSize: 12,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ),
      ],
    );
  }
}

Directional Positioning for RTL Support

Using PositionedDirectional

class DirectionalPositionedExample extends StatelessWidget {
  const DirectionalPositionedExample({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Stack(
      children: [
        Container(
          width: double.infinity,
          height: 200,
          decoration: BoxDecoration(
            color: Theme.of(context).colorScheme.surfaceContainerHighest,
            borderRadius: BorderRadius.circular(12),
          ),
          child: Center(
            child: Text(l10n.mainContent),
          ),
        ),
        // Use PositionedDirectional for RTL support
        PositionedDirectional(
          top: 8,
          end: 8, // Right in LTR, Left in RTL
          child: IconButton(
            icon: const Icon(Icons.close),
            onPressed: () {},
            tooltip: l10n.closeTooltip,
          ),
        ),
        PositionedDirectional(
          bottom: 8,
          start: 8, // Left in LTR, Right in RTL
          child: Text(
            l10n.footerNote,
            style: Theme.of(context).textTheme.bodySmall,
          ),
        ),
      ],
    );
  }
}

RTL-Aware Positioned Widget

class RtlAwarePositioned extends StatelessWidget {
  final double? top;
  final double? bottom;
  final double? start;
  final double? end;
  final double? width;
  final double? height;
  final Widget child;

  const RtlAwarePositioned({
    super.key,
    this.top,
    this.bottom,
    this.start,
    this.end,
    this.width,
    this.height,
    required this.child,
  });

  @override
  Widget build(BuildContext context) {
    final isRtl = Directionality.of(context) == TextDirection.rtl;

    return Positioned(
      top: top,
      bottom: bottom,
      left: isRtl ? end : start,
      right: isRtl ? start : end,
      width: width,
      height: height,
      child: child,
    );
  }
}

// Usage
class BadgeOverlay extends StatelessWidget {
  const BadgeOverlay({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Stack(
      clipBehavior: Clip.none,
      children: [
        const Icon(Icons.notifications, size: 32),
        RtlAwarePositioned(
          top: -4,
          end: -4,
          child: Container(
            padding: const EdgeInsets.all(4),
            decoration: const BoxDecoration(
              color: Colors.red,
              shape: BoxShape.circle,
            ),
            child: Text(
              '5',
              style: const TextStyle(
                color: Colors.white,
                fontSize: 10,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ),
      ],
    );
  }
}

Badge Positioning Patterns

Notification Badge Component

class LocalizedBadge extends StatelessWidget {
  final Widget child;
  final String? badgeText;
  final int? count;
  final Color? backgroundColor;

  const LocalizedBadge({
    super.key,
    required this.child,
    this.badgeText,
    this.count,
    this.backgroundColor,
  });

  @override
  Widget build(BuildContext context) {
    final showBadge = badgeText != null || (count != null && count! > 0);

    if (!showBadge) return child;

    final displayText = badgeText ??
        (count! > 99 ? '99+' : count.toString());

    return Stack(
      clipBehavior: Clip.none,
      children: [
        child,
        PositionedDirectional(
          top: -8,
          end: -8,
          child: Container(
            constraints: const BoxConstraints(
              minWidth: 20,
              minHeight: 20,
            ),
            padding: const EdgeInsets.symmetric(horizontal: 6),
            decoration: BoxDecoration(
              color: backgroundColor ?? Theme.of(context).colorScheme.error,
              borderRadius: BorderRadius.circular(10),
            ),
            child: Center(
              child: Text(
                displayText,
                style: TextStyle(
                  color: Theme.of(context).colorScheme.onError,
                  fontSize: 11,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
          ),
        ),
      ],
    );
  }
}

// Usage
class NotificationButton extends StatelessWidget {
  final int unreadCount;

  const NotificationButton({
    super.key,
    required this.unreadCount,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return LocalizedBadge(
      count: unreadCount,
      child: IconButton(
        icon: const Icon(Icons.notifications_outlined),
        onPressed: () {},
        tooltip: l10n.notificationsTooltip,
      ),
    );
  }
}

Overlay Positioning

Localized Tooltip Overlay

class LocalizedTooltip extends StatefulWidget {
  final Widget child;
  final String message;

  const LocalizedTooltip({
    super.key,
    required this.child,
    required this.message,
  });

  @override
  State<LocalizedTooltip> createState() => _LocalizedTooltipState();
}

class _LocalizedTooltipState extends State<LocalizedTooltip> {
  bool _showTooltip = false;

  @override
  Widget build(BuildContext context) {
    final isRtl = Directionality.of(context) == TextDirection.rtl;

    return GestureDetector(
      onLongPress: () => setState(() => _showTooltip = true),
      onLongPressEnd: (_) => setState(() => _showTooltip = false),
      child: Stack(
        clipBehavior: Clip.none,
        children: [
          widget.child,
          if (_showTooltip)
            Positioned(
              bottom: 48,
              left: isRtl ? null : 0,
              right: isRtl ? 0 : null,
              child: Container(
                padding: const EdgeInsets.symmetric(
                  horizontal: 12,
                  vertical: 8,
                ),
                decoration: BoxDecoration(
                  color: Theme.of(context).colorScheme.inverseSurface,
                  borderRadius: BorderRadius.circular(8),
                  boxShadow: [
                    BoxShadow(
                      color: Colors.black.withOpacity(0.2),
                      blurRadius: 8,
                      offset: const Offset(0, 2),
                    ),
                  ],
                ),
                child: Text(
                  widget.message,
                  style: TextStyle(
                    color: Theme.of(context).colorScheme.onInverseSurface,
                    fontSize: 14,
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

Floating Action Positioning

Positioned FAB Menu

class LocalizedFabMenu extends StatefulWidget {
  const LocalizedFabMenu({super.key});

  @override
  State<LocalizedFabMenu> createState() => _LocalizedFabMenuState();
}

class _LocalizedFabMenuState extends State<LocalizedFabMenu>
    with SingleTickerProviderStateMixin {
  bool _isExpanded = false;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return SizedBox(
      width: 200,
      height: 300,
      child: Stack(
        children: [
          // Secondary actions
          if (_isExpanded) ...[
            PositionedDirectional(
              bottom: 140,
              end: 0,
              child: _FabMenuItem(
                icon: Icons.photo,
                label: l10n.addPhotoAction,
                onPressed: () {},
              ),
            ),
            PositionedDirectional(
              bottom: 80,
              end: 0,
              child: _FabMenuItem(
                icon: Icons.note_add,
                label: l10n.addNoteAction,
                onPressed: () {},
              ),
            ),
          ],
          // Main FAB
          PositionedDirectional(
            bottom: 16,
            end: 0,
            child: FloatingActionButton(
              onPressed: () => setState(() => _isExpanded = !_isExpanded),
              child: AnimatedRotation(
                turns: _isExpanded ? 0.125 : 0,
                duration: const Duration(milliseconds: 200),
                child: const Icon(Icons.add),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _FabMenuItem extends StatelessWidget {
  final IconData icon;
  final String label;
  final VoidCallback onPressed;

  const _FabMenuItem({
    required this.icon,
    required this.label,
    required this.onPressed,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Container(
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
          decoration: BoxDecoration(
            color: Theme.of(context).colorScheme.surface,
            borderRadius: BorderRadius.circular(4),
            boxShadow: [
              BoxShadow(
                color: Colors.black.withOpacity(0.1),
                blurRadius: 4,
              ),
            ],
          ),
          child: Text(label),
        ),
        const SizedBox(width: 8),
        FloatingActionButton.small(
          heroTag: label,
          onPressed: onPressed,
          child: Icon(icon),
        ),
      ],
    );
  }
}

Image Overlay Positioning

Localized Image Caption

class LocalizedImageCard extends StatelessWidget {
  final String imageUrl;
  final String title;
  final String? badge;

  const LocalizedImageCard({
    super.key,
    required this.imageUrl,
    required this.title,
    this.badge,
  });

  @override
  Widget build(BuildContext context) {
    return ClipRRect(
      borderRadius: BorderRadius.circular(12),
      child: Stack(
        children: [
          // Image
          AspectRatio(
            aspectRatio: 16 / 9,
            child: Image.network(
              imageUrl,
              fit: BoxFit.cover,
              errorBuilder: (context, error, stack) => Container(
                color: Theme.of(context).colorScheme.surfaceContainerHighest,
                child: const Icon(Icons.image, size: 48),
              ),
            ),
          ),
          // Gradient overlay
          Positioned.fill(
            child: Container(
              decoration: BoxDecoration(
                gradient: LinearGradient(
                  begin: Alignment.topCenter,
                  end: Alignment.bottomCenter,
                  colors: [
                    Colors.transparent,
                    Colors.black.withOpacity(0.7),
                  ],
                  stops: const [0.5, 1.0],
                ),
              ),
            ),
          ),
          // Title at bottom
          PositionedDirectional(
            bottom: 12,
            start: 12,
            end: 12,
            child: Text(
              title,
              style: const TextStyle(
                color: Colors.white,
                fontSize: 16,
                fontWeight: FontWeight.bold,
              ),
              maxLines: 2,
              overflow: TextOverflow.ellipsis,
            ),
          ),
          // Badge at top corner
          if (badge != null)
            PositionedDirectional(
              top: 12,
              end: 12,
              child: Container(
                padding: const EdgeInsets.symmetric(
                  horizontal: 8,
                  vertical: 4,
                ),
                decoration: BoxDecoration(
                  color: Theme.of(context).colorScheme.primary,
                  borderRadius: BorderRadius.circular(4),
                ),
                child: Text(
                  badge!,
                  style: TextStyle(
                    color: Theme.of(context).colorScheme.onPrimary,
                    fontSize: 12,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

Status Indicator Positioning

User Avatar with Status

class LocalizedUserAvatar extends StatelessWidget {
  final String? imageUrl;
  final String initials;
  final bool isOnline;
  final double size;

  const LocalizedUserAvatar({
    super.key,
    this.imageUrl,
    required this.initials,
    this.isOnline = false,
    this.size = 48,
  });

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: size,
      height: size,
      child: Stack(
        children: [
          // Avatar
          CircleAvatar(
            radius: size / 2,
            backgroundImage: imageUrl != null ? NetworkImage(imageUrl!) : null,
            child: imageUrl == null ? Text(initials) : null,
          ),
          // Online indicator
          if (isOnline)
            PositionedDirectional(
              bottom: 0,
              end: 0,
              child: Container(
                width: size * 0.3,
                height: size * 0.3,
                decoration: BoxDecoration(
                  color: Colors.green,
                  shape: BoxShape.circle,
                  border: Border.all(
                    color: Theme.of(context).colorScheme.surface,
                    width: 2,
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

Card Corner Actions

Positioned Action Menu

class LocalizedActionCard extends StatelessWidget {
  final String title;
  final String description;
  final VoidCallback? onEdit;
  final VoidCallback? onDelete;

  const LocalizedActionCard({
    super.key,
    required this.title,
    required this.description,
    this.onEdit,
    this.onDelete,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Card(
      child: Stack(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Padding(
                  padding: const EdgeInsetsDirectional.only(end: 40),
                  child: Text(
                    title,
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                ),
                const SizedBox(height: 8),
                Text(
                  description,
                  style: Theme.of(context).textTheme.bodyMedium,
                ),
              ],
            ),
          ),
          PositionedDirectional(
            top: 4,
            end: 4,
            child: PopupMenuButton<String>(
              icon: const Icon(Icons.more_vert),
              tooltip: l10n.moreOptionsTooltip,
              onSelected: (value) {
                if (value == 'edit') onEdit?.call();
                if (value == 'delete') onDelete?.call();
              },
              itemBuilder: (context) => [
                PopupMenuItem(
                  value: 'edit',
                  child: Row(
                    children: [
                      const Icon(Icons.edit, size: 20),
                      const SizedBox(width: 12),
                      Text(l10n.editAction),
                    ],
                  ),
                ),
                PopupMenuItem(
                  value: 'delete',
                  child: Row(
                    children: [
                      Icon(
                        Icons.delete,
                        size: 20,
                        color: Theme.of(context).colorScheme.error,
                      ),
                      const SizedBox(width: 12),
                      Text(
                        l10n.deleteAction,
                        style: TextStyle(
                          color: Theme.of(context).colorScheme.error,
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

ARB File Structure

English (app_en.arb)

{
  "@@locale": "en",

  "cardTitle": "Featured Item",
  "@cardTitle": {
    "description": "Title for featured card"
  },

  "cardDescription": "This is a special item with exclusive features and benefits.",
  "@cardDescription": {
    "description": "Description for featured card"
  },

  "newBadge": "NEW",
  "@newBadge": {
    "description": "Badge for new items"
  },

  "mainContent": "Main Content Area",
  "closeTooltip": "Close",
  "footerNote": "Last updated today",

  "notificationsTooltip": "Notifications",

  "addPhotoAction": "Add Photo",
  "addNoteAction": "Add Note",

  "moreOptionsTooltip": "More options",
  "editAction": "Edit",
  "deleteAction": "Delete"
}

German (app_de.arb)

{
  "@@locale": "de",

  "cardTitle": "Hervorgehobener Artikel",
  "cardDescription": "Dies ist ein besonderer Artikel mit exklusiven Funktionen und Vorteilen.",

  "newBadge": "NEU",

  "mainContent": "Hauptinhaltsbereich",
  "closeTooltip": "Schließen",
  "footerNote": "Zuletzt aktualisiert heute",

  "notificationsTooltip": "Benachrichtigungen",

  "addPhotoAction": "Foto hinzufügen",
  "addNoteAction": "Notiz hinzufügen",

  "moreOptionsTooltip": "Weitere Optionen",
  "editAction": "Bearbeiten",
  "deleteAction": "Löschen"
}

Arabic (app_ar.arb)

{
  "@@locale": "ar",

  "cardTitle": "عنصر مميز",
  "cardDescription": "هذا عنصر خاص بميزات وفوائد حصرية.",

  "newBadge": "جديد",

  "mainContent": "منطقة المحتوى الرئيسية",
  "closeTooltip": "إغلاق",
  "footerNote": "آخر تحديث اليوم",

  "notificationsTooltip": "الإشعارات",

  "addPhotoAction": "إضافة صورة",
  "addNoteAction": "إضافة ملاحظة",

  "moreOptionsTooltip": "خيارات أخرى",
  "editAction": "تعديل",
  "deleteAction": "حذف"
}

Best Practices Summary

Do's

  1. Use PositionedDirectional for RTL support
  2. Test badge positions in both LTR and RTL layouts
  3. Use clipBehavior: Clip.none when elements overflow Stack
  4. Combine with Stack for layered positioning
  5. Consider touch targets when positioning interactive elements

Don'ts

  1. Don't use Positioned(left/right) in RTL-supporting apps
  2. Don't position elements off-screen without checking direction
  3. Don't forget to test with long translations
  4. Don't hardcode positions that should adapt to content

Accessibility Considerations

class AccessiblePositionedContent extends StatelessWidget {
  const AccessiblePositionedContent({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Semantics(
      container: true,
      child: Stack(
        children: [
          // Main content
          Semantics(
            label: l10n.mainContentLabel,
            child: Container(
              padding: const EdgeInsets.all(16),
              child: Text(l10n.contentText),
            ),
          ),
          // Close button with proper semantics
          PositionedDirectional(
            top: 8,
            end: 8,
            child: Semantics(
              button: true,
              label: l10n.closeButtonLabel,
              child: IconButton(
                icon: const Icon(Icons.close),
                onPressed: () {},
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Conclusion

Positioned and PositionedDirectional are essential for creating polished multilingual Flutter applications with overlays, badges, and floating elements. By using PositionedDirectional instead of Positioned with left/right, you ensure your layouts work correctly in both LTR and RTL languages. Always test positioned elements with actual translations and in RTL mode to verify correct placement.

Further Reading