coderef

Etsy

Etsy's iOS app is a warm, handcrafted marketplace built on a soft cream canvas (#FAF5EF) that signals "handmade, not mass-produced" the moment it loads.
DownloadRaw
Etsy

Build with the Etsy look.

Palette, type and components from this DESIGN.md, applied to a sample hero.

Palette

At a glance

Theme
light
Typefaces
Inter
Sections
8
Length
3,302 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Etsy

Light and dark previews, tokens and a ready-to-use package, generated from this DESIGN.md. No AI involved: the same file always produces the same output.

Generating design system…
DownloadRaw

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 — NavigationBar instead of a UITabBar, Animatable keyframes instead of a UIKit spring, sp/dp instead of pt.

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 BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); the warm cream canvas wants WindowCompat dark-content system bars. Apply Modifier.windowInsetsPadding(WindowInsets.systemBars) or Scaffold insets; the listing hero bleeds full-width while the sticky bar clears the gesture nav.
  • Warm shadow: pass ambientColor/spotColor = EtsyColors.CardShadow to Modifier.shadow for the brown tint (API 28+). On older APIs the shadow is neutral — add a 1.dp EtsyColors.Divider border so cards still separate from cream.
  • Font scaling: sp honors 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 from dp.
  • TalkBack: give the favorite heart a contentDescription toggle ("Saved" / "Add to favorites"); merge the star row into one contentDescription like "Rated 4.9 out of 5, 1,284 reviews"; group product-card text with Modifier.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: #595959 on #FAF5EF passes 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.

Brand names describe the visual style only; coderef is not affiliated with or endorsed by any brand listed. Licenses