Pantrix UI

Card

A container for one subject: a header, a body and actions on a surface of their own, with media edge to edge above them.

import com.pantrixui.components.container.card.PantrixCard

Examples

At its plainest

Its shortest form: a PantrixCard and its body. No variant was given, so it is Elevated. The lines of the body stand one under another, with the theme's small spacing between them.

The first card was given no width: it is as wide as its widest line. The second was given fillMaxWidth() and fills the space it is in.

PantrixCard {
    PantrixText(text = "Weekly summary", style = PantrixTheme.typography.title)
    PantrixText(text = "Three new orders, one return")
}
PantrixCard(modifier = Modifier.fillMaxWidth()) {
    PantrixText(text = "Monthly summary", style = PantrixTheme.typography.title)
    PantrixText(text = "A width was given: the card fills the space it is in")
}

Three variants

variant picks how the card is set apart from what it stands on. Elevated is in the theme's surface colour and is lifted by a shadow; on iOS it has no shadow, and the surface colour alone sets the card apart. Filled is not lifted and is set apart by the neutral role's fill: it is for a card on a surface that is already raised. Outlined is edged by a hairline in the theme's divider colour: it is for dense lists of cards, where many shadows become noise.

PantrixCard(modifier = Modifier.fillMaxWidth(), variant = CardVariant.Elevated) {
    PantrixText(text = "Elevated card", style = PantrixTheme.typography.title)
    PantrixText(text = "The default variant: set apart by a shadow")
}
PantrixCard(modifier = Modifier.fillMaxWidth(), variant = CardVariant.Filled) {
    PantrixText(text = "Filled card", style = PantrixTheme.typography.title)
    PantrixText(text = "Set apart by colour instead of a shadow")
}
PantrixCard(modifier = Modifier.fillMaxWidth(), variant = CardVariant.Outlined) {
    PantrixText(text = "Outlined card", style = PantrixTheme.typography.title)
    PantrixText(text = "Set apart by a hairline")
}

Media, header and actions

The card's three slots. media stands at the very top, above the header too, and takes none of the card's padding: it reaches the card's edges, and the card cuts its corners. header is above the body and is usually the card's title. footer gathers the actions in a row at the card's end edge; the main action stands last.

Since the card was given a width, the media spans that width and the actions sit against the end edge.

PantrixCard(
    modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(),
    header = {
        PantrixText(
            text = "Compose Multiplatform",
            style = PantrixTheme.typography.title,
            role = TextRole.Heading(level = 3)
        )
    },
    media = {
        PantrixImage(
            painter = painterResource(Res.drawable.compose_multiplatform),
            modifier = Modifier.height(140.dp).background(PantrixTheme.colors.info.container),
            contentDescription = null
        )
    },
    footer = {
        PantrixButton(text = "Later", onClick = {}, variant = ButtonVariant.Text, tagKey = "article_later")
        PantrixButton(text = "Read", onClick = {}, tagKey = "article_read")
    },
    tagKey = "article"
) {
    PantrixText(text = "Android, iOS, desktop and web from a single codebase.")
}

Of one height in a row

The cards of a row are as tall as the tallest: the row is given height(IntrinsicSize.Min), and each card weight(1f) and fillMaxHeight(). A card given a height is filled by its body: the footer stands at the card's foot, the button of the plan with the short summary too. A child of the body given Modifier.weight(1f) takes the room between and pushes the price down to the button.

A card given no height is as tall as what it holds, and its footer stands right under its body.

Row(
    modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
    horizontalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.md)
) {
    PLANS.forEach { plan ->
        PantrixCard(
            modifier = Modifier.weight(1f).fillMaxHeight(),
            variant = CardVariant.Outlined,
            footer = { PantrixButton(text = "Choose", onClick = {}, tagKey = "plan_${plan.key}") },
            tagKey = "plan_${plan.key}"
        ) {
            PantrixText(text = plan.name, style = PantrixTheme.typography.title)
            PantrixText(text = plan.summary, color = PantrixTheme.colors.textSecondary)
            Spacer(Modifier.weight(1f))
            PantrixText(text = plan.price, style = PantrixTheme.typography.callout)
        }
    }
}

private val PLANS = listOf(
    Plan("solo", "Solo", "For one app.", "Free"),
    Plan("team", "Team", "For a team of up to five, with a shared theme and shared components.", "400 ₺ a month")
)

private class Plan(val key: String, val name: String, val summary: String, val price: String)

The whole card as one button

Given onClick, the whole card is one button. That is the accessible way to build a card that opens something: a screen reader hears one target, with the card's text or, if one is given, with contentDescription. Press the first card: the app's snackbar lets you know.

The second card's enabled is false: it cannot be pressed and gives its content the theme's disabled colour; this colour of its text is what says that it cannot be pressed. A card without onClick ignores enabled.

val snackbars = LocalPantrixSnackbars.current
val scope = rememberCoroutineScope()
PantrixCard(
    modifier = Modifier.fillMaxWidth(),
    variant = CardVariant.Outlined,
    onClick = { scope.launch { snackbars?.show(message = "Order #1043 opened", tagKey = "order_opened") } },
    contentDescription = "Open order 1043",
    tagKey = "order_1043"
) {
    PantrixText(text = "Order #1043", style = PantrixTheme.typography.title)
    PantrixText(text = "The whole card is one button", color = PantrixTheme.colors.textSecondary)
}
PantrixCard(modifier = Modifier.fillMaxWidth(), variant = CardVariant.Outlined, onClick = {}, enabled = false) {
    PantrixText(text = "Order #1039", style = PantrixTheme.typography.title)
    PantrixText(text = "Cancelled, cannot be opened")
}

In its own colours

With colors the card takes the given colours, not its variant's; a field that is left out stays the variant's.

The first card was given only a fill, and that fill is the colour of one of the theme's roles: the theme itself pairs it with the colour that reads on it. The second card's fill is in none of the theme's roles; it is the app's own purple, and the theme cannot know what reads on it, so content is given too. On the third card, which is Outlined, only the edge changed.

val own = ownColors()
PantrixCard(
    modifier = Modifier.fillMaxWidth(),
    variant = CardVariant.Filled,
    colors = CardColors(container = PantrixTheme.colors.info.container)
) {
    PantrixText(text = "New feature", style = PantrixTheme.typography.title)
    PantrixText(text = "Only the fill was given; the theme paired the text's colour with it")
}
PantrixCard(
    modifier = Modifier.fillMaxWidth(),
    variant = CardVariant.Filled,
    colors = CardColors(container = own.container, content = own.onContainer),
    tagKey = "own_colors"
) {
    PantrixText(text = "Membership", style = PantrixTheme.typography.title)
    PantrixText(text = "Both the fill and the content colour were given")
}
PantrixCard(
    modifier = Modifier.fillMaxWidth(),
    variant = CardVariant.Outlined,
    colors = CardColors(border = own.color)
) {
    PantrixText(text = "Selected plan", style = PantrixTheme.typography.title)
    PantrixText(text = "Only the edge's colour was given")
}

Shape, padding and style

shape changes the corners of one card and wins over the style's corners. contentPadding is the room around the header, the body and the actions; its default is spacing.lg, and the second card was given spacing.sm.

style changes the card's geometry: on the last card, a deeper shadow with CardStyle(elevation = 8.dp). Only the field that is given changes, and it changes on every platform: this card has a shadow on iOS too.

PantrixCard(modifier = Modifier.fillMaxWidth(), shape = PantrixTheme.shapes.none) {
    PantrixText(text = "Square corners", style = PantrixTheme.typography.title)
    PantrixText(text = "shape = PantrixTheme.shapes.none")
}
PantrixCard(
    modifier = Modifier.fillMaxWidth(),
    variant = CardVariant.Outlined,
    contentPadding = PaddingValues(PantrixTheme.spacing.sm)
) {
    PantrixText(text = "Tight padding", style = PantrixTheme.typography.title)
    PantrixText(text = "contentPadding = PaddingValues(PantrixTheme.spacing.sm)")
}
PantrixCard(modifier = Modifier.fillMaxWidth(), style = CardStyle(elevation = 8.dp)) {
    PantrixText(text = "Deeper shadow", style = PantrixTheme.typography.title)
    PantrixText(text = "style = CardStyle(elevation = 8.dp)")
}

Until the data arrives: the skeleton

Press "Refresh": for two seconds the two cards are replaced by their skeletons. PantrixCardSkeleton is not drawn to look like a card: it is built from the card's own shape, fill, shadow, padding and spacing. Its bars take the room of what they stand in for: when the data arrives the card's height does not change, and nothing below it moves.

The skeleton cannot know what is in the card: showHeader, lines, footerButtons and mediaHeight are given to match the card it stands in for. The first card has a header, one line and two buttons: lines = 1, footerButtons = 2.

The fill and the content colour of the "Campaign" card were given by the caller. Its skeleton takes the card's colours with colors too; its bars are drawn in the colour given with skeletonColors.

val own = ownColors()
val campaign = CardColors(container = own.container, content = own.onContainer)
var loading by remember { mutableStateOf(false) }
LaunchedEffect(loading) {
    if (loading) {
        delay(2_000)
        loading = false
    }
}
if (loading) {
    PantrixCardSkeleton(modifier = Modifier.fillMaxWidth(), lines = 1, footerButtons = 2)
} else {
    PantrixCard(
        modifier = Modifier.fillMaxWidth(),
        header = {
            PantrixText(
                text = "Order #1042",
                style = PantrixTheme.typography.title,
                role = TextRole.Heading(level = 3)
            )
        },
        footer = {
            PantrixButton(
                text = "Refresh",
                onClick = { loading = true },
                variant = ButtonVariant.Text,
                tagKey = "reload"
            )
            PantrixButton(text = "Track", onClick = {}, tagKey = "track")
        }
    ) {
        PantrixText(text = "Two items, delivery on Thursday")
    }
}
if (loading) {
    PantrixCardSkeleton(
        modifier = Modifier.fillMaxWidth(),
        variant = CardVariant.Filled,
        colors = campaign,
        skeletonColors = SkeletonColors(base = own.onContainer.copy(alpha = 0.3f), highlight = own.container),
        lines = 1
    )
} else {
    PantrixCard(
        modifier = Modifier.fillMaxWidth(),
        variant = CardVariant.Filled,
        colors = campaign,
        tagKey = "campaign"
    ) {
        PantrixText(text = "Campaign", style = PantrixTheme.typography.title)
        PantrixText(text = "Its fill and content colour were given by the caller")
    }
}

API

modifier

A container for one subject: a header, a body and actions on a surface of their own, with media edge to edge above them.

ParameterTypeDefaultWhat it is
modifierModifierModifierthe card's modifier.
variantCardVariantCardVariant.Elevatedhow the card stands off what it sits on: its fill, its shadow and its edge.
onClick(() -> Unit)?nullwhen given, the whole card becomes one button, and a click is reported as a Card event that carries the variant.
enabledBooleantruewhether a card with onClick can be pressed. A card without one ignores it.
styleCardStyle?nullthis call's own style: over every enclosing one and under the density. Only the fields it sets change, on every platform.
shapeShapePantrixTheme.style(CardStyle, style).shape.toShape()the card's corners: by default the resolved style's. Given here, it wins over style's.
colorsCardColors?nullthis card's own colours: the ones it sets, with the variant's for the rest.
contentPaddingPaddingValuesPaddingValues(PantrixTheme.spacing.lg)the room around the header, the body and the footer. The media takes none.
header(@Composable () -> Unit)?nulldrawn above the body: usually the card's title.
media(@Composable () -> Unit)?nulldrawn edge to edge above the padded content, because a picture inset by the card's padding reads as a mistake.
footer(@Composable RowScope.() -> Unit)?nullthe card's actions in a row at the end edge, the main one last: closest to the thumb on a phone, and where the eye finishes on a wide card. In a card taller than what it holds, they stand at its foot.
contentDescriptionString?nullwhat a screen reader says for the card. For a card with onClick, name what it opens.
tagKeyString?nullthe card's automation key: the card is Card_<tagKey>.
interactionSourceMutableInteractionSource?nullthe card's interactions, hoisted. The card keeps a source of its own when this is null.
content@Composable ColumnScope.() -> Unitthe card's body, in a column whose children stand spacing.sm apart. A child given a weight takes the room a card's height leaves.

CardStyle

PantrixCard's geometry: its corners, and the shadow of its elevated variant.

ParameterTypeDefaultWhat it is
shapeShapeSpecShapeSpec.UnspecifiedThe card's corners: medium on Android, large on iOS, small on desktop and the web.
elevationDpDp.UnspecifiedThe shadow under CardVariant.Elevated: 2dp on Android, none on iOS, 1dp on desktop and the web. The other variants draw none.

CardColors

What PantrixCard is painted with, for a card that does not take its variant's colours: a colour that is set is used, and one that is left out stays the variant's.

ParameterTypeDefaultWhat it is
containerColorColor.UnspecifiedThe card's fill. The theme's surface, or the neutral role's container in a CardVariant.Filled card.
contentColorColor.UnspecifiedWhat the card holds is drawn in, unless a child sets a colour of its own: the colour that reads on container. None on the theme's surface, where each child draws in its own default, a text in textPrimary; the neutral role's onContainer in a filled card.
borderColorColor.UnspecifiedThe hairline around the card. The theme's divider on a CardVariant.Outlined card, and where that card is given a fill that is no ground of the theme's, on which that line would not read, a share of what is written there: the edge a subtle button has. The other variants have none unless one is given here.