Skip to content
Development
Skill

/compose-slot-api-pattern

Use when designing or reviewing a reusable Jetpack Compose component whose visual regions vary by caller, or when primitive content parameters and boolean shape flags are accumulating. Technique-layer skill — complements the codebase-specific compose-expert.

From plugin
amethyst
1.6k30 skills3 commands
Install
$ npx -y skills add vitorpamplona/amethyst --skill compose-slot-api-pattern --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/compose-slot-api-pattern

Context preview

The summary Claude sees to decide when to auto-load this skill.

Use when designing or reviewing a reusable Jetpack Compose component whose visual regions vary by caller, or when primitive content parameters and boolean shape flags are accumulating. Technique-layer skill — complements the codebase-specific compose-expert.

SKILL.md

compose-slot-api-pattern.SKILL.md
name: compose-slot-api-pattern
description: Use when designing or reviewing a reusable Jetpack Compose component whose visual regions vary by caller, or when primitive content parameters and boolean shape flags are accumulating. Technique-layer skill — complements the codebase-specific compose-expert.

Compose: slot API pattern

Core principle

A reusable Compose component's job is to lay things out, not to enumerate what it lays out. The moment you write `title: String, subtitle: String?, leadingIcon: ImageVector?, trailingIcon: ImageVector?, trailingText: String?, showSwitch: Boolean, switchValue: Boolean, onSwitchChange: (Boolean) -> Unit?, badge: String?, …`, the component has stopped describing a layout and started enumerating call sites — and the next call site will need a parameter the component doesn't have.

The fix is to **delegate content to the caller** via `@Composable` lambda parameters. The component contributes structure (where the leading bit, headline, supporting bit, trailing bit go). The caller contributes everything that goes *in* those slots.

Material 3's `ListItem` is the canonical example: every visual piece is a slot (`headlineContent`, `supportingContent`, `leadingContent`, `trailingContent`, `overlineContent`), not a primitive. That's not over-engineering — it's the design that scales to every list-item shape the design system needs without ever editing `ListItem` again.

When to use this skill

You're designing or reviewing a Compose component intended for reuse (more than one call site, now or planned), its visual content varies by caller, and any of these is true:

  • Its signature has `title: String`, `icon: ImageVector`, `actionText: String?`, etc. — primitive types describing *content*.
  • It has multiple optional-content parameters that vary by call site (`subtitle: String?`, `leadingIcon: ImageVector?`, `trailingText: String?`).
  • It has boolean flags whose only purpose is to switch between content shapes (`showChevron: Boolean`, `showSwitch: Boolean`, `mode: Mode.Text | Mode.Switch | …`).
  • It accepts a `String` parameter where one caller would want a `Text` with custom style, a second caller a `Text` with a `Badge`, a third caller a row of icons.
  • It already has *one* slot (often `trailing` or `content`) and the rest of the parameters are still primitives.

1. Replace primitive content with `@Composable` slots

Where the component asks for caller-controlled *content*, prefer a `@Composable () -> Unit` slot. Where the slot is structurally required, leave it non-nullable with no default. Where it's optional, make it nullable with a `null` default.

// ❌ BAD — primitive parameters; trailing area is the only slot; everything else is locked
@Composable
fun SettingsRow(
    title: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    subtitle: String? = null,
    leadingIcon: ImageVector? = null,
    trailing: (@Composable () -> Unit)? = null,
) { … }

This shape *seems* fine because the call sites today fit (`title` is always single-line text, `leadingIcon` is always an `ImageVector`). The problem is the *next* call site: a row with a `Badge` next to the title, a leading slot that's a circular avatar (not an `ImageVector`), a subtitle that's a row of chips. Each forces either a new parameter, a new flag, or a workaround.

// ✅ GOOD — every visual region is a slot; the row describes structure, not content
@Composable
fun SettingsRow(
    headlineContent: @Composable () -> Unit,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    supportingContent: (@Composable () -> Unit)? = null,
    leadingContent: (@Composable () -> Unit)? = null,
    trailingContent: (@Composable () -> Unit)? = null,
) { … }

Call sites stay short because the typical content is a one-liner:

SettingsRow(
    headlineContent = { Text("Account") },
    leadingContent = { Icon(Icons.Default.Person, contentDescription = null) },
    trailingContent = { SettingsRowDefaults.Chevron() },
    onClick = { … },
)

And the awkward cases that *would* have required new primitive parameters now don't:

SettingsRow(
    headlineContent = {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Text("Inbox")
            Spacer(Modifier.width(8.dp))
            Badge { Text("3") }
        }
    },
    onClick = { … },
)

Slot naming

  • Use `xxxContent` for free-form `@Composable () -> Unit` slots (`headlineContent`, `supportingContent`, `trailingContent`) — matches Material 3.
  • Use a singular noun (`title`, `icon`, `actions`) when the slot is semantically constrained and the component name disambiguates (`Scaffold(topBar = { … }, bottomBar = { … }, floatingActionButton = { … })`).
  • Don't use `content` *and* other `xxxContent` slots together — pick one convention per component.

2. Scope receivers when the slot emits into a layout

If the slot's content will sit inside a `Row`/`Column`/`Box` whose layout features (`Modifier.weight`, `BoxScope.matchParentSize`, alignment) should be available to the caller, declare the slot as a receiver lambda: `@Composable RowScope.() -> Unit`.

// ❌ BAD — actions render inside a Row, but callers can't use RowScope.weight()
@Composable
fun MyTopBar(
    title: @Composable () -> Unit,
    actions: @Composable () -> Unit = {},   // ← caller has no Row scope
)
// ✅ GOOD — caller gets RowScope; .weight() and alignment-by works inside
@Composable
fun MyTopBar(
    title: @Composable () -> Unit,
    actions: @Composable RowScope.() -> Unit = {},
)

This is what makes `TopAppBar(actions = { IconButton(…); IconButton(…) })` work — the caller is implicitly inside a `RowScope`.

Don't bolt a scope receiver onto every slot reflexively. The receiver should match the actual parent layout the slot emits into. If the slot is rendered inside a `Box`, use `BoxScope`. If it's inside a `Column`, use `ColumnScope`. If the parent is

Read more
Ships withamethyst

Nostr client for Android

Get the whole plugin

Other skills on amethyst.