| name | variant-modeling |
| description | Modeling component variants as a closed matrix of size × intent × state, with explicit enums and slot APIs. Use this when designing or refactoring a component's public API. |
Variant Modeling
Instructions
Every durable component has a small, named set of variants. Model them as an explicit matrix, not a pile of booleans.
1. The Three-Axis Matrix
Most interactive primitives vary across:
- Size —
sm, md, lg (sometimes xs, xl).
- Intent —
primary, secondary, tertiary, danger, success.
- State —
default, hover, pressed, focused, disabled, loading.
Size and intent are props set by the caller. State is derived from interaction and accessibility.
2. Closed Enums, Not Booleans
Three booleans (primary, danger, loading) yield 8 illegal combinations and one compiler-untyped mess. One enum per axis is self-documenting.
Kotlin (Compose):
enum class ButtonIntent { Primary, Secondary, Tertiary, Danger }
enum class ButtonSize { Small, Medium, Large }
@Composable
fun Button(
label: String,
onClick: () -> Unit,
intent: ButtonIntent = ButtonIntent.Primary,
size: ButtonSize = ButtonSize.Medium,
enabled: Boolean = true,
loading: Boolean = false,
leadingIcon: @Composable (() -> Unit)? = null,
) { }
Swift:
public enum DSButtonIntent { case primary, secondary, tertiary, danger }
public enum DSButtonSize { case small, medium, large }
public struct DSButton: View {
let intent: DSButtonIntent
let size: DSButtonSize
let isEnabled: Bool
let isLoading: Bool
}
Dart:
enum DsButtonIntent { primary, secondary, tertiary, danger }
enum DsButtonSize { small, medium, large }
TypeScript:
export type ButtonIntent = 'primary' | 'secondary' | 'tertiary' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
3. Token Resolution Per Variant
A component token table maps (intent, size, state) to semantic tokens — once, at the bottom of the component file.
{
"button": {
"primary": {
"default": { "bg": { "$value": "{color.action.primary.bg}" },
"fg": { "$value": "{color.action.primary.fg}" } },
"hover": { "bg": { "$value": "{color.action.primary.bgHover}" } },
"disabled":{ "bg": { "$value": "{color.action.primary.bgDisabled}" },
"fg": { "$value": "{color.content.muted}" } }
},
"danger": {
"default": { "bg": { "$value": "{color.feedback.danger.bg}" },
"fg": { "$value": "{color.feedback.danger.fg}" } }
}
}
}
4. Slots for Content Flexibility
Where callers need to inject markup, prefer slots (child parameters) over another prop. Slots let you ship one component that handles text, icon-only, icon+text, and custom content without a new variant.
@Composable
fun Button(
onClick: () -> Unit,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
content: @Composable RowScope.() -> Unit,
)
<Button onPress={save}>
<Icon name="check" />
<Text>Save</Text>
</Button>
5. Disallowed Combinations
When a combination does not exist (e.g., tertiary + danger), either:
- Omit it from the token table and fall back with a deterministic message (log in debug, render a safe default in release), or
- Encode the valid subset as a sealed type so the compiler rejects it.
sealed class ButtonVariant {
data class Primary(val size: ButtonSize) : ButtonVariant()
data class Danger (val size: ButtonSize) : ButtonVariant()
data class Ghost (val size: ButtonSize) : ButtonVariant()
}
6. States Are Not Props
pressed, hover, focused, and loading are derived, not passed in:
- Compose:
InteractionSource → collectIsPressedAsState().
- SwiftUI:
ButtonStyle receives configuration.isPressed.
- Flutter:
WidgetStateProperty (formerly MaterialStateProperty).
- RN:
Pressable's pressed render prop.
Only loading and disabled are explicit props, because they are app-level concerns.
7. Anti-Patterns
- Boolean explosion (
isPrimary, isDanger, isGhost).
- Inline
if/else cascades in the render method instead of a variant lookup table.
- Exposing hover/pressed as caller props.
- Adding a new variant without extending the snapshot matrix.
Checklist