Skip to content
Blog

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 — inside ListViews, 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.