Add a Typing Indicator to Your FlutterFlow AI Chatbot
Your FlutterFlow support chatbot works. The user types a question, taps send, and 3–8 seconds later a helpful WidgetChat reply appears. The problem is those 3–8 seconds: the screen sits frozen, the send button looks dead, and the user assumes the app crashed. Half of them tap send again — and now you have a duplicate request.
The fix is a live typing indicator: a temporary bubble with animated dots that you push into the same ListView as your real messages, then swap out for the WidgetChat reply when it lands. Below is the exact insert-a-placeholder pattern, including the two things that trip everyone up — auto-scroll timing and double-appending on retries.
Why a placeholder bubble beats a separate spinner
The naive approach is a CircularProgressIndicator floating somewhere with a Visibility toggle. It works, but it never sits in the message flow, so it looks bolted-on and it doesn't scroll with the conversation.
The better pattern treats "typing…" as just another message. Your chat is already a ListView bound to a list of messages. So you make the typing bubble a real entry in that list, distinguished by a flag. One list, one source of truth, one auto-scroll rule.
Step 1 — Give each message an isTyping flag
In FlutterFlow, create a Custom Data Type called ChatMessage with these fields:
text— StringisUser— Boolean (true = from the user, false = from the bot)isTyping— Boolean (true only for the placeholder)
Store the conversation as a Page State (or App State) variable messages of type List<ChatMessage>. FlutterFlow auto-generates addToMessages(...) and removeFromMessages(...) helpers for list state, which you'll use in the action flow.
Step 2 — Render the bubble conditionally in the ListView
Inside your ListView → generated item, wrap the bubble content in a Conditional Value. When isTyping is true, show your animated-dots widget; otherwise show the normal Text. Because both live in the same generated item, the typing bubble aligns, indents, and scrolls exactly like a real bot message.
Step 3 — The animated dots (a FlutterFlow custom widget)
FlutterFlow has no built-in animated-dots widget, so add a small Custom Widget. This uses a single AnimationController with staggered intervals — the same technique Flutter's official typing-indicator cookbook recommends — so the three dots pulse in sequence:
import 'package:flutter/material.dart';
class TypingDots extends StatefulWidget {
const TypingDots({
super.key,
this.width,
this.height,
this.dotColor = Colors.grey,
this.dotSize = 8.0,
});
final double? width;
final double? height;
final Color dotColor;
final double dotSize;
@override
State<TypingDots> createState() => _TypingDotsState();
}
class _TypingDotsState extends State<TypingDots>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
// Stagger each dot's animation window across the timeline.
final start = i * 0.2;
final anim = CurvedAnimation(
parent: _controller,
curve: Interval(start, start + 0.6, curve: Curves.easeInOut),
);
return FadeTransition(
opacity: Tween<double>(begin: 0.3, end: 1.0).animate(anim),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2.0),
child: Container(
width: widget.dotSize,
height: widget.dotSize,
decoration: BoxDecoration(
color: widget.dotColor,
shape: BoxShape.circle,
),
),
),
);
}),
);
}
}
Drop that into the isTyping == true branch of your conditional. Prefer no code? The flutter_typing_indicator package on pub.dev exposes an equivalent widget you can register the same way.
Step 4 — The action flow on "Send"
Wire the send button's Action Flow in this order:
- Add to
messages— the user's message (isUser: true,isTyping: false). - Add to
messages— the placeholder (isUser: false,isTyping: true,text: ''). - Scroll To action → target your ListView → End (200 ms).
- API / Action Call — send the user's text to WidgetChat and wait for the reply.
- On success, swap the placeholder for the real reply (Step 5).
The user sees their message, then dots, instantly — no dead air.
Step 5 — Swap the placeholder for the real reply (without races)
Doing "remove placeholder" and "add reply" as two separate FlutterFlow actions can flicker or, worse, double-append if the call retried. Do it atomically in one Custom Action that rebuilds the list. FlutterFlow generates a ChatMessageStruct for your data type, so the signature is clean:
// Custom Action: resolveTypingWithReply
// Returns a new list with every typing placeholder removed and the
// reply appended exactly once. Idempotent — safe to call on retries.
List<ChatMessageStruct> resolveTypingWithReply(
List<ChatMessageStruct> messages,
String replyText,
) {
// Drop any in-flight typing bubbles (there should only ever be one).
final cleaned =
messages.where((m) => !(m.isTyping)).toList();
// Guard against a duplicate append if this fires twice for one reply.
final last = cleaned.isNotEmpty ? cleaned.last : null;
final alreadyAdded =
last != null && !last.isUser && last.text == replyText;
if (!alreadyAdded) {
cleaned.add(
ChatMessageStruct(
text: replyText,
isUser: false,
isTyping: false,
),
);
}
return cleaned;
}
Then add an Update Page State action: set messages = resolveTypingWithReply(messages, <WidgetChat reply>). Because the function filters all typing entries first, a stray second placeholder can never survive.
Gotcha 1 — Auto-scroll fires before the row exists
If you scroll immediately after adding the reply, the ListView hasn't laid out the new row yet, so you land a pixel short of the bottom. Schedule the scroll after the frame renders. FlutterFlow's Scroll To → End action handles most cases, but if you scroll from a custom action, use a post-frame callback:
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.animateTo(
controller.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
);
});
A simpler alternative: set the ListView's Reverse property to true and query/order messages by timestamp ascending. The newest item then naturally renders at the bottom and you rarely need to scroll manually at all.
Gotcha 2 — Retries that double-append
When the WidgetChat call is slow, users tap send again, or your own "retry on failure" branch re-runs the API call. Two safeguards keep the list clean:
- One placeholder, always. Because
resolveTypingWithReplystrips everyisTypingentry before appending, extra placeholders self-heal. - Disable send while in-flight. Add a Page State bool
isSending, set it true before the API call and false after, and bind the send button's Enabled to!isSending. This stops the duplicate request at the source.
With those in place, a flaky network produces exactly one typing bubble and exactly one reply — never a stack of orphaned dots.
The result
Same ListView, one isTyping flag, one atomic swap. The user sends a message, sees animated dots within a frame, and watches them turn into a real WidgetChat answer — no frozen screen, no duplicate messages, no bolted-on spinner.
Try WidgetChat free
WidgetChat drops a production-ready AI support chatbot into your Flutter or FlutterFlow app in minutes — you bring the UI polish like this typing indicator, we handle the answers. Try WidgetChat free and give your users a chatbot that never feels broken while it thinks.





Comments
Comments are coming soon. We'd love to hear your thoughts!