← Back to Blog

Flutter Slider Localization: Range Values, Labels, and Accessibility

fluttersliderrangelocalizationaccessibilityinput

Flutter Slider Localization: Range Values, Labels, and Accessibility

Sliders are essential input controls for selecting values from a range in Flutter applications. From volume controls to price filters, properly localizing slider content ensures users worldwide can understand and interact with your range selectors. This guide covers all slider variants and their localization requirements.

Understanding Slider Components

Sliders have several localizable elements:

  • Value labels: Current value display
  • Range labels: Min/max indicators
  • Semantic labels: Screen reader descriptions
  • Divisions labels: Tick mark values
  • Tooltips: Hover/press value indicators

Basic Slider Localization

Let's start with a simple localized slider:

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

class LocalizedSlider extends StatelessWidget {
  final double value;
  final ValueChanged<double> onChanged;

  const LocalizedSlider({
    super.key,
    required this.value,
    required this.onChanged,
  });

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

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.volumeLevel(value.round()),
          style: Theme.of(context).textTheme.titleMedium,
        ),
        Slider(
          value: value,
          min: 0,
          max: 100,
          divisions: 10,
          label: l10n.volumePercent(value.round()),
          semanticFormatterCallback: (value) {
            return l10n.volumeSemanticLabel(value.round());
          },
          onChanged: onChanged,
        ),
      ],
    );
  }
}

ARB Translations for Sliders

Define comprehensive slider translations:

{
  "volumeLevel": "Volume: {level}%",
  "@volumeLevel": {
    "description": "Current volume level display",
    "placeholders": {
      "level": {
        "type": "int",
        "example": "50"
      }
    }
  },
  "volumePercent": "{value}%",
  "@volumePercent": {
    "description": "Volume percentage for slider label",
    "placeholders": {
      "value": {
        "type": "int"
      }
    }
  },
  "volumeSemanticLabel": "Volume at {percent} percent",
  "@volumeSemanticLabel": {
    "description": "Screen reader label for volume slider",
    "placeholders": {
      "percent": {
        "type": "int"
      }
    }
  },
  "minValue": "Minimum",
  "maxValue": "Maximum",
  "currentValue": "Current value: {value}",
  "@currentValue": {
    "placeholders": {
      "value": {
        "type": "String"
      }
    }
  }
}

Range Slider Localization

For selecting value ranges:

class LocalizedRangeSlider extends StatelessWidget {
  final RangeValues values;
  final ValueChanged<RangeValues> onChanged;
  final double min;
  final double max;

  const LocalizedRangeSlider({
    super.key,
    required this.values,
    required this.onChanged,
    this.min = 0,
    this.max = 100,
  });

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

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.priceRange(
            values.start.round(),
            values.end.round(),
          ),
          style: Theme.of(context).textTheme.titleMedium,
        ),
        RangeSlider(
          values: values,
          min: min,
          max: max,
          divisions: 20,
          labels: RangeLabels(
            l10n.currencyValue(values.start.round()),
            l10n.currencyValue(values.end.round()),
          ),
          semanticFormatterCallback: (value) {
            return l10n.priceSemanticLabel(value.round());
          },
          onChanged: onChanged,
        ),
        // Min/Max labels
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(l10n.currencyValue(min.round())),
              Text(l10n.currencyValue(max.round())),
            ],
          ),
        ),
      ],
    );
  }
}

Price Filter with Currency Formatting

Handle locale-specific currency:

import 'package:intl/intl.dart';

class LocalizedPriceFilter extends StatelessWidget {
  final RangeValues priceRange;
  final ValueChanged<RangeValues> onChanged;
  final String currencyCode;

  const LocalizedPriceFilter({
    super.key,
    required this.priceRange,
    required this.onChanged,
    this.currencyCode = 'USD',
  });

  String _formatPrice(BuildContext context, double value) {
    final locale = Localizations.localeOf(context);
    final formatter = NumberFormat.currency(
      locale: locale.toString(),
      symbol: _getCurrencySymbol(currencyCode),
      decimalDigits: 0,
    );
    return formatter.format(value);
  }

  String _getCurrencySymbol(String code) {
    switch (code) {
      case 'EUR': return '€';
      case 'GBP': return '£';
      case 'JPY': return '¥';
      default: return '\$';
    }
  }

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

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              l10n.priceFilterTitle,
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 8),
            Text(
              l10n.selectedPriceRange(
                _formatPrice(context, priceRange.start),
                _formatPrice(context, priceRange.end),
              ),
            ),
            const SizedBox(height: 16),
            RangeSlider(
              values: priceRange,
              min: 0,
              max: 1000,
              divisions: 20,
              labels: RangeLabels(
                _formatPrice(context, priceRange.start),
                _formatPrice(context, priceRange.end),
              ),
              onChanged: onChanged,
            ),
          ],
        ),
      ),
    );
  }
}

Custom Slider with Discrete Labels

For sliders with specific labeled values:

class DiscreteValueSlider extends StatelessWidget {
  final int selectedIndex;
  final ValueChanged<int> onChanged;

  const DiscreteValueSlider({
    super.key,
    required this.selectedIndex,
    required this.onChanged,
  });

  List<String> _getQualityLabels(AppLocalizations l10n) {
    return [
      l10n.qualityLow,
      l10n.qualityMedium,
      l10n.qualityHigh,
      l10n.qualityUltra,
    ];
  }

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

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.videoQuality,
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 8),
        Slider(
          value: selectedIndex.toDouble(),
          min: 0,
          max: (labels.length - 1).toDouble(),
          divisions: labels.length - 1,
          label: labels[selectedIndex],
          semanticFormatterCallback: (value) {
            return l10n.qualitySemanticLabel(labels[value.round()]);
          },
          onChanged: (value) => onChanged(value.round()),
        ),
        // Label row
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 8),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: labels.map((label) {
              return Text(
                label,
                style: Theme.of(context).textTheme.bodySmall,
              );
            }).toList(),
          ),
        ),
      ],
    );
  }
}

Slider with RTL Support

Handle RTL languages correctly:

class RTLAwareSlider extends StatelessWidget {
  final double value;
  final ValueChanged<double> onChanged;

  const RTLAwareSlider({
    super.key,
    required this.value,
    required this.onChanged,
  });

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

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(l10n.brightness),
            Text(l10n.percentValue(value.round())),
          ],
        ),
        Directionality(
          // Ensure slider always goes left-to-right for visual consistency
          // but labels respect text direction
          textDirection: TextDirection.ltr,
          child: Slider(
            value: value,
            min: 0,
            max: 100,
            label: l10n.percentValue(value.round()),
            onChanged: onChanged,
          ),
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              // Icon positions should match visual slider direction
              Icon(Icons.brightness_low,
                   semanticLabel: l10n.lowBrightness),
              Icon(Icons.brightness_high,
                   semanticLabel: l10n.highBrightness),
            ],
          ),
        ),
      ],
    );
  }
}

Time Duration Slider

For selecting durations with localized formatting:

class DurationSlider extends StatelessWidget {
  final Duration duration;
  final Duration min;
  final Duration max;
  final ValueChanged<Duration> onChanged;

  const DurationSlider({
    super.key,
    required this.duration,
    required this.onChanged,
    this.min = const Duration(minutes: 1),
    this.max = const Duration(hours: 2),
  });

  String _formatDuration(BuildContext context, Duration d) {
    final l10n = AppLocalizations.of(context)!;

    if (d.inHours > 0) {
      final hours = d.inHours;
      final minutes = d.inMinutes.remainder(60);
      if (minutes == 0) {
        return l10n.hoursOnly(hours);
      }
      return l10n.hoursAndMinutes(hours, minutes);
    }
    return l10n.minutesOnly(d.inMinutes);
  }

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

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          l10n.timerDuration,
          style: Theme.of(context).textTheme.titleMedium,
        ),
        Text(
          _formatDuration(context, duration),
          style: Theme.of(context).textTheme.headlineSmall,
        ),
        Slider(
          value: duration.inMinutes.toDouble(),
          min: min.inMinutes.toDouble(),
          max: max.inMinutes.toDouble(),
          divisions: (max.inMinutes - min.inMinutes) ~/ 5,
          label: _formatDuration(context, duration),
          semanticFormatterCallback: (value) {
            final d = Duration(minutes: value.round());
            return l10n.durationSemanticLabel(_formatDuration(context, d));
          },
          onChanged: (value) {
            onChanged(Duration(minutes: value.round()));
          },
        ),
      ],
    );
  }
}

Accessibility Considerations

Ensure sliders are accessible:

class AccessibleSlider extends StatelessWidget {
  final double value;
  final ValueChanged<double> onChanged;

  const AccessibleSlider({
    super.key,
    required this.value,
    required this.onChanged,
  });

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

    return Semantics(
      label: l10n.temperatureSlider,
      value: l10n.temperatureValue(value.round()),
      hint: l10n.sliderHint,
      slider: true,
      child: Column(
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(l10n.temperature),
              ExcludeSemantics(
                child: Text(l10n.temperatureDisplay(value.round())),
              ),
            ],
          ),
          Slider(
            value: value,
            min: 16,
            max: 30,
            divisions: 14,
            label: l10n.temperatureDisplay(value.round()),
            semanticFormatterCallback: (value) {
              return l10n.temperatureSemanticLabel(value.round());
            },
            onChanged: onChanged,
          ),
          // Quick select buttons for accessibility
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              TextButton(
                onPressed: () => onChanged(18),
                child: Text(l10n.cool),
              ),
              TextButton(
                onPressed: () => onChanged(22),
                child: Text(l10n.comfortable),
              ),
              TextButton(
                onPressed: () => onChanged(26),
                child: Text(l10n.warm),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

Complete ARB File for Sliders

{
  "volumeLevel": "Volume: {level}%",
  "@volumeLevel": {
    "placeholders": { "level": { "type": "int" } }
  },
  "volumePercent": "{value}%",
  "@volumePercent": {
    "placeholders": { "value": { "type": "int" } }
  },
  "volumeSemanticLabel": "Volume at {percent} percent",
  "@volumeSemanticLabel": {
    "placeholders": { "percent": { "type": "int" } }
  },

  "priceRange": "Price: {min} - {max}",
  "@priceRange": {
    "placeholders": {
      "min": { "type": "int" },
      "max": { "type": "int" }
    }
  },
  "priceFilterTitle": "Price Filter",
  "selectedPriceRange": "Selected: {start} to {end}",
  "@selectedPriceRange": {
    "placeholders": {
      "start": { "type": "String" },
      "end": { "type": "String" }
    }
  },
  "currencyValue": "${value}",
  "@currencyValue": {
    "placeholders": { "value": { "type": "int" } }
  },
  "priceSemanticLabel": "Price {amount} dollars",
  "@priceSemanticLabel": {
    "placeholders": { "amount": { "type": "int" } }
  },

  "videoQuality": "Video Quality",
  "qualityLow": "Low",
  "qualityMedium": "Medium",
  "qualityHigh": "High",
  "qualityUltra": "Ultra",
  "qualitySemanticLabel": "Quality set to {level}",
  "@qualitySemanticLabel": {
    "placeholders": { "level": { "type": "String" } }
  },

  "brightness": "Brightness",
  "percentValue": "{value}%",
  "@percentValue": {
    "placeholders": { "value": { "type": "int" } }
  },
  "lowBrightness": "Low brightness",
  "highBrightness": "High brightness",

  "timerDuration": "Timer Duration",
  "minutesOnly": "{minutes} min",
  "@minutesOnly": {
    "placeholders": { "minutes": { "type": "int" } }
  },
  "hoursOnly": "{hours} hr",
  "@hoursOnly": {
    "placeholders": { "hours": { "type": "int" } }
  },
  "hoursAndMinutes": "{hours} hr {minutes} min",
  "@hoursAndMinutes": {
    "placeholders": {
      "hours": { "type": "int" },
      "minutes": { "type": "int" }
    }
  },
  "durationSemanticLabel": "Duration set to {duration}",
  "@durationSemanticLabel": {
    "placeholders": { "duration": { "type": "String" } }
  },

  "temperatureSlider": "Temperature control slider",
  "temperature": "Temperature",
  "temperatureValue": "{degrees} degrees",
  "@temperatureValue": {
    "placeholders": { "degrees": { "type": "int" } }
  },
  "temperatureDisplay": "{degrees}°C",
  "@temperatureDisplay": {
    "placeholders": { "degrees": { "type": "int" } }
  },
  "temperatureSemanticLabel": "Temperature set to {degrees} degrees Celsius",
  "@temperatureSemanticLabel": {
    "placeholders": { "degrees": { "type": "int" } }
  },
  "sliderHint": "Drag left or right to adjust",
  "cool": "Cool",
  "comfortable": "Comfortable",
  "warm": "Warm"
}

Testing Slider Localization

Test your localized sliders:

void main() {
  group('Slider Localization Tests', () {
    testWidgets('displays localized value label', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('de'),
          home: Scaffold(
            body: LocalizedSlider(
              value: 50,
              onChanged: (_) {},
            ),
          ),
        ),
      );

      expect(find.text('Lautstärke: 50%'), findsOneWidget);
    });

    testWidgets('range slider shows formatted currency', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          locale: const Locale('de', 'DE'),
          home: Scaffold(
            body: LocalizedPriceFilter(
              priceRange: const RangeValues(100, 500),
              onChanged: (_) {},
              currencyCode: 'EUR',
            ),
          ),
        ),
      );

      // German format uses € symbol and comma for decimals
      expect(find.textContaining('€'), findsWidgets);
    });

    testWidgets('semantic labels are accessible', (tester) async {
      final semanticsHandle = tester.ensureSemantics();

      await tester.pumpWidget(
        MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Scaffold(
            body: AccessibleSlider(
              value: 22,
              onChanged: (_) {},
            ),
          ),
        ),
      );

      final semantics = tester.getSemantics(find.byType(Slider));
      expect(semantics.label, contains('Temperature'));

      semanticsHandle.dispose();
    });
  });
}

Best Practices

  1. Always provide semantic labels for screen reader users
  2. Format values according to locale using NumberFormat
  3. Use appropriate divisions to avoid awkward values
  4. Consider RTL layouts for slider direction
  5. Provide alternative controls for accessibility
  6. Test with different locales and screen readers

Conclusion

Slider localization requires attention to value formatting, semantic labels, and cultural conventions. By implementing proper localization patterns, you ensure users can effectively interact with range controls regardless of their language or accessibility needs. Remember to test thoroughly with various locales and screen readers to guarantee an inclusive experience.