Building a Custom Page Indicator in Flutter: From Dots to Worms
Build a fast, accessible Flutter page indicator with PageController and CustomPainter, plus worm animation, theming, RTL, and tap-to-jump support.
Image used for representation purposes only.
Why build a custom page indicator?
Flutter ships with powerful scrolling APIs, but it leaves page indicators up to you. Packages are handy, yet a custom indicator gives you:
- Pixel-perfect branding and motion language
- Minimal dependencies and tighter control over performance
- Flexibility to support accessibility, RTL, and theming the way you need
This guide walks through building a high-performance, animated page indicator that listens to a PageController, paints dots efficiently with CustomPainter, supports a sliding and a worm animation, and is tappable for direct navigation.
Architecture at a glance
- PageView + PageController: drives the current page offset as a double via controller.page.
- AnimatedBuilder: rebuilds the indicator when the controller notifies scroll changes.
- CustomPaint + CustomPainter: draws inactive dots once and the active indicator on every frame.
- GestureDetector: lets users tap a dot to jump to that page.
All animations are derived from the page offset, so everything stays in sync with scroll physics.
Quick start: drop-in example
The snippet below builds a simple carousel with a custom indicator overlaid at the bottom. Copy into a main.dart and run.
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Custom Indicator Demo',
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
home: const HomePage(),
debugShowCheckedModeBanner: false,
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final controller = PageController(viewportFraction: 0.9);
static const count = 5;
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
PageView.builder(
controller: controller,
itemCount: count,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 32),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.primaries[index % Colors.primaries.length].shade200,
borderRadius: BorderRadius.circular(24),
),
child: Center(
child: Text(
'Page ${index + 1}',
style: Theme.of(context).textTheme.displayMedium,
),
),
),
);
},
),
Positioned(
left: 0,
right: 0,
bottom: 24,
child: Center(
child: PageDotsIndicator(
controller: controller,
count: count,
dotSize: 10,
spacing: 12,
activeColor: Theme.of(context).colorScheme.primary,
inactiveColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
isWorm: true,
onDotTapped: (index) {
controller.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
},
),
),
),
],
),
);
}
}
class PageDotsIndicator extends StatelessWidget {
const PageDotsIndicator({
super.key,
required this.controller,
required this.count,
this.dotSize = 8,
this.spacing = 8,
this.activeColor = Colors.black,
this.inactiveColor = const Color(0x33000000),
this.isWorm = false,
this.onDotTapped,
this.semanticLabel,
});
final PageController controller;
final int count;
final double dotSize;
final double spacing;
final Color activeColor;
final Color inactiveColor;
final bool isWorm;
final ValueChanged<int>? onDotTapped;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final label = semanticLabel ?? 'Page indicator';
return RepaintBoundary(
child: Semantics(
label: label,
liveRegion: false,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: onDotTapped == null
? null
: (details) {
final box = context.findRenderObject() as RenderBox?;
if (box == null) return;
final local = box.globalToLocal(details.globalPosition);
final index = _indexFromOffset(local.dx);
if (index != null) onDotTapped!(index);
},
child: AnimatedBuilder(
animation: controller,
builder: (context, _) {
final page = controller.hasClients
? (controller.page ?? controller.initialPage.toDouble())
: controller.initialPage.toDouble();
return CustomPaint(
size: Size((dotSize * count) + (spacing * (count - 1)) + 16, dotSize * 2.2),
painter: _DotsPainter(
count: count,
page: page.clamp(0.0, (count - 1).toDouble()),
dotSize: dotSize,
spacing: spacing,
active: activeColor,
inactive: inactiveColor,
isWorm: isWorm,
textDirection: Directionality.of(context),
),
);
},
),
),
),
);
}
int? _indexFromOffset(double dx) {
final step = dotSize + spacing;
final start = 8.0; // same horizontal padding used in painter
final width = (count - 1) * step + dotSize;
final left = start;
final right = start + width;
if (dx < left || dx > right) return null;
final i = ((dx - left) / step).round().clamp(0, count - 1);
return i;
}
}
class _DotsPainter extends CustomPainter {
_DotsPainter({
required this.count,
required this.page,
required this.dotSize,
required this.spacing,
required this.active,
required this.inactive,
required this.isWorm,
required this.textDirection,
});
final int count;
final double page; // clamped 0..count-1
final double dotSize;
final double spacing;
final Color active;
final Color inactive;
final bool isWorm;
final TextDirection textDirection;
@override
void paint(Canvas canvas, Size size) {
final step = dotSize + spacing;
final diameter = dotSize;
final radius = diameter / 2;
final paintInactive = Paint()..color = inactive;
final paintActive = Paint()..color = active;
// Center the row horizontally with small side padding.
final contentWidth = (count - 1) * step + diameter;
final startX = ((size.width - contentWidth) / 2).clamp(8.0, double.infinity);
final centerY = size.height / 2;
// Draw inactive dots.
for (var i = 0; i < count; i++) {
final cx = startX + radius + (i * step);
canvas.drawCircle(Offset(cx, centerY), radius, paintInactive);
}
// Compute LTR or RTL progression.
double progress = page;
if (textDirection == TextDirection.rtl) {
progress = (count - 1) - page;
}
final leftIndex = progress.floor();
final t = (progress - leftIndex).clamp(0.0, 1.0);
final leftX = startX + radius + (leftIndex * step);
final rightX = startX + radius + (leftIndex == count - 1 ? leftIndex * step : (leftIndex + 1) * step);
if (!isWorm) {
// Sliding dot
final cx = leftX + (rightX - leftX) * t;
canvas.drawCircle(Offset(cx, centerY), radius, paintActive);
} else {
// Worm: stretch then settle
final stretch = t < 0.5 ? t * 2 : 1.0;
final shrink = t < 0.5 ? 0.0 : (t - 0.5) * 2;
final from = leftX - radius;
final to = rightX + radius;
final rectLeft = from + (to - from) * shrink;
final rectRight = from + (to - from) * stretch;
final rrect = RRect.fromLTRBR(
rectLeft,
centerY - radius,
rectRight,
centerY + radius,
Radius.circular(radius),
);
canvas.drawRRect(rrect, paintActive);
}
}
@override
bool shouldRepaint(covariant _DotsPainter old) {
return old.count != count ||
old.page != page ||
old.dotSize != dotSize ||
old.spacing != spacing ||
old.active != active ||
old.inactive != inactive ||
old.isWorm != isWorm ||
old.textDirection != textDirection;
}
}
What you get:
- Inactive dots are cheap to draw and rarely change.
- The active indicator follows the precise scroll offset.
- Worm mode stretches then settles, producing a lively transition.
- Tap a dot to jump directly to a page.
Design knobs and theming
- dotSize and spacing: control density and visual weight. For small UIs, 6–8 px works well; for carousels, 10–12 px feels balanced.
- Colors: pipe values from ThemeData for dark mode. For example, use colorScheme.primary for active and onSurface with opacity for inactive.
- isWorm: toggle between minimalist sliding and expressive worm.
Accessibility and semantics
- Use Semantics to expose the indicator. The example wraps the painter, but you can add more detail:
Semantics(
label: 'Page ${(controller.page ?? 0).round() + 1} of $count',
increasedValue: 'Next page',
decreasedValue: 'Previous page',
child: yourIndicator,
)
- Ensure tap targets are at least 44 px high. Increase the CustomPaint height if needed.
- For screen readers, the PageView itself should remain focusable and swipeable; do not intercept semantics focus.
RTL support
The painter mirrors progress when the TextDirection is RTL. That means the first page appears on the right and the active indicator animates right-to-left in locales like Arabic or Hebrew without changing your PageView.
Performance tips
- CustomPainter is fast because it draws with a few vector calls. Keep allocations out of paint.
- Wrap in RepaintBoundary to isolate repaints from the rest of the UI tree.
- AnimatedBuilder avoids setState in the indicator; the controller is already a Listenable.
- Keep the canvas work simple: draw inactive dots, then one active element. Resist per-dot animations.
Making the dots tappable and discoverable
The example maps tap positions to an index by dividing by the step size. You can enhance UX by:
- Adding a short haptic feedback on tap
- Animating to the chosen page with a curve and 250–350 ms duration
- Showing tooltips on long press if appropriate
onDotTapped: (i) async {
HapticFeedback.selectionClick();
await controller.animateToPage(
i,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
);
}
Variations you can build quickly
- Segmented progress bar
AnimatedBuilder(
animation: controller,
builder: (context, _) {
final p = controller.hasClients ? (controller.page ?? 0.0) : 0.0;
final value = (p + 1) / count; // 0..1
return ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(minHeight: 6, value: value),
);
},
)
- Expanding active dot
// Inside painter after computing t and leftX/rightX
final scale = 1.0 + 0.3 * (1.0 - (t - 0.5).abs() * 2.0); // 1..1.3..1
final r = radius * scale;
final cx = leftX + (rightX - leftX) * t;
canvas.drawCircle(Offset(cx, centerY), r, paintActive);
- Numeric indicator
AnimatedBuilder(
animation: controller,
builder: (context, _) {
final p = controller.hasClients ? (controller.page ?? 0.0) : 0.0;
final current = p.round() + 1;
return Text('$current / $count', style: Theme.of(context).textTheme.labelLarge);
},
)
Edge cases and gotchas
- controller.page is null until the first frame. Always fall back to initialPage.
- When count is 1, consider hiding the indicator.
- If you rebuild with a new count, clamp the page: min(page, count - 1).
- For infinite carousels, map the virtual index back into a finite range before painting.
Testing checklist
- Golden tests: verify dot positions and colors at 0%, 50%, and 100% transitions.
- Semantics tests: ensure labels like page X of N are present.
- Tap hit-testing: simulate taps near dot boundaries and assert the correct index.
- RTL snapshot: wrap with Directionality.rtl and confirm mirroring.
Production polish
- Respect reduced motion: if the user requests fewer animations, snap the active indicator without worm stretching.
- Add a parameter for alignment and padding to fit varied layouts.
- Expose animation curve and duration for programmatic page jumps.
Wrap up
A custom page indicator in Flutter is a small but high-impact UI detail. By deriving animation from PageController and painting with CustomPainter, you get a smooth, theme-aware, accessible component with minimal overhead. Start with the sliding dot, toggle worm mode when you want character, and expand into progress bars or numeric indicators as your product evolves.
Related Posts
Flutter Custom Dialog Animations: Practical Patterns and Reusable Code
Build polished Flutter custom dialog animations with showGeneralDialog: fade-scale, slide-up, and 3D flip patterns, plus reusable code and tips.
Flutter Google Maps Custom Markers: Assets, Widgets, SVG, and Clustering
Build custom Google Maps markers in Flutter—assets, SVG, widget-to-bitmap, clustering, animations, and custom info windows—with performance tips.
Flutter + Rive: A Complete Guide to Interactive Animations
A practical guide to integrating Rive animations in Flutter: setup, state machines, inputs, performance, testing, and fixes.