ListTile Patterns in Flutter: The Row That Powers Every List
ListTile anatomy — leading, title, subtitle, trailing — density and three-line variants, tap handling with InkWell, and when to build a custom row instead.
Published on • November 4, 2026
AI Assistant

If Flutter apps have a signature widget, it’s ListTile: the fixed-height row behind settings screens, inbox lists, dropdown menus, and dialogs. It encodes Material’s row spec so you don’t hand-build Rows with magic numbers (docs.flutter.dev).
The Four Slots
ListTile(
leading: const CircleAvatar(child: Icon(Icons.person)),
title: const Text('Amara Okafor'),
subtitle: const Text('Last seen 2 hours ago'),
trailing: const Icon(Icons.chevron_right),
isThreeLine: false,
dense: false,
onTap: () => _openProfile(),
)
leading— avatar or icon (40×40 default slot).title— primary text; must not wrap intosubtitle’s space.subtitle— secondary line; two-line subtitles needisThreeLine: true.trailing— status, chevron, switch, or menu.
The widget computes minHeight (56dp default, 64dp with isThreeLine… isThreeLine bumps to 72/88dp) and insets everything from contentPadding so rows align across an entire list.
Tap, Long-Press, and Visual Feedback
ListTile doesn’t ripple on its own — pass onTap/onLongPress and it wraps itself in an InkWell with the right Material highlight:
ListTile(
onTap: _open,
onLongPress: _showContextMenu,
selected: _isSelected, // tints leading/title/subtitle
hoverColor: const Color(0x1F6750A4),
)
Setting selected: true is the clean way to mark the active row in a navigation list — no custom colors required. When the row must be non-interactive (a header inside a ExpansionTile), simply omit onTap: it renders without ripple or semantics for activation.
Density and Trailing Controls
dense: true shrinks the row for information-dense UIs (desktop tables, admin panels). For rows whose trailing element is a control — switch, checkbox, radio — keep the whole row tappable and put the control semantics on the trailing widget:
ListTile(
title: const Text('Dark mode'),
trailing: Switch(
value: _dark,
onChanged: (v) => setState(() => _dark = v),
),
onTap: () => setState(() => _dark = !_dark), // row tap toggles too
)
That dual path — control and row both toggle — is what makes settings rows feel right on touch.
Padding and Dividers
contentPadding defaults to 16dp horizontal; EdgeInsets.zero tightens rows inside cards. Dividers are not part of ListTile — use Divider(height: 1) between rows, or ListTile.divideTiles(context: context, tiles: rows) to split a list programmatically.
When to Skip ListTile
ListTile assumes a single text line per slot with fixed metrics. Custom layouts — two-line titles with inline badges, avatars with presence dots, multi-action rows — fight those constraints. The escape hatch is a plain Padding + Row with Expanded text; just remember to add your own Semantics(button: true) if the row is tappable.
Leading, title, subtitle, trailing — get the four slots right and every list in your app inherits Material’s alignment for free.