Skip to content
Blog

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 into subtitle’s space.
  • subtitle — secondary line; two-line subtitles need isThreeLine: 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.