Phase 2 · Building Interactive UIsModule 24~40 min read

Card, Surface & ListItem: Reusable Content

Choose Material containers, understand tonal and shadow elevation, use content colors and slots, and give interactive content clear ownership.

Goals & prerequisites

A card communicates that several pieces of information belong to one subject. A surface provides a Material background and content-color context. Knowing the difference helps you build components that remain clear when their content, theme, or interactions change.

Complete layouts, state hoisting, and the Material 3 introduction first. This lesson teaches you to select a container, expose a useful component contract, apply theme roles, and give a card one understandable primary action. Its examples use androidx.compose.material3 throughout.

Choose the container by its job

Do not wrap every paragraph in a card. Use grouping when it helps someone understand the screen. A reading list may use plain list items separated by dividers, while a lesson overview can group its title, summary, and progress in one card.

ComponentUseful responsibilityKeep in mind
CardA filled container for one coherent subjectIts content uses a ColumnScope; add your own inner padding
ElevatedCardA card distinguished with elevationAvoid relying on shadow alone to communicate selection
OutlinedCardA card distinguished with an outlineOutline and container colors should still come from the theme
SurfaceA shaped Material container and content-color contextChoose a layout inside when displaying several children
ListItemA conventional row with headline and optional supporting/trailing slotsIt does not become an action merely because it resembles a row
HorizontalDividerA visual boundary between related regionsA divider is not a button or a substitute for a heading

Slots & component contracts

A content slot is a composable lambda. It allows the caller to provide UI while the component provides structure. ListItem names its slots by purpose: headlineContent, supportingContent, leadingContent, and trailingContent. Those names are a useful guide to information hierarchy.

Your own card does not need a slot for every detail. Start with domain inputs such as title and completion state, plus an event such as onOpen. Add a content slot when callers truly need different content. Accept a default modifier and apply it once to the root so callers can place the entire component.

Color, shape & elevation

Theme colors have roles. Pair a container color with its matching “on” color so text remains legible across light and dark schemes. Material components expose content colors through composition locals; an ordinary Text inside a correctly configured surface can inherit that color. Explicit text colors override the inherited choice.

ContainerChoices.kt
// Focused alternatives inside a composable; use material3 imports.
Card { Text("Filled card", Modifier.padding(16.dp)) }
ElevatedCard { Text("Elevated card", Modifier.padding(16.dp)) }
OutlinedCard { Text("Outlined card", Modifier.padding(16.dp)) }

Surface(
    color = MaterialTheme.colorScheme.primaryContainer,
    contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
    shape = MaterialTheme.shapes.medium,
    tonalElevation = 2.dp,
) {
    Column(Modifier.padding(16.dp)) {
        Text("Study reminder", style = MaterialTheme.typography.titleMedium)
        Text("Continue with the next lesson.")
    }
}
Focused alternatives inside your existing theme. Import the shown APIs from Material 3 and layout APIs from Compose Foundation.

tonalElevation and shadowElevation serve different visual purposes. Tonal elevation affects eligible surface coloring; shadow elevation controls the shadow. Neither is layout padding. Rounded corners belong to shape; adding a background modifier alone does not reproduce a Material component's color and interaction behavior.

Build a reusable lesson card

Create LessonCardDemo.kt in your app package, keep the package declaration, and add the following imports and declarations. Call LessonCardDemo() inside your generated app theme in setContent. No icon pack, image resource, or navigation dependency is needed.

LessonCardDemo.kt
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp

@Composable
fun LessonCard(
    title: String,
    description: String,
    completed: Boolean,
    onOpen: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Card(
        onClick = onOpen,
        modifier = modifier,
        shape = MaterialTheme.shapes.large,
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surfaceContainer,
        ),
    ) {
        Column(
            modifier = Modifier.padding(20.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Text(
                text = title,
                style = MaterialTheme.typography.titleLarge,
                maxLines = 2,
                overflow = TextOverflow.Ellipsis,
            )
            Text(description, style = MaterialTheme.typography.bodyMedium)
            Surface(
                color = MaterialTheme.colorScheme.secondaryContainer,
                contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
                shape = MaterialTheme.shapes.small,
            ) {
                Text(
                    text = if (completed) "Completed" else "Ready to study",
                    modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
                    style = MaterialTheme.typography.labelLarge,
                )
            }
        }
    }
}

@Composable
fun LessonCardDemo(modifier: Modifier = Modifier) {
    var opened by rememberSaveable { mutableStateOf(false) }
    var completed by rememberSaveable { mutableStateOf(false) }
    Column(
        modifier = modifier.verticalScroll(rememberScrollState()).padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        LessonCard(
            title = "Build reusable Compose components",
            description = "Practice slots, theme colors and accessible actions.",
            completed = completed,
            onOpen = { opened = true },
            modifier = Modifier.fillMaxWidth(),
        )
        Text(if (opened) "Lesson opened in this local demo." else "Tap the lesson card.")
        TextButton(onClick = { completed = !completed }) {
            Text(if (completed) "Mark as incomplete" else "Mark as complete")
        }
        HorizontalDivider()
        ListItem(
            headlineContent = { Text("Study time") },
            supportingContent = { Text("Suggested reading and practice") },
            trailingContent = { Text("30 min") },
        )
    }
}
Complete composable example with a stateless reusable card and a small state-owning demonstration screen. Replace English literals with resources when localizing.

The parent owns completion and the opened message. LessonCard displays values and forwards an event; it does not invent a second completion state. The completion button sits outside the clickable card, so the two actions have separate targets. “Opened” is a local demonstration message; a real application can supply a navigation callback.

Choose one owner for the primary click

Use the clickable Card(onClick = ...) overload for an actionable card. A plain card is a container. Avoid adding a second clickable modifier to the same actionable root. Its built-in action already coordinates interaction indication, semantics, and enabled behavior.

A card may legitimately contain secondary actions, but design their meaning and targets explicitly. For a beginner component, keeping the whole-card action separate from secondary buttons is easier to understand and verify. Hoist events so a preview can supply an empty callback and a screen can supply navigation or another real action.

Flexible content is part of accessibility

Let descriptions wrap and cards grow vertically. A fixed height that fits one English title can cut off translated text or large fonts. The example truncates only the title after two lines; decide whether that is acceptable for your product and expose the full title on the destination screen. Never truncate essential warnings or instructions just to preserve symmetry.

Check TalkBack reading order and the card's announced action. A visible “Completed” label communicates more than a color change. Test both color schemes, long content, and increased font size. Keep decorative artwork out of the accessibility tree when its meaning is already present in text.

Diagnose common mistakes

SymptomCause to inspectRepair
Text touches the card edgeCard content has no inner paddingPut padding on an inner Column
Dark-mode text becomes hard to readHard-coded colors or mismatched container/content rolesUse paired theme colors and inspect inherited contentColor
One tap exposes two competing actionsClick handling exists at several overlapping levelsGive each intended action one deliberate target
A card ignores caller widthThe incoming modifier was dropped or applied to a childForward it to the card root

Key idea

Visual decoration does not create component behavior. Start with the appropriate Material component, then customize the parameters that express the design.

Practice: add useful metadata

  1. Add an estimatedMinutes input to the reusable card.
  2. Display the estimate below the summary using a supporting typography and color role.
  3. Verify a long title, a multi-line summary, dark mode, and large system text.

Expected result: the estimate changes when its caller changes, the entire card still opens once, and the completion action remains a separate target. Content grows without clipping.

Open the reference solution
LessonMetadata.kt
// Extend LessonCard's parameters with:
estimatedMinutes: Int,

// Inside the padded Column, after the description, add:
Text(
    text = "Estimated study time: $estimatedMinutes minutes",
    style = MaterialTheme.typography.bodySmall,
    color = MaterialTheme.colorScheme.onSurfaceVariant,
)

// At the call site, add:
estimatedMinutes = 30,

// For localization, use stringResource for labels and pluralStringResource
// for this quantity. Do not combine translated fragments with a number.

Keep this metadata as an input. A card should not calculate study estimates by reading a repository during composition.

Recap & knowledge check

Key takeaways

  • A card groups one subject; Surface supplies a Material container and content-color context.
  • Add internal padding deliberately and forward caller modifiers to the root.
  • Use theme color roles and distinguish tonal elevation from shadow elevation.
  • Hoist state, expose clear events, and verify actions with assistive technology.

Quick check

1. Why does text touch the edge of a new Card?

2. A Surface uses secondaryContainer. Which content color is its natural pair?

3. Who should own completion state in the reusable LessonCard example?

Continue with official documentation