| name | composability-patterns |
| description | Slots, compound components, and headless primitives for flexible mobile component APIs. Use this when a component needs to serve many callers without becoming a grab-bag of props. |
Composability Patterns
Instructions
Props scale poorly. A component with 30 props is impossible to evolve. Three patterns let you ship small, stable APIs that still bend to every caller: slots, compound components, and headless primitives.
1. Slots
A slot is a child parameter that the caller fills with arbitrary content. Prefer slots over props whose only purpose is to render something inside the component.
Compose:
@Composable
fun Card(
modifier: Modifier = Modifier,
header: @Composable (() -> Unit)? = null,
footer: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) { }
SwiftUI:
public struct DSCard<Header: View, Content: View, Footer: View>: View {
@ViewBuilder let header: () -> Header
@ViewBuilder let content: () -> Content
@ViewBuilder let footer: () -> Footer
public var body: some View { }
}
Flutter:
class DsCard extends StatelessWidget {
const DsCard({super.key, this.header, required this.child, this.footer});
final Widget? header;
final Widget child;
final Widget? footer;
}
React Native:
type CardProps = { header?: ReactNode; footer?: ReactNode; children: ReactNode };
2. Compound Components
When parts of a component must share state (open/closed, selected tab, focused field), expose them as sub-components that read from a shared context. The parent owns state; children are dumb consumers.
Compose:
class TabsState internal constructor(val selected: String, val onSelect: (String) -> Unit)
val LocalTabsState = compositionLocalOf<TabsState> { error("Tabs.Root required") }
@Composable
fun TabsRoot(selected: String, onSelect: (String) -> Unit, content: @Composable () -> Unit) {
CompositionLocalProvider(LocalTabsState provides TabsState(selected, onSelect)) { content() }
}
@Composable
fun TabsList(content: @Composable RowScope.() -> Unit) { Row { content() } }
@Composable
fun TabsTrigger(value: String, label: String) {
val state = LocalTabsState.current
Button(onClick = { state.onSelect(value) }, label = label,
intent = if (state.selected == value) ButtonIntent.Primary else ButtonIntent.Secondary)
}
Usage:
TabsRoot(selected = tab, onSelect = { tab = it }) {
TabsList {
TabsTrigger("overview", "Overview")
TabsTrigger("billing", "Billing")
}
}
SwiftUI uses EnvironmentKey, Flutter uses InheritedWidget, React Native uses React Context.
3. Headless Primitives
A headless component ships behavior without styling: focus, keyboard handling, state machine. The design system wraps it with styled variants. This is how you avoid reimplementing combobox, disclosure, dialog, and tooltip correctly on four platforms.
Pattern:
class DisclosureState(initialOpen: Boolean = false) {
var isOpen by mutableStateOf(initialOpen)
private set
fun toggle() { isOpen = !isOpen }
}
@Composable
fun Disclosure(state: DisclosureState, trigger: @Composable () -> Unit, panel: @Composable () -> Unit) {
Column(Modifier.semantics { expanded = state.isOpen }) {
Box(Modifier.clickable(onClick = state::toggle)) { trigger() }
if (state.isOpen) panel()
}
}
Then a styled wrapper supplies visuals and tokens:
@Composable
fun Accordion(title: String, content: @Composable () -> Unit) {
val state = remember { DisclosureState() }
Disclosure(
state = state,
trigger = { AccordionHeader(title = title, open = state.isOpen) },
panel = { AccordionBody { content() } },
)
}
4. When to Pick Which
| Need | Pattern |
|---|
| Caller supplies a chunk of content into a known position | Slot |
| Sub-parts need shared state or a required assembly order | Compound |
| Behavior is reusable across many visual treatments | Headless |
| Quick one-liner customization (color, size) | Variant prop |
5. Avoid children Free-For-All
Slots should be named. children inside a Card with implicit ordering creates fragile layouts. Prefer header, media, content, footer as separate slots.
6. Escape Hatches
Every composable primitive should expose a modifier / style passthrough so callers can layer one-off tweaks without forking the component. Never allow overriding a token-bound property (e.g., backgroundColor) — forcing that through a variant prop preserves semantics.
7. Anti-Patterns
- Dozens of
render* props (renderHeader, renderFooter) instead of slots.
- Sub-components usable outside their parent with silent no-op behavior.
- A headless hook that leaks visual tokens (colors, paddings).
- A modifier/style prop that lets callers bypass semantic tokens.
Checklist