Companion to DESIGN.md — the framework-neutral spec. This file ports Instacart's visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, motion, and haptics.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (Instacart's white grocery shelf, green-action accent, carrot-orange deals, the − n + stepper, the persistent cart bar) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,dp/spinstead ofpt, Compose haptics instead ofUIImpactFeedbackGenerator.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for images.
// ui/theme/InstacartColors.kt
import androidx.compose.ui.graphics.Color
object InstacartColors {
// Canvas & Surfaces
val Canvas = Color(0xFFFFFFFF)
val Surface = Color(0xFFF6F7F8)
val Divider = Color(0xFFE8E9EB)
// Text
val TextPrimary = Color(0xFF242529)
val TextSecondary = Color(0xFF72757E)
val TextTertiary = Color(0xFFA6A8AE)
// Brand
val Green = Color(0xFF0AAD0A)
val GreenPressed = Color(0xFF098F09)
val GreenTint = Color(0xFFE6F6E6)
// Deal / Semantic
val Carrot = Color(0xFFFF7009)
val CarrotTint = Color(0xFFFFF0E6)
val Error = Color(0xFFD6203A)
}Wire it into a Material 3 lightColorScheme so ripples, dividers, and default component colors inherit the brand. Instacart is effectively light-only; do not provide a dark scheme unless mirroring system dark with identical brand colors.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val InstacartScheme = lightColorScheme(
primary = InstacartColors.Green,
onPrimary = Color.White,
background = InstacartColors.Canvas,
onBackground = InstacartColors.TextPrimary,
surface = InstacartColors.Canvas,
onSurface = InstacartColors.TextPrimary,
surfaceVariant = InstacartColors.Surface,
onSurfaceVariant = InstacartColors.TextSecondary,
outline = InstacartColors.Divider,
tertiary = InstacartColors.Carrot,
error = InstacartColors.Error,
)
@Composable
fun InstacartTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = InstacartScheme, typography = InstacartTypography, content = content)Instacart uses Inter. Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the system font (Roboto).
// ui/theme/InstacartType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal), // 400
Font(R.font.inter_semibold, FontWeight.SemiBold), // 600
Font(R.font.inter_bold, FontWeight.Bold), // 700
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object InstacartText {
val ScreenTitle = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.4).sp)
val Section = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 25.sp, letterSpacing = (-0.3).sp)
val StoreName = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.2).sp)
val ItemTitle = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp)
val Price = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 18.sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 20.sp)
val StepperValue = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 20.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val Badge = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 14.sp, letterSpacing = 0.2.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.1.sp)
val Caption = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 14.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val InstacartTypography = Typography(
headlineLarge = InstacartText.ScreenTitle,
headlineSmall = InstacartText.Section,
titleMedium = InstacartText.StoreName,
bodyMedium = InstacartText.Body,
labelSmall = InstacartText.Tab,
)For prices and quantities, render with tabular figures via TextStyle(fontFeatureSettings = "tnum").
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun QuantityStepper(
quantity: Int,
onChange: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val haptics = LocalHapticFeedback.current
fun step(delta: Int) {
val next = (quantity + delta).coerceAtLeast(0)
if (next != quantity) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ~iOS light impact
onChange(next)
}
}
Row(
modifier = modifier
.height(32.dp)
.clip(CircleShape)
.background(InstacartColors.Green),
verticalAlignment = Alignment.CenterVertically,
) {
GlyphButton("−") { step(-1) }
Text(
quantity.toString(),
style = InstacartText.StepperValue.copy(fontFeatureSettings = "tnum"),
color = Color.White,
modifier = Modifier.widthIn(min = 24.dp),
)
GlyphButton("+") { step(1) }
}
}
@Composable
private fun GlyphButton(glyph: String, onClick: () -> Unit) {
Box(
Modifier.size(36.dp, 32.dp).clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(glyph, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold)
}
}import androidx.compose.animation.*
@Composable
fun AddOrStepper(modifier: Modifier = Modifier) {
var quantity by remember { mutableStateOf(0) }
AnimatedContent(
targetState = quantity == 0,
transitionSpec = { (fadeIn() + scaleIn()) togetherWith (fadeOut() + scaleOut()) },
label = "addMorph",
modifier = modifier,
) { isEmpty ->
if (isEmpty) {
Box(
Modifier
.height(32.dp)
.clip(CircleShape)
.background(InstacartColors.Canvas)
.border(1.5.dp, InstacartColors.Green, CircleShape)
.clickable { quantity = 1 }
.padding(horizontal = 20.dp),
contentAlignment = Alignment.Center,
) {
Text("Add", color = InstacartColors.Green, fontWeight = FontWeight.Bold, fontSize = 14.sp)
}
} else {
QuantityStepper(quantity = quantity, onChange = { quantity = it })
}
}
}@Composable
fun CartBar(
itemCount: Int,
subtotal: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = itemCount > 0,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(56.dp)
.clip(RoundedCornerShape(12.dp))
.background(if (pressed) InstacartColors.GreenPressed else InstacartColors.Green)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.clip(CircleShape).background(Color.White)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text("$itemCount items", color = InstacartColors.Green,
style = InstacartText.Meta.copy(fontWeight = FontWeight.Bold, fontFeatureSettings = "tnum"))
}
Spacer(Modifier.weight(1f))
Text("View cart $subtotal", style = InstacartText.Button.copy(fontFeatureSettings = "tnum"),
color = Color.White)
}
}
}@Composable
fun StoreRow(
logoUrl: String,
name: String,
eta: String,
deliveryFee: String,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Row(
Modifier
.fillMaxWidth()
.background(if (pressed) InstacartColors.Surface else InstacartColors.Canvas)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = logoUrl, contentDescription = null,
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(name, style = InstacartText.StoreName, color = InstacartColors.TextPrimary)
Text("$eta · $deliveryFee delivery", style = InstacartText.Meta, color = InstacartColors.TextSecondary)
}
Icon(Icons.Filled.ChevronRight, contentDescription = null,
tint = InstacartColors.TextTertiary, modifier = Modifier.size(20.dp))
}
}@Composable
fun ProductCard(
photoUrl: String,
price: String,
unitPrice: String,
title: String,
dealFlag: String? = null,
) {
Column(Modifier.width(150.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Box {
AsyncImage(
model = photoUrl, contentDescription = title,
modifier = Modifier.size(150.dp).clip(RoundedCornerShape(12.dp))
.background(InstacartColors.Surface),
contentScale = ContentScale.Crop,
)
if (dealFlag != null) {
Box(
Modifier.align(Alignment.TopStart).padding(8.dp)
.clip(CircleShape).background(InstacartColors.Carrot)
.padding(horizontal = 8.dp, vertical = 4.dp),
) { Text(dealFlag, style = InstacartText.Badge, color = Color.White) }
}
AddOrStepper(Modifier.align(Alignment.BottomEnd).padding(8.dp))
}
Text(price, style = InstacartText.Price.copy(fontFeatureSettings = "tnum"),
color = InstacartColors.TextPrimary)
Text(unitPrice, style = InstacartText.Meta, color = InstacartColors.TextSecondary)
Text(title, style = InstacartText.ItemTitle, color = InstacartColors.TextPrimary,
maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}@Composable
fun ReplacementOption(
label: String,
selected: Boolean,
onSelect: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.border(1.5.dp, if (selected) InstacartColors.Green else InstacartColors.Divider,
RoundedCornerShape(12.dp))
.clickable(onClick = onSelect)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = InstacartText.Body, color = InstacartColors.TextPrimary,
modifier = Modifier.weight(1f))
Box(
Modifier.size(22.dp).clip(CircleShape)
.border(1.5.dp, if (selected) InstacartColors.Green else InstacartColors.Divider, CircleShape),
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(selected, enter = scaleIn(), exit = scaleOut()) {
Box(Modifier.size(12.dp).clip(CircleShape).background(InstacartColors.Green))
}
}
}
}Compose's analog to an iOS arced path animation: hoist a shared Animatable 0 → 1, drive it with tween(450, easing = FastOutSlowInEasing), and graphicsLayer a ghost thumbnail along an interpolated arc (offset Y with a parabolic mid-bump). On completion, bump the cart-badge scale.
suspend fun flyToCart(progress: Animatable<Float, *>, badge: Animatable<Float, *>) {
progress.snapTo(0f)
progress.animateTo(1f, tween(450, easing = FastOutSlowInEasing))
badge.animateTo(1.2f, spring(dampingRatio = 0.5f))
badge.animateTo(1f, spring(dampingRatio = 0.6f))
}Drive the ghost with Modifier.graphicsLayer { translationX = lerp(startX, endX, p); translationY = lerp(startY, endY, p) - arc(p) } where arc(p) = 60f * sin(p * PI).
Use Material 3 NavigationBar. Instacart's iOS tab bar is an opaque white surface with a hairline divider and a green active tint. The Cart tab carries a count badge.
@Composable
fun InstacartBottomBar(selected: Int, onSelect: (Int) -> Unit, cartCount: Int) {
NavigationBar(
containerColor = InstacartColors.Canvas,
tonalElevation = 0.dp,
) {
val items = listOf(
Triple("Stores", Icons.Filled.Storefront, 0),
Triple("Buy it again", Icons.Filled.Replay, 0),
Triple("Lists", Icons.Filled.List, 0),
Triple("Account", Icons.Filled.Person, 0),
Triple("Cart", Icons.Filled.ShoppingCart, cartCount),
)
items.forEachIndexed { i, (label, icon, badge) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = {
if (badge > 0) {
BadgedBox(badge = { Badge(containerColor = InstacartColors.Green) { Text("$badge") } }) {
Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp))
}
} else {
Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp))
}
},
label = { Text(label, style = InstacartText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = InstacartColors.Green,
selectedTextColor = InstacartColors.Green,
unselectedIconColor = InstacartColors.TextSecondary,
unselectedTextColor = InstacartColors.TextSecondary,
indicatorColor = InstacartColors.GreenTint,
),
)
}
}
}The persistent green cart bar sits directly above this bar — render CartBar in the Scaffold bottomBar slot, stacked above InstacartBottomBar, padded 16.dp from the sides.
| Moment | Compose recipe |
|---|---|
| Stepper increment / decrement | HapticFeedbackType.TextHandleMove; value swap is immediate |
| Add pill → stepper morph | AnimatedContent with (fadeIn()+scaleIn()) togetherWith (fadeOut()+scaleOut()) |
| Cart bar entrance | AnimatedVisibility slideInVertically { it } + fadeIn(), spring stiffness medium |
| Card tap | Modifier.scale 1 → 0.97 driven by collectIsPressedAsState() + animateFloatAsState |
| Replacement select | inner dot AnimatedVisibility scaleIn()/scaleOut(); border color crossfade |
| Add-to-cart fly-up | Animatable 0→1 tween(450, FastOutSlowInEasing) arced; badge spring bounce 1→1.2→1 |
// Card press scale
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, spring(dampingRatio = 0.8f), label = "cardScale")
Modifier.scale(scale).clickable(interaction, indication = null) { /* open product */ }Haptics: prefer LocalHapticFeedback. For a crisper grocery "tick" on stepper taps, LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) (API 30+) approximates iOS .light impact well.
Instacart ships custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Instacart's glyphs as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Stepper minus / plus | minus / plus |
Icons.Filled.Remove / Icons.Filled.Add |
| Stores (tab) | storefront |
Icons.Filled.Storefront |
| Buy it again (tab) | arrow.clockwise |
Icons.Filled.Replay |
| Lists (tab) | list.bullet |
Icons.Filled.List |
| Account (tab) | person |
Icons.Filled.Person |
| Cart (tab) | cart |
Icons.Filled.ShoppingCart |
| Search | magnifyingglass |
Icons.Filled.Search |
| Chevron | chevron.right |
Icons.Filled.ChevronRight |
| Save / heart | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Filter | slider.horizontal.3 |
Icons.Filled.Tune |
| Deal / tag | tag.fill |
Icons.Filled.LocalOffer |
| Replacement / swap | arrow.triangle.2.circlepath |
Icons.Filled.SwapHoriz |
- minSdk 24 (Compose floor is 21; modern motion + badges are comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity; the white canvas wants dark system-bar icons viaWindowCompat. ApplyScaffoldinsets so the cart bar clears the gesture nav. - Font scaling:
sphonors the user's font scale automatically — keep it on product titles, store names, body. Pin layout-sensitive text (stepper value, tab labels, deal badges) by deriving size fromdporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: give the stepper
Modifier.semantics { stateDescription = "Quantity $quantity"; }and expose increment/decrement as custom actions; merge product-card text withModifier.semantics(mergeDescendants = true); the cart bar announces "View cart, $itemCount items, $subtotal". - Touch targets: Material guidance is 48.dp minimum. The 32.dp stepper pill must give each glyph a 48.dp hit area via padding even though the pill itself is compact.
- Contrast:
#72757Eon#FFFFFFpasses WCAG AA at 13sp+. Carrot#FF7009text on white is decorative — keep sale prices bold and add a "Sale" text label so color is not the only signal. - Dynamic color: do not enable Material You
dynamicLightColorScheme()— Instacart's brand requires the fixed white canvas, green action color, and carrot-orange deal color regardless of wallpaper.