Top 10 Best Flutter List Tile Widgets: Patterns, Variants and M3 Migration (2026)
Top 10 Flutter ListTile widgets for clean rows with leading and trailing icons, titles, and subtitles — with code examples and GetWidget's GFListTile.
Across the ten industries we ship Flutter in, ListTile shows up everywhere a settings screen, a chat thread, a notification feed, or any list of selectable rows needs to render. The choice between Flutter's built-in ListTile, one of its variants (CheckboxListTile, SwitchListTile, RadioListTile, ExpansionTile), a community package, or a hand-rolled Row looks small at design time and turns into an accessibility, performance, or theming problem once the app hits real users. This guide walks through the flutter list tile widget patterns we actually ship, when each one earns its place, the Material 3 defaults that changed under teams who upgraded without checking, and the production-grade fixes that most ListTile tutorials skip.
Two framing notes. Pub.dev currently lists more than 40 actively maintained ListTile-adjacent packages. Most of them solve the same problem with cosmetic variations. The patterns worth knowing solve something the built-in ListTile cannot do cleanly: expandable rows with state retention, swipe-dismissible rows, persistent timeline-style rows, or virtualised long lists where every frame matters. The rest is taste. Second: Material 3 changed enough ListTile defaults (text styles, leading-trailing alignment, state-driven colors) that a useMaterial3: true flip on an existing app will visibly shift every list. Read the M3 section before you upgrade.
When ListTile is the right primitive (and when it isn't)
ListTile ships with Flutter. It takes a leading widget, a title, an optional subtitle, a trailing widget, and an onTap callback. For 80% of row-style UI (settings screens, contact lists, notification feeds, message previews) it is the right answer. No package, no extra dependency, no custom layout math. The reason to reach for anything else is almost always one of four specific needs: a stateful variant (checkbox or switch or radio), an expandable row, swipe-to-dismiss, or a layout the ListTile padding and height contract simply cannot reach.
The pattern that bites teams: skipping ListTile and writing a custom Row with Padding to get pixel-exact spacing. That custom row almost always loses the 48dp minimum tap target, the built-in InkWell ripple, the implicit Semantics that names this row to a screen reader, and the dense / visualDensity hooks that let the system shift heights for accessibility settings. We have audited apps where every settings row was a hand-rolled Row, and re-fixing accessibility cost more than the original layout work would have.
| Need | Use | Why |
|---|---|---|
| Settings row, contact row, notification row | ListTile | 48dp tap target, ripple, Semantics, theming — all free |
| Row with a toggle that updates state | SwitchListTile | Whole row is the tap target, not just the switch |
| Row with multiselect checkbox | CheckboxListTile | Same logic: whole row is tappable, with M3 Checkbox defaults |
| Single-choice option in a settings group | RadioListTile | Group state is managed via groupValue; preserves a11y |
| Expandable section header with children below | ExpansionTile | Built-in expand/collapse animation and state retention |
| Pixel-exact custom layout that ListTile cannot reach | InkWell + Row inside Material | Keep the ripple and tap target; lose ListTile's contract intentionally |
| Row inside a Card with raised visual treatment | Card with ListTile child | Card handles elevation; ListTile handles structure |
The ListTile anatomy our review checklist uses
ListTile has three horizontal slots (leading, center, trailing) and a vertical contract that depends on how many lines of text it holds. One-line: 56dp tall, leading widget centered vertically. Two-line: 72dp tall, title and subtitle stacked. Three-line: 88dp tall, requires isThreeLine: true explicitly. Most layout bugs we see in code review come from passing two paragraphs into subtitle without isThreeLine, which causes a RenderFlex overflow at the bottom on smaller fonts.
ListTile(
leading: const Icon(Icons.notifications_outlined),
title: const Text('Notifications'),
subtitle: const Text('Push, email, in-app'),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/settings/notifications'),
// Make the whole row a single Semantics node, not five children.
// Without this, screen readers announce each leading/trailing icon
// separately and lose the row context.
); Two properties that earn their keep on every project: contentPadding (override the 16dp horizontal default when nesting inside a Card with its own padding, otherwise rows look indented by 32dp), and minLeadingWidth (force-align leading icons across mixed rows where some have avatars and some have small icons). We set these in ListTileTheme at the app level so individual screens stay clean.
Material 3 changed these ListTile defaults — read before you flip the flag
Setting useMaterial3: true on a ThemeData built around the Material 2 ListTile contract will shift every list visibly. Six things change. First, three new text style properties (titleTextStyle, subtitleTextStyle, leadingAndTrailingTextStyle) replace the M2-era pattern of styling text inside the title widget. Second, the new titleAlignment property controls how leading and trailing widgets align relative to a multi-line title — three options (threeLine, titleHeight, center), where titleHeight is the new M3 default and looks different from M2's center. Third, textColor and iconColor now accept WidgetStateColor, so you can drive selected and disabled state colors from one theme entry rather than four overrides.
ThemeData(
useMaterial3: true,
listTileTheme: ListTileThemeData(
titleTextStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
subtitleTextStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
leadingAndTrailingTextStyle: const TextStyle(fontSize: 14),
titleAlignment: ListTileTitleAlignment.threeLine,
iconColor: WidgetStateColor.resolveWith((states) {
if (states.contains(WidgetState.selected)) return Colors.deepPurple;
if (states.contains(WidgetState.disabled)) return Colors.grey;
return Colors.black87;
}),
minLeadingWidth: 32,
),
); Three more shifts that catch teams. The selected state under M3 paints the row with the secondaryContainer color rather than the M2 grey overlay, so a designer comparing screenshots will flag the change immediately. ListTile inside a Card now inherits the card's surface tint color in its hover state, which is invisible on most screens but ships with a faint M3 elevation tint. And the dense: true shortcut still works but is functionally equivalent to visualDensity: VisualDensity.compact under M3 — pick visualDensity for forward-compatible code.
Top 10 best flutter list tile widget patterns we ship
The patterns and packages below are what we reach for on real client builds. Order reflects how often each one earns its place across the Flutter apps we ship in healthcare, fintech, ecommerce, and the other seven industries on our books. Maintenance status reflects pub.dev as of May 2026.
1. ListTile (built-in): the default for 80% of rows
Built-in ListTile with a theme-level ListTileThemeData is the highest-impact pattern in this list. It ships with the right tap target, the right ripple, the right Semantics, and the right height contract. The one rule we enforce in code review: if a row would have been a hand-rolled Row, justify why ListTile cannot reach the design before approving the PR.
2. CheckboxListTile: multiselect rows without losing tap targets
CheckboxListTile makes the entire row the tap target, not just the 24dp checkbox. We use it on filter screens and permission pickers, plus the multiselect contact pickers in CRM-style apps. The controlAffinity prop (leading vs trailing vs platform) lets the same code render the checkbox where each platform expects it — iOS users expect trailing, Material users expect leading.
3. SwitchListTile: settings rows where the whole row toggles
SwitchListTile is the right widget for every notification preference, dark-mode toggle, and feature flag in a settings screen. Same tap-target argument as CheckboxListTile. Under M3 the switch ships with the redesigned track-and-thumb visual; the Switch.adaptive variant of the standalone widget exists but SwitchListTile itself does not yet expose adaptive — render Cupertino settings with a manual swap if iOS parity matters.
4. RadioListTile: single-choice from a group
RadioListTile manages group state via groupValue and value props. It is the right widget for currency pickers, theme pickers, and any single-choice settings group. The accessibility win: a screen reader announces the row as 'Selected, USD' rather than 'Radio button, USD', because RadioListTile sets the right Semantics.checked flag at the row level.
5. ExpansionTile: collapsible section headers with state retention
ExpansionTile gives you a ListTile-styled header with an animated expand-collapse, and children rendered below when open. We use it for FAQ screens, grouped settings, and any content where a user scans a list of section titles and drills into one. Two production tips: pass a controller (ExpansionTileController) if you need programmatic open or close, and set maintainState: true if the children hold form state — without it, ExpansionTile rebuilds children on every collapse and loses TextField content.
6. GFListTile (GetWidget): themed default for apps already on GetWidget
GFListTile is part of the open-source GetWidget UI Kit we maintain (4,811 stars, 23K monthly pub.dev downloads). It exposes size presets, a built-in description slot, and theming hooks that match the rest of GetWidget. Pull it in when you are already using GetWidget components elsewhere in the app. For one-off list rendering, built-in ListTile plus a ListTileThemeData is the lower-overhead choice.
7. Dismissible-wrapped ListTile: swipe-to-archive rows
Wrap a ListTile in Dismissible to get a Gmail-style swipe-to-archive or swipe-to-delete row. The pattern is one widget, but the gotchas are real: every Dismissible needs a stable key (use the row's record id, not the index), confirmDismiss should prompt the user before a destructive action, and the onDismissed callback must remove the item from the source list before the next build or the framework throws a duplicate-key exception.
8. flutter_expanded_tile: persistent expanded state across rebuilds
Built-in ExpansionTile loses expand state when the parent rebuilds the tree (a common gotcha with StreamBuilder and FutureBuilder above the list). flutter_expanded_tile ships an ExpandedTileController that survives parent rebuilds because state lives outside the widget. We pull this package on apps where the list is driven by a stream that emits often (a chat thread, a live order feed).
9. timeline_tile: vertical timelines with consistent rail alignment
Order-tracking screens, audit logs, and activity feeds want a vertical rail with dots and connectors between rows — a ListTile cannot model that cleanly. timeline_tile renders the rail, the indicator, and the row content in one widget with consistent alignment math. Two trade-offs: it is heavier than ListTile, and accessibility is on you (the rail is decorative; mark it Semantics(excludeSemantics: true)).
10. Custom Row inside Material + InkWell: when ListTile cannot reach the design
When the design needs more than three columns, an irregular height, or a layout ListTile cannot model, the right escape is not 'add another package'. Build a custom Row inside a Material widget, wrap it with InkWell for the ripple, set a fixed constraints with BoxConstraints(minHeight: 48), and wrap the result in Semantics with a button: true label. That gives you ListTile-equivalent tap targets and a11y without the layout contract.
Picking between ListTile variants: a decision matrix
The four ListTile variants (Checkbox, Switch, Radio, Expansion) cover most stateful-row designs. The mistake we see most often: nesting a plain Switch inside a ListTile's trailing slot instead of using SwitchListTile, which means only the 36dp switch is tappable rather than the full 360dp row. The matrix below is how we pick the variant in design review.
| Use case | ListTile + manual control | CheckboxListTile | SwitchListTile | RadioListTile | ExpansionTile |
|---|---|---|---|---|---|
| Multiselect contact picker | Avoid: loses tap target | Yes: best fit | No | No | No |
| Notification on/off settings row | Avoid | No | Yes: best fit | No | No |
| Currency picker (single choice) | Avoid | No | No | Yes: best fit | Inside ExpansionTile if grouped |
| FAQ section with collapsible answer | No | No | No | No | Yes: best fit |
| Chat thread row (avatar, name, preview, time) | Yes: best fit | No | No | No | No |
| Settings row with disclosure arrow | Yes — best fit (trailing: chevron) | No | No | No | If row also contains sub-options |
ListTileTheme: getting consistent styling across the app
Almost every ListTile property has a corresponding ListTileThemeData entry. Set them once in ThemeData.listTileTheme and every list in the app inherits, including ListTiles inside Card and Drawer widgets and inside BottomSheet or Dialog instances. The biggest gain: when design tweaks the leading icon color from #555 to #444, you change one line in the theme rather than 47 ListTile call sites.
ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
listTileTheme: ListTileThemeData(
iconColor: Colors.black87,
textColor: Colors.black87,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
minLeadingWidth: 32,
selectedColor: Colors.deepPurple,
selectedTileColor: Colors.deepPurple.withOpacity(0.08),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
// For one-off overrides on a specific screen, wrap the list
// in ListTileTheme(...) — it inherits unset values from above.
); Two properties that move the needle on visual polish: shape (round the row corners under M3 to match Card and BottomSheet radii — 12dp is the M3 default for medium containers), and selectedTileColor (the M3 secondaryContainer fill at low opacity is the right visual cue for a selected row). Both are set once at the theme level and propagate everywhere.
Performance: ListTile in long lists is where Flutter apps jank
A scrolling list of ListTile widgets is one of the most common sources of frame-time blowouts in Flutter apps we audit. The widget itself is cheap. The problem is what teams put inside it — an uncached NetworkImage in the leading slot, a heavyweight build method in title, a StreamBuilder that rebuilds the entire row when one field changes.
ListView.builder(
itemCount: contacts.length,
itemBuilder: (context, index) {
final c = contacts[index];
return RepaintBoundary(
child: ListTile(
leading: CircleAvatar(
backgroundImage: CachedNetworkImageProvider(c.photoUrl),
),
title: Text(c.name),
subtitle: Text(c.lastMessage),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/chat/${c.id}'),
),
);
},
); Accessibility: what ListTile gives you and what it doesn't
ListTile is the most accessible row primitive in Flutter. It enforces a 48dp minimum tap target, emits a single Semantics node per row by default, announces the row as a button when onTap is set, and inherits the system's text scaling. That is more than most custom Row implementations ship with. The three gaps that catch teams:
| Gap | Symptom | Fix |
|---|---|---|
| Decorative leading icons announced separately | Screen reader reads icon name (e.g. 'Notifications icon') before the row title | Wrap leading in ExcludeSemantics, or set MergeSemantics on the row parent |
| Trailing chevron read as 'Chevron right button' | Two interactive announcements for one tap target | Pass null for trailing's Semantics or wrap in ExcludeSemantics |
| Selected state not announced | Screen reader does not say 'selected' on the current row | Use ListTile selected: true and set selectedColor; the Semantics flag follows |
| Three-line content overflow | Text clips silently at small font scales | Set isThreeLine: true; do not rely on subtitle to render two paragraphs |
The simplest accessibility upgrade on most Flutter apps we audit: wrap every list with MergeSemantics at the row level and ExcludeSemantics on every decorative icon. Two lines of code per row, and the screen-reader experience moves from unusable to acceptable in one pass.
The five ListTile bugs we see in code review every month
| Bug | What goes wrong | Fix |
|---|---|---|
| RenderFlex overflow on small fonts | Two-paragraph subtitle clips at the bottom at text-scale 1.3 and above | Set isThreeLine: true on the ListTile |
| dense vs visualDensity confusion | dense: true silently sets visualDensity to compact, overriding theme defaults | Pick one: drop dense in favor of explicit visualDensity in the theme |
| Infinite-height layout exception | ListTile inside a Column without a SingleChildScrollView throws unbounded constraint errors | Wrap in Expanded or use ListView; never put ListTile in a free Column for more than one row |
| Switch / Checkbox tap target collapses | Plain Switch in trailing slot only makes the 36dp switch tappable, not the row | Replace with SwitchListTile or CheckboxListTile |
| onTap fires on subtitle Text long-press | Custom GestureDetector inside title or subtitle interferes with ListTile's gesture handling | Move the gesture handler to ListTile's onLongPress; do not nest GestureDetectors |
Migrating from Material 2 ListTile to Material 3
If you are flipping useMaterial3: true on an existing app, walk this checklist before merging. The first four items move screenshots; the last two prevent silent regressions in theming behavior.
| M2 pattern | M3 replacement | Notes |
|---|---|---|
| TextStyle inside title: Text(style: ...) | titleTextStyle on ListTileThemeData | Single source of truth; survives theme switches |
| Custom selected highlight via Container wrap | selectedColor + selectedTileColor on ListTile | Inherits M3 secondaryContainer; respects state |
| dense: true | visualDensity: VisualDensity.compact | Forward-compatible; future M4 may deprecate dense |
| Static iconColor for all states | WidgetStateColor.resolveWith on iconColor | Selected, disabled, pressed states unified |
| Manual height via SizedBox wrapper | titleAlignment + visualDensity | Lets the framework manage 56/72/88 dp contract |
| Trailing IconButton with own Semantics | Trailing Icon + ExcludeSemantics | One Semantics node per row, not two |
For the leading-slot widget patterns that most often pair with ListTile — avatars, status indicators, icons — see our guide to flutter avatar widgets. For how list rows fit into a production Flutter app (state plus performance and navigation and CI/CD) ourFlutter mobile app development field guide covers the practices we enforce on every build.
Common questions about the Flutter list tile widget
What is the flutter list tile widget used for?
ListTile is a single-row primitive for any list of items with a leading slot, a title, an optional subtitle, and a trailing slot. It enforces a 48dp tap target, a built-in InkWell ripple, the right Semantics for screen readers, and the M3 height contract (56dp one-line, 72dp two-line, 88dp three-line). Most settings screens, contact lists, chat threads, and notification feeds use it as the row primitive.
When should I use CheckboxListTile instead of ListTile with a trailing Checkbox?
Whenever you want the whole row to toggle the checkbox, not just the 24dp checkbox itself. CheckboxListTile makes the entire row the tap target and routes the Semantics correctly, so a screen reader announces the row as 'Checked' or 'Not checked' rather than 'Checkbox button'. The same logic applies to SwitchListTile and RadioListTile.
Does Material 3 change how ListTile looks?
Yes. Flipping useMaterial3: true changes six things: three new text style properties (titleTextStyle, subtitleTextStyle, leadingAndTrailingTextStyle), the default titleAlignment shifts to the M3 titleHeight rule, textColor and iconColor accept WidgetStateColor for state-driven theming, the selected row paints with secondaryContainer rather than the M2 grey overlay, ListTile inside a Card picks up surface tint, and dense: true becomes equivalent to visualDensity: compact. Update screenshots before merging the M3 flip.
How do I make ListTile rounded?
Set shape on the ListTile or, better, on ListTileThemeData so every row in the app inherits. The M3 default for medium containers is 12dp; for tight settings rows we use 8dp. Combine with selectedTileColor to get a rounded selected highlight that matches the M3 Card and BottomSheet styling.
Why does my ListTile throw a layout exception inside Column?
ListTile expects bounded width but a free Column inside a Column or inside a Scaffold body can give unbounded vertical constraints. For more than one row, switch to ListView or ListView.builder. For a single row in a Column, wrap in Flexible or set a SizedBox with explicit height. Never put a long list of ListTile widgets in a non-scrolling Column — the framework will accept it on small lists and break later.
How do I improve ListTile performance in a long list?
Three rules. Use ListView.builder, not ListView with a children list, the moment the list crosses about 20 rows. Wrap each ListTile in a RepaintBoundary when rows hold images. const-ify every static child (const Icon, const Text). That combination gets us from 38fps to 60fps on a 500-row contact list on a mid-range Android device.
Is ListTile accessible by default?
Mostly. It emits a single Semantics node, enforces a 48dp tap target, and announces the row as a button when onTap is set. The three gaps to fix: wrap decorative leading icons in ExcludeSemantics, wrap trailing chevrons the same way, and set selected: true (not just a custom highlight color) so the screen reader announces the selected state. Two lines of code per row moves the screen-reader experience from unusable to acceptable.
What is the difference between ListTile and GFListTile?
ListTile ships with Flutter — no dependency, no bundle cost. GFListTile is part of the open-source GetWidget UI Kit we maintain and adds size presets, a built-in description slot, and theming hooks consistent with the rest of GetWidget. Pick GFListTile when you are already using GetWidget components elsewhere; pick built-in ListTile when list rows are an isolated need.
How do I make ExpansionTile remember its state across rebuilds?
Built-in ExpansionTile loses state when its parent rebuilds (a common gotcha with StreamBuilder or FutureBuilder above the list). Two options: pass an ExpansionTileController and manage state in a parent StatefulWidget, or use the flutter_expanded_tile package whose ExpandedTileController lives outside the widget tree. For chat threads and live feeds, the package is the lower-friction choice.
Part of the /Flutter App Development Company series.