coderef

Tripadvisor

Tripadvisor's iOS app is a bright, optimistic travel canvas built on pure white (#FFFFFF) with a single electric signature: Tripadvisor Green (#34E0A1), the spring-fresh mint introduced with the owl rebrand.
DownloadRaw
Tripadvisor

Build with the Tripadvisor 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,292 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Tripadvisor

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

Tripadvisor (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Tripadvisor’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 (Tripadvisor’s bright white gallery canvas, single-green accent, the five-circle bubble rating) while making everything idiomatic Android — NavigationBar instead of a UITabBar, Brush scrim instead of a CALayer gradient, 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/TripColors.kt
import androidx.compose.ui.graphics.Color

object TripColors {
    // Canvas & Surfaces
    val Canvas         = Color(0xFFFFFFFF)
    val Surface        = Color(0xFFF2F2F2)
    val Divider        = Color(0xFFE0E0E0)
    val SurfacePressed = Color(0xFFE8E8E8)

    // Text
    val TextPrimary    = Color(0xFF000000)
    val TextSecondary  = Color(0xFF6B6B6B)
    val TextTertiary   = Color(0xFF9B9B9B)

    // Brand
    val Green          = Color(0xFF34E0A1)
    val GreenPressed   = Color(0xFF21C589)
    val OwlBlack       = Color(0xFF000000)

    // Semantic
    val EmptyBubble    = Color(0xFFD9D9D9)
    val ErrorRed       = Color(0xFFD6122E)
}

Wire it into a Material 3 lightColorScheme so ripples, dividers, and default component colors inherit the brand. Tripadvisor is light-first.

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

private val TripScheme = lightColorScheme(
    primary        = TripColors.Green,
    onPrimary      = TripColors.OwlBlack,    // intentional: black on bright mint
    background     = TripColors.Canvas,
    onBackground   = TripColors.TextPrimary,
    surface        = TripColors.Canvas,
    onSurface      = TripColors.TextPrimary,
    surfaceVariant = TripColors.Surface,
    outline        = TripColors.Divider,
    error          = TripColors.ErrorRed,
)

@Composable
fun TripTheme(content: @Composable () -> Unit) =
    MaterialTheme(colorScheme = TripScheme, typography = TripTypography, content = content)

2. Typography

Trip Sans 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/TripType.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 TripSans = 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 TripText {
    val TitleLarge = TextStyle(TripSans, fontWeight = FontWeight.Bold,     fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.4).sp)
    val PlaceHero  = TextStyle(TripSans, fontWeight = FontWeight.Bold,     fontSize = 24.sp, lineHeight = 29.sp, letterSpacing = (-0.3).sp)
    val Section    = TextStyle(TripSans, fontWeight = FontWeight.Bold,     fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
    val PlaceName  = TextStyle(TripSans, fontWeight = FontWeight.Bold,     fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.1).sp)
    val CardTitle  = TextStyle(TripSans, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 21.sp, letterSpacing = (-0.1).sp)
    val ReviewBody = TextStyle(TripSans, fontWeight = FontWeight.Normal,   fontSize = 15.sp, lineHeight = 22.sp)
    val Button     = TextStyle(TripSans, fontWeight = FontWeight.Bold,     fontSize = 16.sp, lineHeight = 20.sp)
    val ButtonSec  = TextStyle(TripSans, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 19.sp)
    val Meta       = TextStyle(TripSans, fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 17.sp)
    val Tab        = TextStyle(TripSans, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.2.sp)
    val Badge      = TextStyle(TripSans, fontWeight = FontWeight.Bold,     fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.4.sp)
    val Caption    = TextStyle(TripSans, fontWeight = FontWeight.Normal,   fontSize = 12.sp, lineHeight = 16.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val TripTypography = Typography(
    headlineLarge = TripText.TitleLarge,
    headlineSmall = TripText.Section,
    titleMedium   = TripText.PlaceName,
    bodyMedium    = TripText.ReviewBody,
    labelSmall    = TripText.Tab,
)

3. Signature Components

Five-Circle Bubble Rating

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp

@Composable
fun BubbleRating(
    value: Float,                 // 0f .. 5f
    size: Dp = 16.dp,
    reviewCount: Int? = null,
    modifier: Modifier = Modifier,
) {
    Row(modifier, verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(6.dp)) {
        Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
            repeat(5) { i ->
                val fill = (value - i).coerceIn(0f, 1f) // 0, 0.5, or 1
                Canvas(Modifier.size(size)) {
                    val r = this.size.minDimension / 2f
                    drawCircle(TripColors.EmptyBubble, r, Offset(r, r))
                    if (fill > 0f) {
                        clipRect(right = this.size.width * fill) {
                            drawCircle(TripColors.Green, r, Offset(r, r))
                        }
                    }
                }
            }
        }
        if (reviewCount != null) {
            Text(
                "%.1f (%,d)".format(value, reviewCount),
                style = TripText.Meta,
                color = TripColors.TextSecondary,
            )
        }
    }
}

Interactive Bubble Picker (Review Write)

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

@Composable
fun BubblePicker(
    rating: Int,
    onChange: (Int) -> Unit,
    size: Dp = 32.dp,
) {
    val haptics = LocalHapticFeedback.current
    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
        (1..5).forEach { i ->
            val selected = i <= rating
            val scale by animateFloatAsState(
                targetValue = if (i == rating) 1.15f else 1f,
                animationSpec = spring(dampingRatio = 0.55f),
                label = "bubble$i",
            )
            Box(
                Modifier
                    .size(size)
                    .scale(scale)
                    .clip(CircleShape)
                    .background(if (selected) TripColors.Green else TripColors.EmptyBubble)
                    .clickable(remember { MutableInteractionSource() }, indication = null) {
                        haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ~iOS light impact
                        onChange(i)
                    }
            )
        }
    }
}

Primary CTA Pill

enum class PillStyle { Filled, Outline }

@Composable
fun TripPillButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    style: PillStyle = PillStyle.Filled,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.97f else 1f, label = "pillScale")
    val filled = style == PillStyle.Filled

    Box(
        modifier = modifier
            .scale(scale)
            .clip(CircleShape)
            .then(
                if (filled) Modifier.background(if (pressed) TripColors.GreenPressed else TripColors.Green)
                else Modifier.border(1.dp, TripColors.OwlBlack, CircleShape)
            )
            .clickable(interaction, indication = null, onClick = onClick)
            .padding(horizontal = if (filled) 28.dp else 24.dp, vertical = if (filled) 14.dp else 12.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(text, style = if (filled) TripText.Button else TripText.ButtonSec, color = TripColors.OwlBlack)
    }
}

Place Card

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.Icon
import coil.compose.AsyncImage

@Composable
fun PlaceCard(
    name: String,
    photoUrl: String,
    rating: Float,
    reviews: Int,
    category: String,
    priceTier: String,
    distance: String,
    awarded: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    var saved by remember { mutableStateOf(false) }
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "cardScale")

    Column(
        modifier
            .scale(scale)
            .clickable(interaction, indication = null, onClick = onClick),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        Box {
            AsyncImage(
                model = photoUrl,
                contentDescription = name,
                modifier = Modifier
                    .fillMaxWidth()
                    .aspectRatio(3f / 2f)
                    .clip(RoundedCornerShape(16.dp)),
                contentScale = ContentScale.Crop,
            )
            Box(
                Modifier
                    .align(Alignment.TopEnd)
                    .padding(12.dp)
                    .size(36.dp)
                    .clip(CircleShape)
                    .background(Color.Black.copy(alpha = 0.30f))
                    .clickable { saved = !saved },
                contentAlignment = Alignment.Center,
            ) {
                Icon(
                    if (saved) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
                    contentDescription = if (saved) "Saved" else "Save",
                    tint = if (saved) TripColors.Green else Color.White,
                    modifier = Modifier.size(20.dp),
                )
            }
        }

        if (awarded) {
            Box(
                Modifier
                    .clip(CircleShape)
                    .background(TripColors.Green)
                    .padding(horizontal = 10.dp, vertical = 4.dp),
            ) { Text("TRAVELERS' CHOICE", style = TripText.Badge, color = TripColors.OwlBlack) }
        }

        Text(name, style = TripText.PlaceName, color = TripColors.TextPrimary)
        BubbleRating(value = rating, size = 16.dp, reviewCount = reviews)
        Text("$category · $priceTier · $distance", style = TripText.Meta, color = TripColors.TextSecondary)
    }
}

Place Detail Hero (scrim over photo)

import androidx.compose.ui.graphics.Brush

@Composable
fun PlaceHero(
    name: String,
    photoUrl: String,
    rating: Float,
    reviews: Int,
) {
    Box(Modifier.fillMaxWidth().height(280.dp)) {
        AsyncImage(
            model = photoUrl,
            contentDescription = "$name photo",
            modifier = Modifier.fillMaxSize(),
            contentScale = ContentScale.Crop,
        )
        Box(
            Modifier
                .fillMaxSize()
                .background(
                    Brush.verticalGradient(
                        0.5f to Color.Transparent,
                        1f to Color.Black.copy(alpha = 0.55f),
                    )
                )
        )
        Column(
            Modifier.align(Alignment.BottomStart).padding(20.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text(name, style = TripText.PlaceHero, color = Color.White)
            Row(verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.spacedBy(6.dp)) {
                BubbleRating(value = rating, size = 18.dp)
                Text("%,d reviews".format(reviews), style = TripText.Meta, color = Color.White)
            }
        }
    }
}

4. Bottom Navigation (Tab Bar)

Use Material 3 NavigationBar. Tripadvisor’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use a 96%-opaque white surface. Active tint is Tripadvisor Green — green is the active indicator here.

import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*

@Composable
fun TripBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = TripColors.Canvas.copy(alpha = 0.96f),
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Explore" to Icons.Filled.Explore,
            "Search"  to Icons.Filled.Search,
            "Trips"   to Icons.Filled.Work,
            "Review"  to Icons.Filled.Create,
            "More"    to Icons.Filled.MoreHoriz,
        )
        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 = TripText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = TripColors.Green,
                    selectedTextColor   = TripColors.Green,
                    unselectedIconColor = TripColors.TextSecondary,
                    unselectedTextColor = TripColors.TextSecondary,
                    indicatorColor      = Color.Transparent, // no Material pill — Tripadvisor has none
                ),
            )
        }
    }
}

5. Navigation

The top bar carries the owl logomark (left, 28.dp), a location pill (center), and the profile avatar (right). Below it, a pinned #F2F2F2 search field at 48.dp with a full-pill CircleShape. On scroll, collapse to a compact blurred bar — Android has no live blur, so use the 96%-opaque white surface and a 1.dp Divider. Render the search field in a Scaffold topBar slot so list content scrolls beneath it.

6. Motion

Moment Compose recipe
Save-heart bounce Animatable keyframes 1 → 1.2 → 1 over 280ms; HapticFeedbackType.LongPress
Bubble selection animateFloatAsState 1 → 1.15 spring(dampingRatio = 0.55f); HapticFeedbackType.TextHandleMove
Card tap animateFloatAsState 1 → 0.98 spring(dampingRatio = 0.8f)
Hero parallax read LazyListState.firstVisibleItemScrollOffset, apply Modifier.graphicsLayer { translationY = offset * 0.5f } to the hero
Tab switch Crossfade 200ms, active icon swaps to filled variant
// Save-heart bounce
val scale = remember { Animatable(1f) }
LaunchedEffect(saved) {
    if (saved) {
        scale.animateTo(1.2f, tween(140))
        scale.animateTo(1f, tween(140))
    }
}

Haptics: prefer LocalHapticFeedback. For richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(8, ...) to approximate iOS’s .light impact.

7. Icons

The closest first-party set is androidx.compose.material:material-icons-extended. For exact parity with Tripadvisor’s custom glyphs, export them as vector drawables and load via ImageVector.vectorResource(R.drawable.…).

Purpose SF Symbol (iOS) Material Icon (Compose)
Search magnifyingglass Icons.Filled.Search
Save heart heart / heart.fill Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite
Share square.and.arrow.up Icons.Filled.Share
Back chevron.left Icons.AutoMirrored.Filled.ArrowBack
Filters line.3.horizontal.decrease Icons.Filled.Tune
Location pin mappin.and.ellipse Icons.Filled.LocationOn
Directions arrow.triangle.turn.up.right.diamond Icons.Filled.Navigation
Helpful vote hand.thumbsup Icons.Outlined.ThumbUp / Icons.Filled.ThumbUp
Explore (tab) safari.fill Icons.Filled.Explore
Trips (tab) suitcase.fill Icons.Filled.Work
Review (tab) square.and.pencil Icons.Filled.Create
More (tab) ellipsis Icons.Filled.MoreHoriz

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() in the Activity; the white canvas wants WindowCompat dark-content system bars. Apply Modifier.windowInsetsPadding(WindowInsets.systemBars) or Scaffold insets; the detail hero bleeds full-width while the sticky bar clears the status bar.
  • Font scaling: sp honors the user’s font scale automatically — keep it on place names, review body, headers. Pin layout-sensitive units (the bubble rating, tab labels, photo-count pill) by deriving size from dp or wrapping in CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)).
  • TalkBack: expose the bubble rating as one combined contentDescription ("Rated 4.5 out of 5, 1,284 reviews"); make the review picker Modifier.semantics { stateDescription = "$rating of 5 bubbles" } with an adjustable role; mark the save heart as a separate toggle button.
  • Touch targets: Material guidance is 48.dp minimum. The CTA pill clears it; ensure the 36.dp save-heart overlay has a 48.dp hit area via padding, and each review-form bubble has ≥44.dp effective touch.
  • Contrast: #6B6B6B on #FFFFFF passes WCAG AA at 13sp+. Black-on-#34E0A1 is intentional and high-contrast — do not switch to white text.
  • Dynamic color: do not enable Material You dynamicLightColorScheme() — Tripadvisor’s brand requires the fixed white canvas and the single mint-green accent regardless of wallpaper.
All mobile designs →

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