ExpansionTile and ExpansionPanel in Flutter: Disclosure Widgets Compared
ExpansionTile for list-section disclosure, ExpansionPanel for grouped settings, controlling expansion programmatically, animation gotchas, and accessibility considerations.
Published on • November 4, 2026
AI Assistant

Settings screens and FAQ lists run on one interaction: tap a header, the section grows. Flutter ships two widgets for this — ExpansionTile (list-oriented) and ExpansionPanel (group-oriented) — and they solve slightly different problems (docs.flutter.dev).
ExpansionTile: The List Workhorse
ExpansionTile is a ListTile that owns a collapsible subtree:
ExpansionTile(
title: const Text('Notifications'),
subtitle: const Text('Email, push, weekly digest'),
leading: const Icon(Icons.notifications_none),
initiallyExpanded: false,
shape: const Border(), // remove the default divider lines
children: const [
SwitchListTile(title: Text('Push notifications'), value: true, onChanged: null),
SwitchListTile(title: Text('Email digest'), value: false, onChanged: null),
],
)
Useful details: shape/collapsedShape control the divider lines (set const Border() for seamless rows), leading/subtitle follow normal ListTile conventions, and controlAffinity: ListTileControlAffinity.leading moves the chevron to the left.
Controlling Expansion From Code
The expanded state doesn’t have to live inside the tile. Drive it with a GlobalKey<ExpansionTileState> or, better, an external boolean plus AnimatedBuilder-free rebuild:
ExpansionTile(
key: const ValueKey('filters'),
title: const Text('Filters'),
initiallyExpanded: _filtersOpen,
onExpansionChanged: (open) => setState(() => _filtersOpen = open),
children: _buildFilterChips(),
)
onExpansionChanged fires on every toggle — persist it (e.g. in shared_preferences) so screens reopen the way the user left them.
ExpansionPanel: Grouped Settings
ExpansionPanel is not a standalone widget — it lives inside an ExpansionPanelList, which is designed for a page of independently expandable groups:
ExpansionPanelList(
elevation: 0,
expandedHeaderPadding: EdgeInsets.zero,
children: [
ExpansionPanel(
isExpanded: _accountOpen,
canTapOnHeader: true,
headerBuilder: (context, isOpen) =>
const ListTile(title: Text('Account')),
body: const Padding(
padding: EdgeInsets.all(16),
child: AccountSettingsForm(),
),
),
],
expansionCallback: (panelIndex, isOpen) =>
setState(() => _accountOpen = isOpen),
)
Note the callback shape: expansionCallback receives an index, not a key — you manage the booleans yourself. canTapOnHeader: true makes the whole header tappable, which is the expected behavior on desktop.
Which One Should You Use?
ExpansionTile— insideListViews, one section at a time, when each row should look like a normal list item. Best for FAQs, filters, and profile sections.ExpansionPanelList— a dedicated settings page with several groups, when you want consistent header styling across groups without list semantics.
Flutter’s own guidance notes ExpansionPanel is intentionally bare — it has no built-in icons or subtitles, so most teams wrap the header in a ListTile as above.
Animation and A11y Gotchas
Both widgets animate height with AnimatedSize-style interpolation, which means children with intrinsic height changes mid-animation (async images, text that loads late) can produce jumps. Give children bounded constraints. For semantics, expanded state is announced automatically — but ensure headers are real text (not icon-only), and remember that on web the disclosure is keyboard-focusable via the ListTile’s activation.
Pick ExpansionTile for lists and ExpansionPanel for settings pages, drive expansion from explicit state, and disclosure UI stays predictable as content grows.