coderef

Airbnb

Airbnb's iOS app is an editorial magazine for places to stay. The canvas is white, photography is the hero, and the UI chrome gets out of the way so a Tuscan farmhouse, a Tokyo micro-apartment, or a Joshua Tree dome can carry the full…
DownloadRaw
Airbnb

Build with the Airbnb look.

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

Palette

At a glance

Theme
light
Typefaces
Cereal, Plus Jakarta Sans, Large nav title
Sections
8
Length
4,524 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Airbnb

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

Airbnb (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Airbnb’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 (Airbnb’s white editorial canvas, photography-as-hero stay cards, single coral accent, soft 16dp radii) while making everything idiomatic Android — NavigationBar instead of a UITabBar, a translucent Surface instead of .regularMaterial blur, sp/dp instead of pt. Airbnb is a light-canvas app, so we wire a Material 3 lightColorScheme, not a dark one.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for the remote stay photography.

1. Color Tokens

// ui/theme/AirbnbColors.kt
import androidx.compose.ui.graphics.Color

object AirbnbColors {
    // Canvas, Surfaces & Dividers
    val Canvas       = Color(0xFFFFFFFF)
    val SurfaceGray  = Color(0xFFF7F7F7)
    val SurfaceGray2 = Color(0xFFEBEBEB)
    val Divider      = Color(0xFFEBEBEB)

    // Text
    val Hof        = Color(0xFF484848) // primary text — titles, body
    val Foggy      = Color(0xFF767676) // secondary — host byline, review count, dates
    val FoggyLight = Color(0xFFB0B0B0) // tertiary — placeholders, disabled
    val Ink        = Color(0xFF222222) // hero titles, borders, visited map pin

    // Brand
    val Coral        = Color(0xFFFF385C) // primary — Reserve, save-heart fill, active tab
    val CoralPressed = Color(0xFFE31C5F)
    val Rausch       = Color(0xFFFF5A5F) // heritage — Belo logomark only
    val Babu         = Color(0xFF00A699) // Plus / Experiences / Superhost seal
    val Arches       = Color(0xFFFC642D) // Trips module
    val Beach        = Color(0xFFFFB400) // star yellow — review screens only

    // Semantic
    val Success = Color(0xFF008A05)
    val Error   = Color(0xFFC13515)

    // Dark mode (warm — preserves photography integrity, not true black)
    val DarkCanvas  = Color(0xFF121212)
    val DarkSurface = Color(0xFF1C1C1E)
    val DarkText    = Color(0xFFDDDDDD)
    val DarkTextSec = Color(0xFFA0A0A0)
}

Airbnb is a white-canvas editorial app, so wire a Material 3 lightColorScheme (provide the warm-dark scheme for dark mode). Ripples, dividers, and stock component defaults then inherit the brand.

// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.isSystemInDarkTheme

private val AirbnbLight = lightColorScheme(
    primary        = AirbnbColors.Coral,
    onPrimary      = Color.White,
    background     = AirbnbColors.Canvas,
    onBackground   = AirbnbColors.Hof,         // warm #484848, never pure black
    surface        = AirbnbColors.Canvas,
    onSurface      = AirbnbColors.Hof,
    surfaceVariant = AirbnbColors.SurfaceGray,
    outline        = AirbnbColors.Divider,
    error          = AirbnbColors.Error,
)

private val AirbnbDark = darkColorScheme(
    primary        = AirbnbColors.Coral,       // coral is unchanged on dark
    onPrimary      = Color.White,
    background     = AirbnbColors.DarkCanvas,
    onBackground   = AirbnbColors.DarkText,
    surface        = AirbnbColors.DarkSurface,
    onSurface      = AirbnbColors.DarkText,
    surfaceVariant = AirbnbColors.DarkSurface,
    outline        = Color(0xFF2C2C2E),
    error          = AirbnbColors.Error,
)

@Composable
fun AirbnbTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (darkTheme) AirbnbDark else AirbnbLight,
    typography  = AirbnbTypography,
    content     = content,
)

2. Typography

Airbnb Cereal (Dalton Maag, 2018) is proprietary. Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the system font (Roboto) — but Cereal is warm and tightly spaced, so if you ship a substitute prefer bundling Plus Jakarta Sans / Manrope, which capture its roundness better than a pure grotesque.

// ui/theme/AirbnbType.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 AirbnbCereal = FontFamily(
    Font(R.font.airbnb_cereal_light,     FontWeight.Light),     // 300
    Font(R.font.airbnb_cereal_book,      FontWeight.Normal),    // 400
    Font(R.font.airbnb_cereal_medium,    FontWeight.Medium),    // 500
    Font(R.font.airbnb_cereal_bold,      FontWeight.Bold),      // 700
    Font(R.font.airbnb_cereal_extrabold, FontWeight.ExtraBold), // 800
    Font(R.font.airbnb_cereal_black,     FontWeight.Black),     // 900
)

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object AirbnbText {
    val LargeNav    = TextStyle(AirbnbCereal, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.4).sp)
    val Hero        = TextStyle(AirbnbCereal, fontWeight = FontWeight.Bold,      fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
    val Section     = TextStyle(AirbnbCereal, fontWeight = FontWeight.Bold,      fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
    val Subsection  = TextStyle(AirbnbCereal, fontWeight = FontWeight.Bold,      fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
    val CardTitle   = TextStyle(AirbnbCereal, fontWeight = FontWeight.Medium,    fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.05).sp)
    val Body        = TextStyle(AirbnbCereal, fontWeight = FontWeight.Normal,    fontSize = 16.sp, lineHeight = 22.sp)
    val BodySmall   = TextStyle(AirbnbCereal, fontWeight = FontWeight.Normal,    fontSize = 14.sp, lineHeight = 20.sp)
    val Meta        = TextStyle(AirbnbCereal, fontWeight = FontWeight.Normal,    fontSize = 14.sp, lineHeight = 18.sp)
    val RatingNum   = TextStyle(AirbnbCereal, fontWeight = FontWeight.Medium,    fontSize = 14.sp, lineHeight = 17.sp)
    val PriceInline = TextStyle(AirbnbCereal, fontWeight = FontWeight.Bold,      fontSize = 15.sp, lineHeight = 18.sp)
    val PriceHero   = TextStyle(AirbnbCereal, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
    val Button      = TextStyle(AirbnbCereal, fontWeight = FontWeight.Bold,      fontSize = 16.sp, lineHeight = 16.sp)
    val ButtonSm    = TextStyle(AirbnbCereal, fontWeight = FontWeight.Medium,    fontSize = 14.sp, lineHeight = 14.sp)
    val Tab         = TextStyle(AirbnbCereal, fontWeight = FontWeight.Medium,    fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
    val Chip        = TextStyle(AirbnbCereal, fontWeight = FontWeight.Medium,    fontSize = 12.sp, lineHeight = 12.sp)
    val Caption     = TextStyle(AirbnbCereal, fontWeight = FontWeight.Normal,    fontSize = 12.sp, lineHeight = 16.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val AirbnbTypography = Typography(
    headlineLarge  = AirbnbText.LargeNav,
    headlineMedium = AirbnbText.Hero,
    headlineSmall  = AirbnbText.Section,
    titleMedium    = AirbnbText.CardTitle,
    bodyLarge      = AirbnbText.Body,
    bodyMedium     = AirbnbText.BodySmall,
    labelSmall     = AirbnbText.Tab,
)

3. Signature Components

Save Heart (top-right of every stay card)

import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp

@Composable
fun SaveHeart(
    isSaved: Boolean,
    onToggle: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val haptics = LocalHapticFeedback.current
    val scale = remember { Animatable(1f) }
    LaunchedEffect(isSaved) {
        if (isSaved) {
            scale.animateTo(1.25f, spring(dampingRatio = 0.6f, stiffness = 500f))
            scale.animateTo(1f, spring(dampingRatio = 0.6f, stiffness = 500f))
        }
    }

    Box(
        modifier = modifier
            .size(44.dp) // 44dp tap target around a 32dp glyph
            .clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
            ) {
                haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ~iOS .soft impact
                onToggle()
            },
        contentAlignment = Alignment.Center,
    ) {
        if (isSaved) {
            Icon(
                Icons.Filled.Favorite,
                contentDescription = "Saved",
                tint = AirbnbColors.Coral,
                modifier = Modifier.size(28.dp).scale(scale.value),
            )
        } else {
            // White fill + ink outline so it stays legible over varied photography
            Icon(
                Icons.Filled.Favorite,
                contentDescription = null,
                tint = Color.White,
                modifier = Modifier
                    .size(28.dp)
                    .shadow(4.dp, CircleShape, spotColor = Color.Black.copy(alpha = 0.2f)),
            )
            Icon(
                Icons.Outlined.FavoriteBorder,
                contentDescription = "Save",
                tint = AirbnbColors.Ink,
                modifier = Modifier.size(28.dp),
            )
        }
    }
}

Rating Row (star + number + review count)

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Text

@Composable
fun RatingRow(rating: Double, reviewCount: Int, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier,
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        Icon(
            Icons.Filled.Star,
            contentDescription = null,
            tint = AirbnbColors.Hof, // dark gray on cards — NOT yellow
            modifier = Modifier.size(12.dp),
        )
        Text("%.2f".format(rating), style = AirbnbText.RatingNum, color = AirbnbColors.Hof)
        Text(" · ${"%,d".format(reviewCount)}", style = AirbnbText.Meta, color = AirbnbColors.Foggy)
    }
}

Stay Card (the hero component)

HorizontalPager from androidx.compose.foundation.pager replaces SwiftUI’s paged TabView.

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage

@Composable
fun StayCard(
    photoUrls: List<String>,
    title: String,
    host: String,
    dates: String,
    pricePerNight: Int,
    rating: Double,
    reviewCount: Int,
    modifier: Modifier = Modifier,
) {
    var isSaved by remember { mutableStateOf(false) }
    val pager = rememberPagerState(pageCount = { photoUrls.size })

    Column(modifier = modifier.fillMaxWidth()) {
        Box {
            HorizontalPager(
                state = pager,
                modifier = Modifier
                    .fillMaxWidth()
                    .aspectRatio(4f / 3f) // signature 4:3
                    .clip(RoundedCornerShape(16.dp)), // signature 16dp radius
            ) { page ->
                AsyncImage(
                    model = photoUrls[page],
                    contentDescription = "$title photo ${page + 1}",
                    modifier = Modifier.fillMaxSize(),
                    contentScale = ContentScale.Crop,
                )
            }
            SaveHeart(
                isSaved = isSaved,
                onToggle = { isSaved = !isSaved },
                modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
            )
            // Dot indicators bottom-center (6 max)
            Row(
                Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp),
                horizontalArrangement = Arrangement.spacedBy(6.dp),
            ) {
                repeat(minOf(photoUrls.size, 6)) { i ->
                    Box(
                        Modifier
                            .size(6.dp)
                            .clip(CircleShape)
                            .background(
                                if (i == pager.currentPage) Color.White
                                else Color.White.copy(alpha = 0.5f)
                            ),
                    )
                }
            }
        }

        RatingRow(rating, reviewCount, Modifier.padding(top = 8.dp))
        Text(title, style = AirbnbText.CardTitle, color = AirbnbColors.Hof,
            maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp))
        Text(host, style = AirbnbText.Meta, color = AirbnbColors.Foggy,
            maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp))
        Text(dates, style = AirbnbText.Meta, color = AirbnbColors.Foggy,
            modifier = Modifier.padding(top = 6.dp))
        Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
            Text("$$pricePerNight", style = AirbnbText.PriceInline, color = AirbnbColors.Hof)
            Text("night", style = AirbnbText.PriceInline.copy(fontWeight = FontWeight.Normal),
                color = AirbnbColors.Hof)
        }
    }
}

Search Pill (Explore top)

import androidx.compose.foundation.border
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tune

@Composable
fun SearchPill(onClick: () -> Unit, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(56.dp)
            .shadow(12.dp, CircleShape, spotColor = Color.Black.copy(alpha = 0.08f))
            .clip(CircleShape) // full pill
            .background(AirbnbColors.Canvas)
            .border(0.5.dp, AirbnbColors.Divider, CircleShape)
            .clickable(onClick = onClick)
            .padding(horizontal = 20.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Icon(Icons.Filled.Search, contentDescription = null,
            tint = AirbnbColors.Ink, modifier = Modifier.size(16.dp))
        Column(Modifier.weight(1f)) {
            Text("Where to?", style = AirbnbText.CardTitle.copy(fontWeight = FontWeight.Bold),
                color = AirbnbColors.Ink)
            Text("Anywhere · Any week · Add guests", style = AirbnbText.Caption,
                color = AirbnbColors.Foggy)
        }
        Box(
            Modifier
                .size(36.dp)
                .clip(CircleShape)
                .border(1.dp, AirbnbColors.Divider, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            Icon(Icons.Filled.Tune, contentDescription = "Filters",
                tint = AirbnbColors.Ink, modifier = Modifier.size(14.dp))
        }
    }
}

Category Bar (sliding underline — see §4 for the motion)

import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.animation.animateColorAsState

@Composable
fun CategoryBar(
    categories: List<Pair<androidx.compose.ui.graphics.vector.ImageVector, String>>,
    selected: Int,
    onSelect: (Int) -> Unit,
) {
    LazyRow(
        modifier = Modifier
            .fillMaxWidth()
            .height(72.dp)
            .background(AirbnbColors.Canvas),
        contentPadding = PaddingValues(horizontal = 16.dp),
        horizontalArrangement = Arrangement.spacedBy(32.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        itemsIndexed(categories) { i, (icon, label) ->
            val isSel = i == selected
            val tint by animateColorAsState(
                if (isSel) AirbnbColors.Ink else Color(0xFF717171), label = "catTint")
            Column(
                horizontalAlignment = Alignment.CenterHorizontally,
                modifier = Modifier.clickable(
                    interactionSource = remember { MutableInteractionSource() },
                    indication = null,
                ) { onSelect(i) },
            ) {
                Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(24.dp))
                Spacer(Modifier.height(6.dp))
                Text(label, style = AirbnbText.Chip, color = tint)
                Spacer(Modifier.height(8.dp))
                Box(
                    Modifier
                        .height(2.dp)
                        .width(if (isSel) 28.dp else 0.dp)
                        .background(AirbnbColors.Ink),
                )
            }
        }
    }
}

Sticky Booking Footer (Stay Detail bottom)

iOS uses .regularMaterial blur; Android has no first-class live blur, so use a 95%-opaque canvas Surface with a hairline top divider.

import androidx.compose.material3.Surface
import androidx.compose.ui.text.style.TextDecoration

@Composable
fun BookingFooter(
    totalPrice: Int,
    dateRange: String,
    onReserve: () -> Unit,
) {
    val haptics = LocalHapticFeedback.current
    Surface(color = AirbnbColors.Canvas.copy(alpha = 0.95f), tonalElevation = 0.dp) {
        Column {
            Box(Modifier.fillMaxWidth().height(0.5.dp).background(AirbnbColors.Divider))
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(80.dp)
                    .padding(horizontal = 24.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Column(Modifier.weight(1f)) {
                    Row(verticalAlignment = Alignment.Bottom,
                        horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                        Text("$$totalPrice", style = AirbnbText.Subsection.copy(fontSize = 16.sp),
                            color = AirbnbColors.Ink)
                        Text("total", style = AirbnbText.BodySmall, color = AirbnbColors.Ink)
                    }
                    Text(dateRange, style = AirbnbText.BodySmall, color = AirbnbColors.Ink,
                        textDecoration = TextDecoration.Underline)
                }
                ReservePill(text = "Reserve") {
                    haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS .medium impact
                    onReserve()
                }
            }
        }
    }
}

@Composable
fun ReservePill(text: String, onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "reserveScale")
    Box(
        modifier = Modifier
            .scale(scale)
            .heightIn(min = 48.dp)
            .clip(RoundedCornerShape(8.dp)) // soft rectangle, NOT a full pill
            .background(if (pressed) AirbnbColors.CoralPressed else AirbnbColors.Coral)
            .clickable(interaction, indication = null, onClick = onClick)
            .padding(horizontal = 28.dp, vertical = 14.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(text, style = AirbnbText.Button, color = Color.White)
    }
}

Map Price Bubble

enum class BubbleState { Default, Visited, Selected }

@Composable
fun MapPriceBubble(price: Int, state: BubbleState = BubbleState.Default) {
    val bg = when (state) {
        BubbleState.Default  -> Color.White
        BubbleState.Visited  -> AirbnbColors.Ink
        BubbleState.Selected -> AirbnbColors.Coral
    }
    val fg = if (state == BubbleState.Default) AirbnbColors.Ink else Color.White
    Box(
        Modifier
            .shadow(6.dp, RoundedCornerShape(12.dp), spotColor = Color.Black.copy(alpha = 0.2f))
            .clip(RoundedCornerShape(12.dp)) // rounded rect, NOT a full pill
            .background(bg)
            .padding(horizontal = 10.dp, vertical = 6.dp),
    ) {
        Text("$$price", style = AirbnbText.PriceInline.copy(fontWeight = FontWeight.Bold), color = fg)
    }
}

4. Category Underline — Shared-Element Slide

Airbnb’s signature interaction is the category-bar underline that slides to the tapped chip (iOS matchedGeometryEffect). The §3 CategoryBar animates width per chip; for a true single underline that travels across chips, drive an Animatable offset with a spring, mirroring DESIGN.md §6’s “underline slides … 250ms spring”.

import androidx.compose.foundation.layout.offset
import androidx.compose.ui.unit.Dp

@Composable
fun SlidingUnderline(
    chipCount: Int,
    selected: Int,
    chipWidth: Dp,
    chipSpacing: Dp = 32.dp,
) {
    val targetX = (chipWidth + chipSpacing) * selected
    val x by animateDpAsState(
        targetValue = targetX,
        animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f), // ~250ms feel
        label = "underline",
    )
    Box(
        Modifier
            .offset(x = x)
            .width(chipWidth)
            .height(2.dp)
            .background(AirbnbColors.Ink),
    )
}

The search-pill → modal morph is Compose’s SharedTransitionLayout + Modifier.sharedElement() (Compose 1.7+) on the pill container; fall back to AnimatedVisibility with slideInVertically + a 400ms cross-fade if you target older BOMs. The Belo logomark loading pulse is a rememberInfiniteTransition driving alpha 0.6f → 1f → 0.6f over 1200ms, tinted AirbnbColors.Rausch (never Coral).

5. Bottom Navigation (Tab Bar)

Use Material 3 NavigationBar. Airbnb’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use a 92%-opaque canvas surface. Active tint is Primary Coral (icon + label), inactive is #717171. All five tabs are always labeled.

import androidx.compose.material.icons.filled.*
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults

@Composable
fun AirbnbBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = AirbnbColors.Canvas.copy(alpha = 0.92f),
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Explore"   to Icons.Filled.Search,
            "Wishlists" to Icons.Filled.Favorite,
            "Trips"     to Icons.Filled.Flight,
            "Inbox"     to Icons.Filled.ChatBubbleOutline,
            "Profile"   to Icons.Filled.AccountCircle,
        )
        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 = AirbnbText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor = AirbnbColors.Coral,
                    selectedTextColor = AirbnbColors.Coral,
                    unselectedIconColor = Color(0xFF717171),
                    unselectedTextColor = Color(0xFF717171),
                    indicatorColor = Color.Transparent, // no Material pill — Airbnb has none
                ),
            )
        }
    }
}

The collapsing large-title nav bar (#222222 Cereal 32sp ExtraBold over a metadata strip) is best built with a LargeTopAppBar + TopAppBarDefaults.exitUntilCollapsedScrollBehavior(), with the CategoryBar pinned directly beneath it inside the Scaffold content.

6. Motion

Moment Compose recipe
Save heart tap Animatable 1 → 1.25 → 1 spring (dampingRatio = 0.6f); HapticFeedbackType.TextHandleMove (~iOS .soft)
Photo carousel swipe HorizontalPager 60fps paging, rubber-band at edges; dot indicators cross-fade
Category bar switch animateDpAsState underline offset, spring(dampingRatio = 0.8f) ≈ 250ms
Search pill → modal SharedTransitionLayout + Modifier.sharedElement() (1.7+); fallback slideInVertically + 400ms cross-fade
Reserve tap animateFloatAsState 1 → 0.98; HapticFeedbackType.LongPress (~.medium); success → see below
Map pin select scale 1 → 1.1 + color White → Coral via animateColorAsState; card slides up from bottom
Belo loading rememberInfiniteTransition alpha 0.6 → 1 → 0.6 over 1200ms, Rausch
// Save-heart bounce — exact 1 → 1.25 → 1 spring (damping 0.6)
@Composable
fun rememberHeartBounce(isSaved: Boolean): Float {
    val scale = remember { Animatable(1f) }
    LaunchedEffect(isSaved) {
        if (isSaved) {
            scale.animateTo(1.25f, spring(dampingRatio = 0.6f, stiffness = 500f))
            scale.animateTo(1f,    spring(dampingRatio = 0.6f, stiffness = 500f))
        }
    }
    return scale.value
}

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 .soft impact on the save heart, and .medium on Reserve. Booking-confirmed → a double-buzz VibrationEffect.createWaveform(longArrayOf(0, 40, 60, 40), -1) mirrors iOS .notificationOccurred(.success).

7. Icons

Airbnb ships custom category glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. Export Airbnb’s bespoke category icons and the Belo logomark as vector drawables and load via ImageVector.vectorResource(R.drawable.…).

Component SF Symbol (iOS) Material Icon (Compose)
Save (unsaved) heart Icons.Outlined.FavoriteBorder
Save (saved) heart.fill Icons.Filled.Favorite
Rating star star.fill Icons.Filled.Star
Search magnifyingglass Icons.Filled.Search
Filters slider.horizontal.3 Icons.Filled.Tune
Show all photos square.grid.2x2.fill Icons.Filled.GridView
Superhost checkmark.seal.fill (Babu) Icons.Filled.Verified
Explore tab magnifyingglass Icons.Filled.Search
Wishlists tab heart / heart.fill Icons.Filled.Favorite
Trips tab airplane Icons.Filled.Flight
Inbox tab message / message.fill Icons.Filled.ChatBubbleOutline
Profile tab person.circle Icons.Filled.AccountCircle
Wifi amenity wifi Icons.Filled.Wifi
TV amenity tv.fill Icons.Filled.Tv
Parking amenity parkingsign.circle Icons.Filled.LocalParking
Back (detail) chevron.left Icons.AutoMirrored.Filled.ArrowBackIos
Share square.and.arrow.up Icons.Filled.IosShare
Belo logomark — custom vector drawable, Rausch #FF5A5F

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; modern motion + HorizontalPager are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity; the white canvas wants WindowCompat dark-content (dark icons) system bars. Apply Scaffold insets / Modifier.windowInsetsPadding(WindowInsets.systemBars) so the sticky Reserve footer clears the gesture nav and the large title clears the status bar.
  • Font scaling: sp honors the user’s font scale automatically — keep it on stay-card titles, host bylines, dates, body copy, and large nav titles. Pin layout-sensitive text (10sp tab labels, 12sp chip labels, the map price bubble, rating numbers) by deriving size from dp or wrapping in CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). Stay-card title scales but clamp at ~18sp.
  • TalkBack: merge the whole stay card into one node with Modifier.semantics(mergeDescendants = true) { contentDescription = "Private room in Reykjavik, hosted by Sigrún, Superhost, 4.92 stars, 324 reviews, Oct 12 to 17, \$214 per night" }; expose the save heart as a separate button node ("Save" / "Saved").
  • Touch targets: Material guidance is 48.dp minimum. The save heart already uses a 44.dp box — bump to 48.dp via padding. The Reserve pill is heightIn(min = 48.dp); category chips fill the 72.dp bar height.
  • Contrast: Hof (#484848) on white passes WCAG AA at all sizes. Foggy (#767676) on white passes AA only at ≥18sp Bold or ≥14sp Medium — do not use Foggy for small body text; for the 14sp Book host byline this is borderline, nudge toward #6A6A6A if targeting strict AA.
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0f, skip the heart 1.25 bounce and the underline slide — fall back to an instant color/position change.
  • Dynamic color: do not enable Material You dynamicLightColorScheme() — Airbnb’s brand requires the fixed white canvas and the specific #FF385C coral regardless of the user’s wallpaper. Photography is the color story, not the system accent.
All mobile designs →

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