Etsy (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Etsy’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 (Etsy’s warm-cream handmade canvas, single-orange accent, the bouncing favorite heart) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,Animatablekeyframes instead of a UIKit spring,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for images.
1. Color Tokens
// ui/theme/EtsyColors.kt
import androidx.compose.ui.graphics.Color
object EtsyColors {
// Canvas & Surfaces
val Canvas = Color(0xFFFAF5EF)
val Card = Color(0xFFFFFFFF)
val Surface = Color(0xFFF1F1F1)
val Divider = Color(0xFFE1DDD5)
val SurfacePressed = Color(0xFFEFEAE2)
// Text
val TextPrimary = Color(0xFF222222)
val TextSecondary = Color(0xFF595959)
val TextTertiary = Color(0xFF8A8A8A)
// Brand
val Orange = Color(0xFFF1641E)
val OrangePressed = Color(0xFFD5571A)
val OrangeTint = Color(0xFFFDEDE4)
// Semantic
val StarFilled = Color(0xFF222222)
val SaleRed = Color(0xFFA61A12)
val Success = Color(0xFF258635)
// Warm-tinted card shadow (brown ambient — not neutral)
val CardShadow = Color(0xFF785A32)
}
Wire it into a Material 3 lightColorScheme. Etsy is warm-light-first.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val EtsyScheme = lightColorScheme(
primary = EtsyColors.Orange,
onPrimary = Color.White,
background = EtsyColors.Canvas,
onBackground = EtsyColors.TextPrimary,
surface = EtsyColors.Card,
onSurface = EtsyColors.TextPrimary,
surfaceVariant = EtsyColors.Surface,
outline = EtsyColors.Divider,
error = EtsyColors.SaleRed,
)
@Composable
fun EtsyTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = EtsyScheme, typography = EtsyTypography, content = content)
2. Typography
Graphik is proprietary. Drop substitute Inter TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the system font (Roboto).
// ui/theme/EtsyType.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 EtsyGraphik = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal), // 400
Font(R.font.inter_medium, FontWeight.Medium), // 500
Font(R.font.inter_bold, FontWeight.Bold), // 700
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object EtsyText {
val TitleLarge = TextStyle(EtsyGraphik, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.4).sp)
val Listing = TextStyle(EtsyGraphik, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
val Section = TextStyle(EtsyGraphik, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
val Price = TextStyle(EtsyGraphik, fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val CardTitle = TextStyle(EtsyGraphik, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 20.sp)
val CardPrice = TextStyle(EtsyGraphik, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 19.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(EtsyGraphik, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 23.sp)
val ShopName = TextStyle(EtsyGraphik, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 18.sp)
val Button = TextStyle(EtsyGraphik, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 20.sp)
val Meta = TextStyle(EtsyGraphik, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val Badge = TextStyle(EtsyGraphik, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.3.sp)
val Tab = TextStyle(EtsyGraphik, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.2.sp)
val ReviewCnt = TextStyle(EtsyGraphik, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 16.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val EtsyTypography = Typography(
headlineLarge = EtsyText.TitleLarge,
headlineSmall = EtsyText.Section,
titleMedium = EtsyText.CardTitle,
bodyMedium = EtsyText.Body,
labelSmall = EtsyText.Tab,
)
3. Signature Components
Favorite Heart (the emotional core)
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Composable
fun FavoriteHeart(
saved: Boolean,
onToggle: () -> Unit,
overPhoto: Boolean = true,
size: Int = 24,
modifier: Modifier = Modifier,
) {
val haptics = LocalHapticFeedback.current
val scale = remember { Animatable(1f) }
val scope = rememberCoroutineScope()
Box(
modifier
.then(
if (overPhoto) Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
else Modifier
)
.scale(scale.value)
.clickable(remember { MutableInteractionSource() }, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
scope.launch {
scale.animateTo(1.25f, tween(150)) // bounce up
scale.animateTo(1f, tween(150)) // settle
}
onToggle()
},
contentAlignment = androidx.compose.ui.Alignment.Center,
) {
Icon(
imageVector = if (saved) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (saved) "Saved to favorites" else "Add to favorites",
tint = if (saved) EtsyColors.Orange else if (overPhoto) Color.White else EtsyColors.TextPrimary,
modifier = Modifier.size(size.dp),
)
}
}
Star Row
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.Text
@Composable
fun StarRow(rating: Double, reviews: Int, starSize: Int = 11) {
val r = Math.round(rating).toInt()
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Row {
(1..5).forEach { i ->
Icon(
if (i <= r) Icons.Filled.Star else Icons.Outlined.StarBorder,
contentDescription = null,
tint = EtsyColors.StarFilled,
modifier = Modifier.size(starSize.dp),
)
}
}
Text("(%,d)".format(reviews), style = EtsyText.ReviewCnt, color = EtsyColors.TextSecondary)
}
}
Handmade Product Card
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.shadow
import coil.compose.AsyncImage
@Composable
fun ProductCard(
title: String,
photoUrl: String,
price: String,
rating: Double,
reviews: Int,
shop: String,
bestseller: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var saved by remember { mutableStateOf(false) }
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val cardScale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "card")
Column(
modifier
.scale(cardScale)
// Warm-tinted ambient shadow
.shadow(10.dp, RoundedCornerShape(12.dp), ambientColor = EtsyColors.CardShadow,
spotColor = EtsyColors.CardShadow)
.clip(RoundedCornerShape(12.dp))
.background(EtsyColors.Card)
.clickable(interaction, indication = null, onClick = onClick)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Box {
AsyncImage(
model = photoUrl,
contentDescription = title,
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop,
)
if (bestseller) {
Text(
"BESTSELLER",
style = EtsyText.Badge,
color = EtsyColors.Orange,
modifier = Modifier
.align(Alignment.TopStart).padding(8.dp)
.clip(CircleShape).background(EtsyColors.OrangeTint)
.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
FavoriteHeart(
saved = saved, onToggle = { saved = !saved },
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
)
}
Text(title, style = EtsyText.CardTitle, color = EtsyColors.TextPrimary, maxLines = 2)
Text(price, style = EtsyText.CardPrice, color = EtsyColors.TextPrimary)
StarRow(rating, reviews)
Text(shop, style = EtsyText.ShopName, color = EtsyColors.TextSecondary)
Text("Free shipping", style = EtsyText.Meta, color = EtsyColors.TextSecondary)
}
}
Primary Button
@Composable
fun EtsyButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
val haptics = LocalHapticFeedback.current
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "btn")
Box(
modifier
.fillMaxWidth()
.scale(scale)
.clip(CircleShape) // full pill
.background(if (pressed) EtsyColors.OrangePressed else EtsyColors.Orange)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onClick()
}
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) { Text(text, style = EtsyText.Button, color = Color.White) }
}
Listing Detail Hero (gallery)
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
@Composable
fun ListingHero(photos: List<String>) {
val pager = rememberPagerState { photos.size }
var saved by remember { mutableStateOf(false) }
Box {
HorizontalPager(state = pager) { i ->
AsyncImage(
model = photos[i],
contentDescription = "Listing photo ${i + 1}",
modifier = Modifier.fillMaxWidth().aspectRatio(4f / 5f),
contentScale = ContentScale.Crop,
)
}
FavoriteHeart(saved, { saved = !saved },
modifier = Modifier.align(Alignment.TopEnd).padding(16.dp))
Row(
Modifier.align(Alignment.BottomCenter).padding(bottom = 14.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
repeat(photos.size) { i ->
Box(
Modifier.size(6.dp).clip(CircleShape)
.background(if (i == pager.currentPage) Color.White else Color.White.copy(alpha = 0.5f))
)
}
}
Text(
"${pager.currentPage + 1} / ${photos.size}",
style = EtsyText.Meta, color = Color.White,
modifier = Modifier
.align(Alignment.BottomEnd).padding(12.dp)
.clip(CircleShape).background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
}
4. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. Etsy’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use a 97%-opaque cream surface. Active tint is Etsy Orange — orange is the active indicator.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun EtsyBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = EtsyColors.Canvas.copy(alpha = 0.97f), tonalElevation = 0.dp) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Search" to Icons.Filled.Search,
"You" to Icons.Filled.Person,
"Favorites" to Icons.Filled.Favorite,
"Cart" to Icons.Filled.ShoppingCart,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
label = { Text(label, style = EtsyText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = EtsyColors.Orange,
selectedTextColor = EtsyColors.Orange,
unselectedIconColor = EtsyColors.TextSecondary,
unselectedTextColor = EtsyColors.TextSecondary,
indicatorColor = Color.Transparent, // no Material pill — Etsy has none
),
)
}
}
}
The Cart tab carries an orange count badge when non-empty — use BadgedBox around the cart Icon.
5. Navigation
The top bar carries the Etsy wordmark (left, orange) and notification + cart icons (right). Below it, a pinned search field — a #FFFFFF CircleShape pill, 48.dp tall, 1.dp EtsyColors.Divider border. On scroll, collapse to a compact bar; Android has no live blur, so use the 97%-opaque cream surface. The listing detail uses a bottom sticky bar (Scaffold bottomBar) with the price and a full-width orange “Add to cart” pill.
6. Motion
| Moment | Compose recipe |
|---|---|
| Favorite-heart bounce | Animatable keyframes 1 → 1.25 → 1 over 300ms; HapticFeedbackType.LongPress |
| Add-to-cart | HapticFeedbackType.LongPress + a SharedTransitionLayout fly-to-cart of the thumbnail; bump the cart badge |
| Card tap | animateFloatAsState 1 → 0.98 spring(dampingRatio = 0.8f) |
| Gallery paging | HorizontalPager snap; a light haptic on pagerState.currentPage change |
| Sticky bar reveal | AnimatedVisibility + slideInVertically as the hero scrolls off |
// Favorite bounce (inside FavoriteHeart's click)
scope.launch {
scale.animateTo(1.25f, tween(150))
scale.animateTo(1f, tween(150))
}
Haptics: prefer LocalHapticFeedback. For richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(10, ...) to approximate iOS’s .medium impact.
7. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. For exact parity with Etsy’s custom glyphs, export them as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Favorite | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Star | star / star.fill |
Icons.Outlined.StarBorder / Icons.Filled.Star |
| Search | magnifyingglass |
Icons.Filled.Search |
| Share | square.and.arrow.up |
Icons.Filled.Share |
| Back | chevron.left |
Icons.AutoMirrored.Filled.ArrowBack |
| Cart | cart / cart.fill |
Icons.Filled.ShoppingCart |
| Filters | slider.horizontal.3 |
Icons.Filled.Tune |
| Message seller | bubble.left |
Icons.AutoMirrored.Outlined.Chat |
| Home (tab) | house.fill |
Icons.Filled.Home |
| You (tab) | person.fill |
Icons.Filled.Person |
| Favorites (tab) | heart.fill |
Icons.Filled.Favorite |
| Cart (tab) | cart.fill |
Icons.Filled.ShoppingCart |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion is comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the warm cream canvas wantsWindowCompatdark-content system bars. ApplyModifier.windowInsetsPadding(WindowInsets.systemBars)orScaffoldinsets; the listing hero bleeds full-width while the sticky bar clears the gesture nav. - Warm shadow: pass
ambientColor/spotColor = EtsyColors.CardShadowtoModifier.shadowfor the brown tint (API 28+). On older APIs the shadow is neutral — add a 1.dpEtsyColors.Dividerborder so cards still separate from cream. - Font scaling:
sphonors the user’s font scale automatically — keep it on the listing title, price, body, card title (2-line clamp). Pin badge text, tab labels, the gallery photo-count pill, and star glyphs (geometric) by deriving size fromdp. - TalkBack: give the favorite heart a
contentDescriptiontoggle ("Saved" / "Add to favorites"); merge the star row into onecontentDescriptionlike"Rated 4.9 out of 5, 1,284 reviews"; group product-card text withModifier.semantics(mergeDescendants = true). - Touch targets: Material guidance is 48.dp minimum. The CTA clears it; ensure the 36.dp favorite-heart backing and gallery floating controls have ≥48.dp effective touch via padding.
- Contrast:
#595959on#FAF5EFpasses WCAG AA at 13sp+. The cream canvas is intentionally low-contrast warmth — keep headings/prices at#222222. - Dynamic color: do not enable Material You
dynamicLightColorScheme()— Etsy’s handmade identity requires the fixed warm-cream canvas and single-orange accent regardless of wallpaper.