Cards, Surface, and ListItem in Jetpack Compose

Quick answer: Use Surface when you need a generic visual container, Card when the content is one coherent item that should read as a unit, and ListItem when you are building a standard one-, two-, or three-line row. They can be nested, but each should communicate a different job: surface establishes a visual region, card groups content, and list item standardizes row structure.

Material 3 gives Compose developers several ways to place a visual boundary around content. The names are easy to mix up because all three composables can accept a shape, colors, and a modifier. The difference is not only visual. Choosing the right container makes your hierarchy easier to scan, keeps elevation intentional, and gives accessibility services a more useful semantics tree.

This guide compares Surface, Card, and ListItem, then combines them in settings and feed patterns. The examples assume the Material 3 dependency and an app theme such as AppTheme.

Compare the three containers

ComposablePrimary jobGood fitInteraction model
SurfaceEstablish a generic background, shape, border, or elevation regionScreen sections, custom components, dialogs, and themed panelsNo built-in card intent; add interaction deliberately
CardPresent one coherent piece of content as a bounded unitArticle previews, products, messages, and dashboard modulesUse the clickable overload when the whole card is an action
ListItemProvide a consistent row with leading, headline, supporting, and trailing slotsSettings, navigation rows, notifications, and dense feedsAdd clickable to the row, or expose a separate trailing action

The Material 3 design-system guide describes Surface as a foundation used by many Material components. The official Card guide defines a card as a container for one coherent piece of content. ListItem is the Material component for a standardized list row.

Use Surface for a visual region

Surface is the most general option. It is useful when a component needs a background color, a shape, a border, tonal elevation, or shadow elevation without claiming to be a card. It is a good building block for a settings section or a custom control that has its own layout rules.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun SettingsSection(
    content: @Composable ColumnScope.() -> Unit,
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        shape = MaterialTheme.shapes.large,
        color = MaterialTheme.colorScheme.surfaceVariant,
        tonalElevation = 2.dp,
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            content = content,
        )
    }
}

Prefer a semantic color role such as surfaceVariant and its matching onSurfaceVariant content color over a hard-coded color. That keeps the component usable in light and dark themes. If your Material 3 version provides newer surface-container roles, use the role that matches your design system rather than copying a color from a screenshot.

Surface does not automatically make its content clickable, scrollable, or dismissible. Add those behaviors explicitly with modifiers or a higher-level component. This separation is useful for reusable components because the caller owns the interaction policy.

Use Card for one coherent content unit

A card should have a clear boundary and a single content idea. An article preview, a payment method, or a profile summary can each be a card. A whole screen layout is usually not a card; use Scaffold, a layout container, or a Surface for that shell instead.

A clickable article card

When the entire item opens the same destination, use the clickable Card overload. It supplies the correct card styling and lets the component expose one clear action.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun ArticleCard(
    title: String,
    summary: String,
    onOpen: () -> Unit,
) {
    Card(
        onClick = onOpen,
        modifier = Modifier.fillMaxWidth(),
        shape = MaterialTheme.shapes.large,
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.titleLarge,
            )
            Spacer(Modifier.height(8.dp))
            Text(
                text = summary,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
        }
    }
}

Pass enabled when the card can be temporarily unavailable. Keep loading and navigation state outside the card, just as you would for a Material 3 button. The card renders state and emits onOpen; it should not start a repository request or own a screen-level ViewModel.

Filled, elevated, and outlined cards

Material 3 provides three visual card variants:

  • Card is the default filled container for a clear content boundary.
  • ElevatedCard adds shadow separation when the card must sit above its surrounding surface.
  • OutlinedCard uses a border when a boundary is needed but a shadow would add too much visual weight.

Choose one signal of separation. A screen full of nested shadows is harder to scan, while an outline can preserve grouping on a flat background. Use CardDefaults.cardColors and CardDefaults.cardElevation when a component needs a deliberate variant, and let the theme provide the rest of the palette.

import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun OfflineNotice() {
    OutlinedCard(
        colors = CardDefaults.outlinedCardColors(),
        modifier = Modifier.padding(16.dp),
    ) {
        Text(
            text = "Changes will sync when you are online.",
            modifier = Modifier.padding(16.dp),
        )
    }
}

Cards have no inherent scrolling or dismiss behavior. If a card contains a long body, put scrolling on an intentional child or parent. If it can be swiped away, combine it with an appropriate gesture or dismiss pattern rather than expecting Card to provide one.

Use ListItem for predictable rows

ListItem handles the spacing and alignment for a common row layout. Its slots let you add a leading icon or avatar, headline text, supporting text, an optional overline, and trailing content without rebuilding the same padding rules for every settings screen.

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun PreferenceRow(
    title: String,
    summary: String,
    onClick: (() -> Unit)? = null,
) {
    val rowModifier = Modifier
        .fillMaxWidth()
        .padding(horizontal = 8.dp)
        .then(
            if (onClick == null) {
                Modifier
            } else {
                Modifier.clickable(onClick = onClick)
            },
        )

    ListItem(
        modifier = rowModifier,
        headlineContent = { Text(title) },
        supportingContent = { Text(summary) },
    )
}

The example makes the row action optional. For a read-only information row, omit clickable. For a navigation row, make the whole row one action and use a decorative trailing arrow. For a row with a Switch, checkbox, or other independent control, do not also make the entire row clickable unless you intentionally design and test the nested interaction behavior.

ListItem is especially useful inside a LazyColumn. Keep the list state and item events at the screen boundary, then pass each row only the data and callbacks it needs. The LazyColumn guide covers spacing, stable keys, and list performance in more detail.

Combine them in a settings screen

A common hierarchy is a LazyColumn for scrolling, a Surface or Card for a visual group, and ListItem for each row in that group.

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Card
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

data class Setting(
    val id: String,
    val title: String,
    val summary: String,
)

@Composable
fun SettingsScreen(
    settings: List<Setting>,
    onSettingClick: (Setting) -> Unit,
) {
    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        item {
            Text(
                text = "Preferences",
                style = MaterialTheme.typography.headlineSmall,
            )
        }
        item {
            Card(modifier = Modifier.fillMaxWidth()) {
                settings.forEach { setting ->
                    ListItem(
                        modifier = Modifier.clickable {
                            onSettingClick(setting)
                        },
                        headlineContent = { Text(setting.title) },
                        supportingContent = { Text(setting.summary) },
                    )
                }
            }
        }
    }
}

This compact example is appropriate for a small, fixed settings group. For a large or remote collection, make each row a separate LazyColumn item so Compose can compose and dispose rows incrementally. In either case, provide a stable key when item identity matters and keep state in the screen’s state holder.

Interaction and accessibility details

Material components include useful default semantics, and ListItem merges its descendants into a row-oriented semantics representation. Compose tests also use the merged semantics tree by default. The Compose semantics documentation explains how this tree is exposed to TalkBack and UI tests.

Keep these rules in mind:

  1. Give a clickable card or row one specific action label. Avoid a vague label such as “Open” when “Open account settings” is possible.
  2. Do not put a second clickable child inside a parent that is already one action. Split the layout into separate actions instead.
  3. Set contentDescription = null for decorative icons next to visible text. Give standalone icon buttons a localized description.
  4. Preserve at least a 48dp touch target for interactive controls. Do not reduce the target just to make a dense row look smaller.
  5. Check enabled and disabled states with a screen reader and at large font scales, not only in a preview.

If a trailing control is independently actionable, make that control the action and leave the row itself non-clickable. This avoids ambiguous focus order and accidental toggles when the user intended to open a detail screen.

Elevation, color, and shape choices

Material 3 separates tonal elevation from shadow elevation. Tonal elevation changes the container’s color treatment, while shadow elevation creates a physical separation cue. Start with the defaults from MaterialTheme and increase either signal only when the hierarchy needs it.

The MaterialTheme guide covers color roles, typography, and shapes. Apply those roles consistently:

  • Use surface or a surface-container role for a neutral region.
  • Use the matching onSurface role for text and icons.
  • Use primary and its matching onPrimary role for a strong action, not for every card.
  • Reuse theme shapes such as small, medium, and large so cards and surfaces feel related.

Avoid putting a bright custom color, a border, and a large shadow on the same container. One clear boundary usually communicates the relationship better than three competing effects.

Common mistakes

Using a Card as the screen scaffold

A card is a content unit, not a replacement for Scaffold. Use the screen layout to manage app bars, navigation, insets, and snackbar space; place cards inside that structure.

Rebuilding ListItem with manual rows everywhere

Manual Row layouts are appropriate for unusual designs, but duplicating padding and baseline rules for every settings row creates drift. Start with ListItem, then switch to a custom layout only when the row truly needs a different structure.

Making both the row and trailing control clickable

Nested actions can be difficult for keyboard and TalkBack users. Decide whether the row opens a destination or whether the trailing control changes a value, then expose those as separate, discoverable actions.

Stacking elevation without a hierarchy

Nested ElevatedCards inside an elevated parent can look muddy and may reduce contrast. Prefer one elevated level and use spacing, shape, or an outline for the next boundary.

Putting application state inside a reusable container

Card, Surface, and ListItem should receive state and callbacks. Hoist screen state to a ViewModel or state holder, as shown in State Hoisting in Jetpack Compose, so the same component remains previewable and testable.

A practical decision tree

Ask these questions before choosing a container:

  1. Am I defining a generic visual region or custom component boundary? Use Surface.
  2. Does the content represent one coherent item that should be scanned as a unit? Use Card, ElevatedCard, or OutlinedCard.
  3. Is this a standard row with leading, headline, supporting, or trailing content? Use ListItem.
  4. Does the whole container perform one action? Use a clickable Card or add one clickable modifier to the row.
  5. Are there independent actions inside it? Keep the parent non-clickable and expose each control separately.

FAQ

Should I use Surface or Card for a settings group?

Use Surface when the group is a neutral visual region with custom content. Use Card when the group should read as one bounded item, especially if it is clickable or has a deliberate filled, elevated, or outlined treatment.

Can I put ListItem inside a Card?

Yes. A card can provide the group boundary while each ListItem supplies consistent row alignment. Keep the nesting shallow and decide whether the card or each row owns interaction.

Is ElevatedCard always better than Card?

No. Elevation is a hierarchy signal, not a decoration. Use the elevated variant only when shadow separation is useful; otherwise the default or outlined variant is usually clearer.

Does ListItem support a click callback directly?

Treat the row as a layout component and add interaction with Modifier.clickable when the whole row is an action. This keeps the event and accessibility label under your control. For independent trailing controls, make only the control interactive.

How do I test a clickable card or list row?

Find it through its visible text or a test tag, then assert its enabled state and call performClick(). Also test that nested controls remain independently discoverable when the design contains more than one action.

Summary

Surface is the flexible foundation, Card is the bounded content unit, and ListItem is the standardized row. Use them together to create a clear hierarchy: screen layout outside, visual group in the middle, and predictable rows inside. Keep state hoisted, elevation restrained, and interactions unambiguous. The result is easier to theme, preview, test, and navigate with accessibility services.