Fliin
On this page

Components / navigation

Page indicator

Shows the current page of an onboarding or a carousel as dots whose active one stretches into a pill, bound to a page view, and lets people change page by touch.

16 of 16 variants pass
Page indicator, onboarding state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add page-indicatorAlso installs: core

Examples

Onboarding

Three welcome pages that slide, and their dots tied to the same PageController with FlPageIndicator.controller: swiping moves the dots, touching a dot turns the page.

Page indicator, onboarding state
import 'package:your_app/ui/fliin/components/page_indicator/fl_page_indicator.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<PageIndicatorOnboardingExample> createState() => _PageIndicatorOnboardingExampleState();
}

class _PageIndicatorOnboardingExampleState extends State<PageIndicatorOnboardingExample> {
  final _pages = PageController();

  static const _titles = ['Plan your week', 'Share with your team', 'Stay on track'];

  @override
  void dispose() {
    _pages.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      spacing: FlScope.of(context).space.lg,
      children: [
        Expanded(
          child: PageView(
            controller: _pages,
            children: [for (final title in _titles) Center(child: FlText(title))],
          ),
        ),
        FlPageIndicator.controller(
          count: _titles.length,
          controller: _pages,
          semanticLabel: 'Intro',
          pageLabel: (page, count) => 'Page $page of $count',
        ),
      ],
    );
  }
}

Bars

FlPageIndicatorVariant.bars: the thin bars at the top of an onboarding, the current page drawn longer.

Page indicator, bars state
import 'package:your_app/ui/fliin/components/page_indicator/fl_page_indicator.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<PageIndicatorBarsExample> createState() => _PageIndicatorBarsExampleState();
}

class _PageIndicatorBarsExampleState extends State<PageIndicatorBarsExample> {
  var _page = 1;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlPageIndicator(
        count: 4,
        index: _page,
        variant: FlPageIndicatorVariant.bars,
        semanticLabel: 'Welcome',
        pageLabel: (page, count) => 'Step $page of $count',
        onChanged: (page) => setState(() => _page = page),
      ),
    );
  }
}

Banner

Over a colored banner or a photo, color paints the dots in the color written on it; the other pages take it at 40 %, or inactiveColor.

Page indicator, banner state
import 'package:your_app/ui/fliin/components/page_indicator/fl_page_indicator.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<PageIndicatorBannerExample> createState() => _PageIndicatorBannerExampleState();
}

class _PageIndicatorBannerExampleState extends State<PageIndicatorBannerExample> {
  var _page = 0;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    return DecoratedBox(
      decoration: ShapeDecoration(
        color: theme.colors.accent,
        shape: FlContinuousBorder(radius: theme.shape.radiusCard),
      ),
      child: Padding(
        padding: EdgeInsetsDirectional.all(theme.space.lg),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          spacing: theme.space.md,
          children: [
            Text(
              'Free delivery this week',
              style: theme.typography
                  .resolve(FlTextRole.heading5, heading: true)
                  .copyWith(color: theme.colors.onAccent),
            ),
            FlPageIndicator(
              count: 3,
              index: _page,
              color: theme.colors.onAccent,
              semanticLabel: 'Offers',
              pageLabel: (page, count) => 'Offer $page of $count',
              onChanged: (page) => setState(() => _page = page),
            ),
          ],
        ),
      ),
    );
  }
}

First

Three pages, the first one current. onChanged receives the dot touched; pageLabel names each page for the screen reader.

Page indicator, first state
import 'package:your_app/ui/fliin/components/page_indicator/fl_page_indicator.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<PageIndicatorFirstExample> createState() => _PageIndicatorFirstExampleState();
}

class _PageIndicatorFirstExampleState extends State<PageIndicatorFirstExample> {
  var _page = 0;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlPageIndicator(
        count: 3,
        index: _page,
        onChanged: (page) => setState(() => _page = page),
        semanticLabel: 'Intro',
        pageLabel: (page, count) => 'Page $page of $count',
      ),
    );
  }
}

Middle

The second of three pages is current: the active dot stretches.

Page indicator, middle state
import 'package:your_app/ui/fliin/components/page_indicator/fl_page_indicator.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<PageIndicatorMiddleExample> createState() => _PageIndicatorMiddleExampleState();
}

class _PageIndicatorMiddleExampleState extends State<PageIndicatorMiddleExample> {
  var _page = 1;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlPageIndicator(
        count: 3,
        index: _page,
        onChanged: (page) => setState(() => _page = page),
        semanticLabel: 'Intro',
        pageLabel: (page, count) => 'Page $page of $count',
      ),
    );
  }
}

Last

The last page is current, as on the final step of an onboarding.

Page indicator, last state
import 'package:your_app/ui/fliin/components/page_indicator/fl_page_indicator.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<PageIndicatorLastExample> createState() => _PageIndicatorLastExampleState();
}

class _PageIndicatorLastExampleState extends State<PageIndicatorLastExample> {
  var _page = 2;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlPageIndicator(
        count: 3,
        index: _page,
        onChanged: (page) => setState(() => _page = page),
        semanticLabel: 'Intro',
        pageLabel: (page, count) => 'Page $page of $count',
      ),
    );
  }
}

Usage

The code the reference images render:

final example = ColoredBox(
  color: FlScope.of(context).colors.surface,
  child: Padding(
    padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: FlScope.of(context).space.sm,
      children: [
        for (final (count, index) in const [(3, 0), (3, 1), (3, 2), (7, 4)])
          SizedBox(
            height: FlScope.of(context).hitTarget,
            child: Center(
              child: FlPageIndicator(
                count: count,
                index: index,
                semanticLabel: 'Introduction',
                pageLabel: (page, count) => 'Page $page of $count',
                onChanged: (_) {},
              ),
            ),
          ),
      ],
    ),
  ),
);

API

FlPageIndicatorVariant: enum {dots, bars}
FlPageIndicatorVariant.dots: static final FlPageIndicatorVariant
FlPageIndicatorVariant.bars: static final FlPageIndicatorVariant
FlPageIndicator: class extends StatefulWidget
FlPageIndicator.new: const new({super.key, required int count, required int index, ValueChanged<int>? onChanged, required String Function(int page, int count) pageLabel, String? semanticLabel, FlPageIndicatorVariant variant, Color? color, Color? inactiveColor})
FlPageIndicator.controller: get PageController?
FlPageIndicator.count: get int
FlPageIndicator.onChanged: get ValueChanged<int>?
FlPageIndicator.pageLabel: get String Function(int page, int count)
FlPageIndicator.semanticLabel: get String?
FlPageIndicator.index: get int?
FlPageIndicator.variant: get FlPageIndicatorVariant
FlPageIndicator.color: get Color?
FlPageIndicator.inactiveColor: get Color?
FlPageIndicator.createState: State<FlPageIndicator>()

Reference renders

The 16 images published with version 0.2.2, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify page-indicator compares your copy to them.

iOS · Light
Page indicator, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Page indicator, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Page indicator, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Page indicator, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Page indicator, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Page indicator, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Page indicator, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Page indicator, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Page indicator, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Page indicator, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Page indicator, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Page indicator, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Page indicator, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Page indicator, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Page indicator, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Page indicator, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • show which onboarding page is visible
  • show dots under an image carousel
  • show how many intro screens are left
  • keep dots in sync with a swiping page view
  • move to the next page by tapping the dots
Not for
  • sections of the same screen: use tabs (tabs)
  • the progress of a task: use a progress indicator (progress)
States
  • First
  • Middle
  • Last

Source

The files fliin add page-indicator copies into your app, before your prefix and imports are applied.

page_indicator/fl_page_indicator.dart

import 'package:flutter/services.dart' show LogicalKeyboardKey;
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/core/fliin.dart';

/// The look of an [FlPageIndicator].
enum FlPageIndicatorVariant {
  /// Izydesk's `.dots`: dots of 8, 8 apart, the current page a `selected`
  /// pill of 24, the others `onSurfaceMuted` at 40 %.
  dots,

  /// The header of Izydesk's onboarding: thin bars 6 high, 5 apart, the
  /// current page 40 long, all in `onSurface`, the others at 30 %.
  bars,
}

/// The dots of a sequence of pages (onboarding, carousel): the current page
/// stretches into a pill, the others stay dots ([variant]): solid capsules,
/// gaps from the `spacing` scale.
///
/// Bound to a `PageController` ([FlPageIndicator.controller]), it follows the
/// finger from one page to the next. On a given page ([FlPageIndicator]), the
/// pill slides there with the `motion.spring.indicator` spring, or jumps
/// there under reduced motion.
///
/// Touch follows the platform: on iOS, like `UIPageControl`, a tap before or
/// after the middle goes back or forward one page, and a drag scrubs through
/// the dots; elsewhere, a tap picks the nearest dot. The screen reader reads
/// a single adjustable node ("page 2 of 3"), and the keyboard arrows change
/// page.
class FlPageIndicator extends StatefulWidget {
  /// The dots of [count] pages, on page [index]. [onChanged] receives the
  /// page chosen by touch, keyboard or screen reader; when null, the dots
  /// cannot be chosen.
  const FlPageIndicator({
    super.key,
    required this.count,
    required int this.index,
    this.onChanged,
    required this.pageLabel,
    this.semanticLabel,
    this.variant = FlPageIndicatorVariant.dots,
    this.color,
    this.inactiveColor,
  }) : assert(count > 0, 'at least one page'),
       controller = null;

  /// The dots of [controller]'s pages, which they follow. Without
  /// [onChanged], a choice animates the page view there in `medium2`, or
  /// jumps there under reduced motion.
  const FlPageIndicator.controller({
    super.key,
    required this.count,
    required PageController this.controller,
    this.onChanged,
    required this.pageLabel,
    this.semanticLabel,
    this.variant = FlPageIndicatorVariant.dots,
    this.color,
    this.inactiveColor,
  }) : assert(count > 0, 'at least one page'),
       index = null;

  /// The number of pages.
  final int count;

  /// Receives the chosen page, from 0 to [count] - 1.
  final ValueChanged<int>? onChanged;

  /// The value read by the screen reader, the page counted from 1:
  /// `(page, count) => 'Page $page of $count'`.
  final String Function(int page, int count) pageLabel;

  /// What the dots stand for ("Introduction"); when null, the value alone.
  final String? semanticLabel;

  /// The current page, from 0 to [count] - 1; null with [controller].
  final int? index;

  /// The followed page view; null with [index].
  final PageController? controller;

  /// Round dots, or the thin bars of an onboarding's header.
  final FlPageIndicatorVariant variant;

  /// The current page's color: over a photo or a colored banner, the color
  /// written on it (`onAccent` on `accent`). When null: `selected` for the
  /// dots, `onSurface` for the bars.
  final Color? color;

  /// The other pages' color. When null: [color] at 40 % for the dots and 30 %
  /// for the bars, or, without [color], `onSurfaceMuted` at 40 % and
  /// `onSurface` at 30 %.
  final Color? inactiveColor;

  @override
  State<FlPageIndicator> createState() => _FlPageIndicatorState();
}

class _FlPageIndicatorState extends State<FlPageIndicator> with SingleTickerProviderStateMixin {
  /// The pill's position in pages, fractional during a movement.
  late final AnimationController _position = AnimationController.unbounded(vsync: this, value: _target.toDouble());

  bool _focused = false;

  PageController? get _controller => widget.controller;

  /// The page to reach: the given index, or the controller's rounded page.
  int get _target => (widget.index ?? _controllerPage(widget.controller!).round()).clamp(0, widget.count - 1);

  double _controllerPage(PageController controller) => controller.hasClients && controller.position.hasContentDimensions
      ? controller.page ?? controller.initialPage.toDouble()
      : controller.initialPage.toDouble();

  @override
  void initState() {
    super.initState();
    _controller?.addListener(_follow);
  }

  @override
  void didUpdateWidget(FlPageIndicator old) {
    super.didUpdateWidget(old);
    final previous = old.controller;
    if (previous != _controller) {
      previous?.removeListener(_follow);
      _controller?.addListener(_follow);
      _position.value = _target.toDouble();
      return;
    }
    final index = widget.index;
    if (index != null && index != old.index) {
      if (FlMotion.of(context).reduced) {
        _position.value = index.toDouble();
      } else {
        _position.driveTo(index.toDouble(), FlSpringMotion(FlScope.of(context).motion.indicator));
      }
    }
  }

  @override
  void dispose() {
    _controller?.removeListener(_follow);
    _position.dispose();
    super.dispose();
  }

  /// A page view is followed with the finger, without a spring; the value
  /// read changes halfway.
  void _follow() {
    final before = _position.value.round();
    _position.value = _controllerPage(_controller!);
    if (_position.value.round() != before) setState(() {});
  }

  void _choose(int page) {
    final clamped = page.clamp(0, widget.count - 1);
    if (clamped == _target) return;
    final onChanged = widget.onChanged;
    if (onChanged != null) {
      onChanged(clamped);
      return;
    }
    final controller = _controller;
    if (controller == null || !controller.hasClients) return;
    if (FlMotion.of(context).reduced) {
      controller.jumpToPage(clamped);
    } else {
      final motion = FlScope.of(context).motion;
      controller.animateToPage(clamped, duration: motion.medium2, curve: motion.standard);
    }
  }

  /// The page the drag has reached: the parent or the page view has not
  /// rendered it yet, and each move only acts when it changes.
  int? _scrub;

  /// The iOS drag scrubs through the dots like `UIPageControl`: the page
  /// view jumps there, without animation.
  void _scrubTo(int page) {
    final clamped = page.clamp(0, widget.count - 1);
    if (clamped == (_scrub ?? _target)) return;
    _scrub = clamped;
    final onChanged = widget.onChanged;
    if (onChanged != null) {
      onChanged(clamped);
    } else if (_controller case final controller? when controller.hasClients) {
      controller.jumpToPage(clamped);
    }
  }

  bool get _interactive => widget.onChanged != null || _controller != null;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final colors = theme.colors;
    final xs = theme.space.xs;
    final bars = widget.variant == FlPageIndicatorVariant.bars;
    final metrics = bars
        ? _Metrics(dot: xs * 1.5, pill: xs * 10, gap: xs * 1.25, count: widget.count)
        : _Metrics(dot: xs * 2, pill: xs * 6, gap: xs * 2, count: widget.count);
    final rtl = Directionality.of(context) == TextDirection.rtl;
    final ios = theme.platform == TargetPlatform.iOS;
    final target = _target;
    final interactive = _interactive;
    final forward = rtl ? -1 : 1;

    int pageAt(double x) {
      final along = rtl ? metrics.width - x : x;
      return metrics.nearest(along, target);
    }

    void tap(TapUpDetails details) {
      final x = details.localPosition.dx;
      if (!ios) return _choose(pageAt(x));
      final after = (x > metrics.width / 2) != rtl;
      _choose(target + (after ? 1 : -1));
    }

    final active = widget.color ?? (bars ? colors.onSurface : colors.selected);
    final faded = widget.color ?? (bars ? colors.onSurface : colors.onSurfaceMuted);
    final inactive = widget.inactiveColor ?? faded.withValues(alpha: faded.a * (bars ? _barOpacity : _inactiveOpacity));
    Widget dots = CustomPaint(
      size: Size(metrics.width, metrics.dot),
      painter: _Dots(position: _position, metrics: metrics, active: active, inactive: inactive, rtl: rtl),
    );
    if (interactive) {
      dots = GestureDetector(
        behavior: HitTestBehavior.opaque,
        excludeFromSemantics: true,
        onTapUp: tap,
        onHorizontalDragStart: ios ? (_) => _scrub = target : null,
        onHorizontalDragUpdate: ios ? (details) => _scrubTo(pageAt(details.localPosition.dx)) : null,
        onHorizontalDragEnd: ios ? (_) => _scrub = null : null,
        onHorizontalDragCancel: ios ? () => _scrub = null : null,
        child: dots,
      );
    }
    final canIncrease = interactive && target < widget.count - 1;
    final canDecrease = interactive && target > 0;
    return FocusableActionDetector(
      enabled: interactive,
      mouseCursor: interactive ? SystemMouseCursors.click : MouseCursor.defer,
      shortcuts: {
        const SingleActivator(LogicalKeyboardKey.arrowRight): _StepIntent(forward),
        const SingleActivator(LogicalKeyboardKey.arrowLeft): _StepIntent(-forward),
        const SingleActivator(LogicalKeyboardKey.home): const _EdgeIntent(end: false),
        const SingleActivator(LogicalKeyboardKey.end): const _EdgeIntent(end: true),
      },
      // An arrow that would change nothing is not consumed: it bubbles up
      // to the page, which may use it.
      actions: {
        _StepIntent: _PageAction<_StepIntent>(
          interactive: interactive,
          count: widget.count,
          target: target,
          pageOf: (intent) => target + intent.pages,
          choose: _choose,
        ),
        _EdgeIntent: _PageAction<_EdgeIntent>(
          interactive: interactive,
          count: widget.count,
          target: target,
          pageOf: (intent) => intent.end ? widget.count - 1 : 0,
          choose: _choose,
        ),
      },
      onShowFocusHighlight: (visible) => setState(() => _focused = visible),
      child: FlFocusRing(
        visible: _focused,
        borderRadius: BorderRadius.circular(metrics.dot / 2),
        // The node sits under the target, which gives it its rectangle.
        child: FlHitTarget(
          child: Semantics(
            slider: true,
            label: widget.semanticLabel,
            value: widget.pageLabel(target + 1, widget.count),
            increasedValue: canIncrease ? widget.pageLabel(target + 2, widget.count) : null,
            decreasedValue: canDecrease ? widget.pageLabel(target, widget.count) : null,
            onIncrease: canIncrease ? () => _choose(target + 1) : null,
            onDecrease: canDecrease ? () => _choose(target - 1) : null,
            child: dots,
          ),
        ),
      ),
    );
  }
}

/// Goes to the page [pageOf] derives from the intent; enabled only when
/// that page exists and differs from [target].
class _PageAction<T extends Intent> extends Action<T> {
  _PageAction({
    required this.interactive,
    required this.count,
    required this.target,
    required this.pageOf,
    required this.choose,
  });

  final bool interactive;
  final int count;
  final int target;
  final int Function(T intent) pageOf;
  final ValueChanged<int> choose;

  @override
  bool isEnabled(T intent) {
    final page = pageOf(intent);
    return interactive && page >= 0 && page < count && page != target;
  }

  @override
  void invoke(T intent) => choose(pageOf(intent));
}

class _StepIntent extends Intent {
  const _StepIntent(this.pages);

  final int pages;
}

class _EdgeIntent extends Intent {
  const _EdgeIntent({required this.end});

  final bool end;
}

/// The opacity of the dots, and of the bars, of the other pages.
const double _inactiveOpacity = 0.4;
const double _barOpacity = 0.3;

/// The row's measurements: the pill takes the space of a dot plus
/// `pill - dot`, so the row keeps its width during the movement.
final class _Metrics {
  const _Metrics({required this.dot, required this.pill, required this.gap, required this.count});

  final double dot;
  final double pill;
  final double gap;
  final int count;

  double get width => count * dot + (pill - dot) + (count - 1) * gap;

  /// The pill share of dot [i] when the pill is at [position]: 1 on its
  /// page, 0 one page away or more.
  double share(int i, double position) => (1 - (position - i).abs()).clamp(0.0, 1.0);

  /// The dot whose center is nearest to [along], measured from the start,
  /// with the pill on [page].
  int nearest(double along, int page) {
    var start = 0.0;
    var best = 0;
    var distance = double.infinity;
    for (var i = 0; i < count; i++) {
      final width = i == page ? pill : dot;
      final d = (start + width / 2 - along).abs();
      if (d < distance) (best, distance) = (i, d);
      start += width + gap;
    }
    return best;
  }
}

class _Dots extends CustomPainter {
  _Dots({
    required this.position,
    required this.metrics,
    required this.active,
    required this.inactive,
    required this.rtl,
  }) : super(repaint: position);

  final Animation<double> position;
  final _Metrics metrics;
  final Color active;
  final Color inactive;
  final bool rtl;

  @override
  void paint(Canvas canvas, Size size) {
    final radius = Radius.circular(metrics.dot / 2);
    var start = 0.0;
    for (var i = 0; i < metrics.count; i++) {
      final share = metrics.share(i, position.value);
      final width = metrics.dot + (metrics.pill - metrics.dot) * share;
      final left = rtl ? size.width - start - width : start;
      canvas.drawRRect(
        RRect.fromLTRBR(left, 0, left + width, metrics.dot, radius),
        Paint()..color = Color.lerp(inactive, active, share)!,
      );
      start += width + metrics.gap;
    }
  }

  @override
  bool shouldRepaint(_Dots old) =>
      old.position != position ||
      old.metrics.dot != metrics.dot ||
      old.metrics.pill != metrics.pill ||
      old.metrics.gap != metrics.gap ||
      old.metrics.count != metrics.count ||
      old.active != active ||
      old.inactive != inactive ||
      old.rtl != rtl;
}

Arrows to move, Enter to open, Escape to close.