Expedia (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Expedia’s visual language to Android with Jetpack Compose (Material 3): a color token object, an M3 Typography, paste-ready @Composables (property card, mode switch, One Key strip), navigation, motion, and haptics.
The DESIGN.md tokens are platform-neutral. This guide keeps the visual identity (Expedia Yellow as the savings signal, Action Blue as interactive, navy review-score badge, the search-and-results loop) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,Cardelevation,dp/spinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for property photos. Do not enable Material You dynamic color — Expedia’s yellow/blue/navy identity must hold regardless of wallpaper.
1. Color Tokens (Compose + M3)
// ui/theme/ExpediaColors.kt
import androidx.compose.ui.graphics.Color
object ExpediaColors {
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFFFFFFF)
val SurfaceGray = Color(0xFFF5F7FA)
val SurfacePressed = Color(0xFFECEFF4)
val Divider = Color(0xFFE3E7ED)
// Canvas & Surfaces (Dark) — navy-biased, NOT pure black
val DarkCanvas = Color(0xFF0E1116)
val DarkSurface1 = Color(0xFF161B22)
val DarkSurface2 = Color(0xFF1F2630)
val DarkDivider = Color(0xFF2A323E)
// Text
val TextPrimary = Color(0xFF1A1F26)
val TextSecondary = Color(0xFF5A6573)
val TextTertiary = Color(0xFF8A95A3)
val DarkTextPrimary = Color(0xFFE8EBEF)
val DarkTextSecondary = Color(0xFF9AA4B2)
// Brand & interactive
val Yellow = Color(0xFFFFC94D) // savings/deal/loyalty — text on it is Navy
val YellowDeep = Color(0xFFFFB31A)
val ActionBlue = Color(0xFF1668E3) // interactive — white text on it
val ActionPressed = Color(0xFF0F4FB0)
val Navy = Color(0xFF00355F) // brand/trust
val NavySoft = Color(0xFF14416B)
val OneKeyGold = Color(0xFFF5C518) // rewards-only
// Semantic
val Success = Color(0xFF1A8B4B)
val Error = Color(0xFFD93A3A)
val Warning = Color(0xFFE8830C)
}
// Review-score badge color for a 0–10 guest score
fun reviewBadgeColor(score: Double): Color = when {
score >= 9.0 -> ExpediaColors.Success // Wonderful
score >= 8.0 -> ExpediaColors.ActionBlue // Excellent
score >= 7.0 -> ExpediaColors.NavySoft // Good
score >= 6.0 -> ExpediaColors.TextSecondary
else -> ExpediaColors.TextTertiary
}
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
private val ExpediaLight = lightColorScheme(
primary = ExpediaColors.ActionBlue,
onPrimary = ExpediaColors.Canvas,
secondary = ExpediaColors.Yellow,
onSecondary = ExpediaColors.Navy,
background = ExpediaColors.SurfaceGray,
onBackground = ExpediaColors.TextPrimary,
surface = ExpediaColors.Canvas,
onSurface = ExpediaColors.TextPrimary,
surfaceVariant = ExpediaColors.SurfacePressed,
outline = ExpediaColors.Divider,
error = ExpediaColors.Error,
)
private val ExpediaDark = darkColorScheme(
primary = ExpediaColors.ActionBlue,
onPrimary = ExpediaColors.Canvas,
secondary = ExpediaColors.Yellow,
onSecondary = ExpediaColors.Navy,
background = ExpediaColors.DarkCanvas,
onBackground = ExpediaColors.DarkTextPrimary,
surface = ExpediaColors.DarkSurface1,
onSurface = ExpediaColors.DarkTextPrimary,
surfaceVariant = ExpediaColors.DarkSurface2,
outline = ExpediaColors.DarkDivider,
error = ExpediaColors.Error,
)
@Composable
fun ExpediaTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) ExpediaDark else ExpediaLight,
typography = ExpediaTypography,
content = content,
)
2. Typography (M3)
Expedia Sans is proprietary — drop the TTFs in res/font/ only if licensed; otherwise use Inter (closest free substitute). Prices and review scores need tabular figures.
// ui/theme/ExpediaType.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.text.style.TextDecoration
import androidx.compose.ui.unit.sp
// If licensed, swap in expedia_sans_* font resources.
val ExpediaSans = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
Font(R.font.inter_extrabold, FontWeight.ExtraBold),
)
object ExpediaText {
val Display = TextStyle(ExpediaSans, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.6).sp)
val ScreenTitle = TextStyle(ExpediaSans, fontWeight = FontWeight.ExtraBold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.4).sp)
val Section = TextStyle(ExpediaSans, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
val CardTitle = TextStyle(ExpediaSans, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.2).sp)
val Body = TextStyle(ExpediaSans, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val CardSubtitle = TextStyle(ExpediaSans, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Meta = TextStyle(ExpediaSans, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 19.sp)
val Strike = TextStyle(ExpediaSans, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 16.sp, textDecoration = TextDecoration.LineThrough)
val Badge = TextStyle(ExpediaSans, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 13.sp, letterSpacing = 0.2.sp)
val OneKeyLine = TextStyle(ExpediaSans, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 14.sp, letterSpacing = 0.1.sp)
val Button = TextStyle(ExpediaSans, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val Tab = TextStyle(ExpediaSans, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 11.sp, letterSpacing = 0.1.sp)
val PriceNow = TextStyle(ExpediaSans, fontWeight = FontWeight.ExtraBold, fontSize = 18.sp, lineHeight = 22.sp)
val ScoreNum = TextStyle(ExpediaSans, fontWeight = FontWeight.ExtraBold, fontSize = 15.sp, lineHeight = 16.sp)
}
val ExpediaTypography = Typography(
headlineLarge = ExpediaText.Display,
headlineMedium = ExpediaText.ScreenTitle,
titleLarge = ExpediaText.Section,
titleMedium = ExpediaText.CardTitle,
bodyMedium = ExpediaText.Body,
labelSmall = ExpediaText.Tab,
)
For tabular figures use
TextStyle(... fontFeatureSettings = "tnum")onPriceNow/ScoreNum, or applyModifierwith a monospaced numeral font if your licensed Expedia Sans ships one.
3. Signature Components
Property Result Card (@Composable)
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun PropertyCard(
imageUrl: String,
dealFlag: String?,
title: String,
location: String,
score: Double,
scoreWord: String,
reviewCount: Int,
strikePrice: Int?,
nightlyPrice: Int,
oneKeyEarn: Int,
modifier: Modifier = Modifier,
) {
var saved by remember { mutableStateOf(false) }
val heartScale by animateFloatAsState(if (saved) 1f else 1f, spring(Spring.DampingRatioMediumBouncy), label = "heart")
val haptics = LocalHapticFeedback.current
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = ExpediaColors.Canvas),
border = androidx.compose.foundation.BorderStroke(0.5.dp, ExpediaColors.Divider),
elevation = CardDefaults.cardElevation(defaultElevation = 3.dp),
) {
Column {
Box(Modifier.fillMaxWidth().aspectRatio(16f / 10f)) {
AsyncImage(
model = imageUrl, contentDescription = title,
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop,
)
if (dealFlag != null) {
Text(
dealFlag, style = ExpediaText.Badge, color = ExpediaColors.Navy,
modifier = Modifier
.padding(12.dp).align(Alignment.TopStart)
.clip(RoundedCornerShape(6.dp))
.background(ExpediaColors.Yellow)
.padding(vertical = 4.dp, horizontal = 9.dp),
)
}
Box(
Modifier
.padding(10.dp).align(Alignment.TopEnd)
.size(30.dp).scale(heartScale)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.4f))
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
saved = !saved
},
contentAlignment = Alignment.Center,
) {
Icon(
if (saved) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (saved) "Saved" else "Save",
tint = if (saved) ExpediaColors.ActionBlue else Color.White,
modifier = Modifier.size(18.dp),
)
}
}
Column(Modifier.padding(start = 14.dp, end = 14.dp, top = 12.dp, bottom = 14.dp)) {
Text(title, style = ExpediaText.CardTitle, color = ExpediaColors.TextPrimary)
Text(location, style = ExpediaText.CardSubtitle, color = ExpediaColors.TextSecondary,
modifier = Modifier.padding(top = 3.dp))
Row(
Modifier.padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
String.format("%.1f", score),
style = ExpediaText.ScoreNum, color = Color.White,
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(reviewBadgeColor(score))
.padding(vertical = 3.dp, horizontal = 7.dp),
)
Text(scoreWord, style = ExpediaText.Badge.copy(fontWeight = FontWeight.Bold), color = ExpediaColors.TextPrimary)
Text("· ${"%,d".format(reviewCount)} reviews", style = ExpediaText.Badge, color = ExpediaColors.TextSecondary)
}
Row(
Modifier.padding(top = 10.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (strikePrice != null) {
Text("$$strikePrice", style = ExpediaText.Strike, color = ExpediaColors.TextTertiary)
}
Text("$$nightlyPrice", style = ExpediaText.PriceNow, color = ExpediaColors.TextPrimary)
Text("/ night", style = ExpediaText.Badge, color = ExpediaColors.TextSecondary)
}
Row(
Modifier.padding(top = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Box(Modifier.size(8.dp).clip(RoundedCornerShape(50)).background(ExpediaColors.OneKeyGold))
Text("Earn ${"%,d".format(oneKeyEarn)} One Key cash", style = ExpediaText.OneKeyLine, color = ExpediaColors.OneKeyGold)
}
}
}
}
}
Segmented Mode Switch (@Composable)
@Composable
fun ModeSwitch(selected: Int, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
val modes = listOf("Stays", "Flights", "Cars", "Bundle")
Row(modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
modes.forEachIndexed { i, label ->
val active = selected == i
Box(
Modifier
.weight(1f)
.clip(RoundedCornerShape(10.dp))
.background(if (active) ExpediaColors.Yellow else ExpediaColors.SurfaceGray)
.then(if (active) Modifier else Modifier.border(0.5.dp, ExpediaColors.Divider, RoundedCornerShape(10.dp)))
.clickable { onSelect(i) }
.padding(vertical = 9.dp),
contentAlignment = Alignment.Center,
) {
Text(label, style = ExpediaText.Badge, color = if (active) ExpediaColors.Navy else ExpediaColors.TextSecondary)
}
}
}
}
One Key Rewards Strip (@Composable)
@Composable
fun OneKeyStrip(balance: String, modifier: Modifier = Modifier) {
Row(
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(ExpediaColors.SurfaceGray)
.border(0.5.dp, ExpediaColors.Divider, RoundedCornerShape(12.dp))
.padding(vertical = 14.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(36.dp).clip(RoundedCornerShape(50)).background(ExpediaColors.OneKeyGold),
contentAlignment = Alignment.Center,
) { Text("1K", style = ExpediaText.Badge.copy(fontWeight = FontWeight.ExtraBold, fontSize = 13.sp), color = Color(0xFF1B1B1B)) }
Column(Modifier.weight(1f)) {
Text("One Key cash available", style = ExpediaText.Badge.copy(fontWeight = FontWeight.Bold, fontSize = 13.sp), color = ExpediaColors.TextPrimary)
Text("Apply at checkout on any stay, flight, or car", style = ExpediaText.OneKeyLine.copy(color = ExpediaColors.TextSecondary))
}
Text(balance, style = ExpediaText.PriceNow.copy(fontSize = 16.sp), color = ExpediaColors.OneKeyGold)
}
}
4. Navigation
Expedia uses a 5-tab bottom bar (Search / Saved / Trips / Support / Account). On Android, model it as a Material 3 NavigationBar. Active is Action Blue; there is no Material tint pill.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun ExpediaBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = ExpediaColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
"Search" to Icons.Filled.Search,
"Saved" to Icons.Filled.FavoriteBorder,
"Trips" to Icons.Filled.Work,
"Support" to Icons.Filled.HelpOutline,
"Account" to Icons.Filled.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(22.dp)) },
label = { Text(label, style = ExpediaText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = ExpediaColors.ActionBlue,
selectedTextColor = ExpediaColors.ActionBlue,
unselectedIconColor = ExpediaColors.TextTertiary,
unselectedTextColor = ExpediaColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Expedia has none
),
)
}
}
}
The detail screen uses a collapsing top app bar (TopAppBar with enterAlwaysScrollBehavior) that is transparent over the photo hero and solidifies to Canvas/DarkCanvas on scroll, with a sticky bottom CTA bar (BottomAppBar, elevation 8.dp) holding the “Reserve” and “Bundle & save” buttons.
5. Motion
| Moment | Compose recipe |
|---|---|
| Save heart | animateFloatAsState 1 → 1.2 → 1 via spring(DampingRatioMediumBouncy); soft haptic on toggle |
| Mode switch | animateColorAsState(tween(200)) on background + text color |
| Price drop | animateColorAsState flash to Yellow then back to TextPrimary over ~500ms |
| Bundle reveal | AnimatedVisibility(expandVertically(tween(240)) + scaleIn(initialScale = 0.96f) + fadeIn()) |
| Card → detail | shared element via SharedTransitionLayout (Compose 1.7+); push tween(320) |
| Filter sheet | ModalBottomSheet slide-up tween(300); result count animateIntAsState |
| Sticky CTA bar | AnimatedVisibility(slideInVertically { it } + fadeIn(), tween(200)) past fold |
| Pull-to-refresh | PullToRefreshContainer tinted Yellow |
val priceColor by animateColorAsState(
targetValue = if (justDropped) ExpediaColors.Yellow else ExpediaColors.TextPrimary,
animationSpec = tween(180), label = "priceFlash",
)
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft impact on save, mode switch, and date-range commit; a success cue on booking confirmation. Auto-applied loyalty discounts are silent — show a snackbar only on error.
6. Icons
Use androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Search (tab) | magnifyingglass |
Icons.Filled.Search |
| Saved (tab) | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Trips (tab) | suitcase |
Icons.Filled.Work |
| Support (tab) | questionmark.circle |
Icons.Filled.HelpOutline |
| Account (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Save heart (card) | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Filter | slider.horizontal.3 |
Icons.Filled.Tune |
| Sort | arrow.up.arrow.down |
Icons.Filled.SwapVert |
| Flight | airplane |
Icons.Filled.Flight |
| Stay | bed.double |
Icons.Filled.Hotel |
| Car | car |
Icons.Filled.DirectionsCar |
| Bundle | shippingbox |
Icons.Filled.Inventory2 |
| Calendar | calendar |
Icons.Filled.CalendarMonth |
| Travelers | person.2 |
Icons.Filled.Group |
| Map | map |
Icons.Filled.Map |
| One Key | key.fill |
Icons.Filled.VpnKey |
| Price trend up | chart.line.uptrend.xyaxis |
Icons.Filled.TrendingUp |
7. Minimum SDK & Accessibility Notes
- minSdk 24,
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); light canvas wants dark-content system bars (light-content in dark mode). The collapsing top bar respects the camera cutout; the sticky CTA respects the gesture nav inset. - Font scaling:
sphonors the user’s font scale — keep it on display, titles, body, prices. Pin layout-sensitive text (10sp tab labels, deal flags/badges, One Key lines, the review-score number) via a fixed-density wrapper. - Font choice: Expedia Sans is proprietary — use it only if licensed; Inter is the parity substitute. Apply tabular figures (
fontFeatureSettings = "tnum") to prices and scores so list columns align. - TalkBack: label the card “Property: {title}, {scoreWord} {score} out of 10, {reviewCount} reviews, {nightlyPrice} dollars per night”; the save toggle “Save {title}, {checked/unchecked}”; expose the One Key earn line as informative text.
- Touch targets: Material guidance is 48.dp. The 30.dp save heart and segment pills should expand hit area to 48.dp via padding; bottom-bar items are 48.dp; primary buttons ≥ 48.dp tall.
- Contrast: Navy
#00355Fon Yellow#FFC94Dand white on Action Blue#1668E3pass WCAG AA at button sizes; validate any custom yellow pairing with a contrast checker. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, drop the heart bounce and bundle scale; substitute aCrossfade; keep the price-drop color flash (conveys state). - Dark mode: invert via the
Dark*palette —#0E1116navy-biased charcoal, NOT true black;#1A1F26text becomes#E8EBEF. Keep brand Yellow/ActionBlue/OneKeyGold fixed. Shadows are faint on dark, so add a 0.5dpDarkDividerborder to floating sheets as the elevation cue. Do not enabledynamicColorScheme()— Expedia’s identity must hold regardless of wallpaper.