How to build Compose UI without Material — using only Foundation and UI layers. Use this skill whenever building custom components without Material 3, creating a design system from scratch, replacing Material components with Foundation equivalents, building custom themes with CompositionLocal, or creating custom press/hover indication. Also triggers on: 'compose without material', 'no material3', 'BasicText', 'BasicTextField', 'custom indication', 'compose foundation primitives', 'replace Material with Foundation', 'CompositionLocal theme', 'compose custom button'.
Build a complete UI toolkit using only Compose Foundation + UI layers. Zero Material dependency. This is how you build real design systems.
The Compose stack has layers. You only need the bottom three:
Your Design System (Gort, etc.)
↓
Compose Foundation (Row, Column, BasicText, clickable, etc.)
↓
Compose UI (Modifier, Layout, Canvas, graphics)
↓
Compose Runtime (State, remember, Composable, recomposition)
Never import: androidx.compose.material3.* or androidx.compose.material.*
| Foundation | Replaces | Notes |
|---|---|---|
Box | Surface | Stack layout — your base container |
BasicText | Text | No theming — you style it |
BasicTextField | TextField/OutlinedTextField | Raw input — you build decoration |
BasicSecureTextField | — | Password input (1.7+) |
Modifier.clickable | — | Click + indication |
Modifier.combinedClickable | — | Click + long press + double click |
Image | Icon | Display vectors/bitmaps |
MutableInteractionSource | — | Track press/hover/focus states |
Indication/IndicationNodeFactory | ripple() | Custom press feedback |
All layout primitives (Row, Column, Spacer, LazyColumn, FlowRow, etc.), drawing modifiers (background, border, clip, drawBehind, graphicsLayer), and animations (AnimatedVisibility, AnimatedContent, spring(), tween()) are Foundation — no Material needed.
RoundedCornerShape(8.dp)
RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
CircleShape
RectangleShape
CutCornerShape(8.dp)
GenericShape { size, _ -> /* custom path */ }
data class MyColors(
val primary: Color, val onPrimary: Color,
val surface: Color, val onSurface: Color,
val border: Color, val error: Color,
)
data class MyTypography(
val display: TextStyle, val headline: TextStyle,
val body: TextStyle, val label: TextStyle,
)
data class MySpacing(
val xs: Dp = 4.dp, val sm: Dp = 8.dp,
val md: Dp = 12.dp, val lg: Dp = 16.dp, val xl: Dp = 24.dp,
)
val LocalMyColors = staticCompositionLocalOf { MyColors.light() }
val LocalMyTypography = staticCompositionLocalOf { MyTypography() }
val LocalMySpacing = staticCompositionLocalOf { MySpacing() }
@Composable
fun MyTheme(colors: MyColors = MyColors.light(), content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalMyColors provides colors,
LocalMyTypography provides MyTypography(),
LocalMySpacing provides MySpacing(),
) { content() }
}
object MyTheme {
val colors: MyColors @Composable get() = LocalMyColors.current
val typography: MyTypography @Composable get() = LocalMyTypography.current
val spacing: MySpacing @Composable get() = LocalMySpacing.current
}
@Composable
fun MyButton(onClick: () -> Unit, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
if (isPressed) 0.97f else 1f,
spring(dampingRatio = Spring.DampingRatioMediumBouncy)
)
Box(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(RoundedCornerShape(8.dp))
.background(MyTheme.colors.primary)
.clickable(interactionSource = interactionSource, indication = MyIndication) { onClick() }
.padding(horizontal = MyTheme.spacing.lg, vertical = MyTheme.spacing.md),
contentAlignment = Alignment.Center,
) { content() }
}
Material uses ripple(). Without Material, build your own:
object MyIndicationFactory : IndicationNodeFactory {
override fun create(interactionSource: InteractionSource): DelegatableNode {
return MyIndicationNode(interactionSource)
}
}
private class MyIndicationNode(
private val interactionSource: InteractionSource,
) : Modifier.Node(), DrawModifierNode {
private var isPressed = false
override fun onAttach() {
coroutineScope.launch {
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> isPressed = true
is PressInteraction.Release, is PressInteraction.Cancel -> isPressed = false
}
}
}
}
override fun ContentDrawScope.draw() {
drawContent()
if (isPressed) drawRect(Color.Black.copy(alpha = 0.08f))
}
}
// Surface replacement
@Composable
fun Surface(modifier: Modifier = Modifier, color: Color = MyTheme.colors.surface, content: @Composable () -> Unit) {
Box(modifier = modifier.clip(RoundedCornerShape(8.dp)).background(color).padding(MyTheme.spacing.md)) { content() }
}
// Divider replacement
@Composable
fun Divider(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxWidth().height(1.dp).background(MyTheme.colors.border))
}
// Checkbox replacement
@Composable
fun Checkbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Box(
modifier = Modifier.size(24.dp)
.border(2.dp, MyTheme.colors.border)
.background(if (checked) MyTheme.colors.primary else Color.Transparent)
.clickable { onCheckedChange(!checked) },
contentAlignment = Alignment.Center,
) { if (checked) BasicText("✓", style = TextStyle(color = MyTheme.colors.onPrimary)) }
}
// Haptics (Android)
val haptic = LocalHapticFeedback.current
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
// Window insets (Android)
Modifier.windowInsetsPadding(WindowInsets.systemBars)
Modifier.windowInsetsPadding(WindowInsets.ime)
// Pointer (Desktop/Web)
Modifier.pointerHoverIcon(PointerIcon.Hand)
// Text selection
SelectionContainer { BasicText("Selectable text") }
| ❌ Don't | ✅ Do Instead |
|---|---|
Import androidx.compose.material3.* | Use Foundation equivalents |
Pass style params to components (backgroundColor: Color) | Read from theme — MyTheme.colors.primary |
Use animateContentSize on lists/tables | It recalculates layout every frame |
Use Column + verticalScroll for large lists | Use LazyColumn |
Animate with tween for interactive elements | Use spring — feels more natural |
Forget key in LazyColumn items | Always: items(list, key = { it.id }) |
graphicsLayer for animation — scale/alpha don't trigger relayoutderivedStateOf for computed values — prevents unnecessary recompositionremember expensive calculations — parsing, sorting, filteringCompositionLocalProvider with accessor objectIndication replaces rippleLazyColumn for scrollable lists (not Column + scroll)