Bumble (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Bumble’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the Hexagon shape, the glowing heart action button, the It’s-a-Match celebration, and the 24-hour countdown chip.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (Bumble’s stadium-loud yellow, pure-black-on-yellow contrast, the hexagon spatial signature, the heavy Brando ladder) while making everything idiomatic Android — a custom
Shapefor the hex,NavigationBarinstead of a UITabBar,Brushglow approximated with elevation + a radial scrim,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for profile photos. No color extraction, so no androidx.palette.
1. Color Tokens
// ui/theme/BumbleColors.kt
import androidx.compose.ui.graphics.Color
object BumbleColors {
// Brand
val Yellow = Color(0xFFFFC629) // the brand — CTAs, tab indicator, match, heart
val HoneyDeep = Color(0xFFF5B616) // pressed state on yellow
val YellowLight = Color(0xFFFFE9A1) // soft chip fills, premium gradients
// Mode colors (Date / BFF / Bizz)
val BFFTeal = Color(0xFF11AAA8)
val BizzOrange = Color(0xFFFF8000)
// Canvas & Surfaces
val Canvas = Color(0xFFFFFFFF) // Date mode
val BFFCream = Color(0xFFFFFCF2) // BFF mode tint
val Surface1 = Color(0xFFF5F5F5) // input fills, settings rows
val Surface2 = Color(0xFFEDEDED) // pressed states, chip fills
val Divider = Color(0xFFE5E5E5) // 0.5dp hairlines
// Text
val Black = Color(0xFF1F1F1F) // primary on white (warm, NOT pure)
val Slate = Color(0xFF5A5A5A) // secondary
val Mist = Color(0xFF9C9C9C) // tertiary, placeholders
val OnYellow = Color(0xFF000000) // pure black — required on yellow for WCAG AA
// Semantic
val MatchPink = Color(0xFFE94B7B) // ONLY the It's-a-Match heart
val Verified = Color(0xFF0066FF) // verified checkmark
val Warning = Color(0xFFFF9500) // incomplete-profile warning
val Error = Color(0xFFD72638) // validation errors, expired countdown
val Success = Color(0xFF00A86B) // confirmation toasts
// Dark mode (warm OLED — yellow unchanged)
val DarkCanvas = Color(0xFF0F0F0F)
val DarkSurface1 = Color(0xFF1A1A1A)
val DarkSurface2 = Color(0xFF2A2A2A)
val DarkDivider = Color(0xFF2F2F2F)
val DarkText = Color(0xFFF2F2F2)
val DarkTextSec = Color(0xFF9C9C9C)
val HoneyDeepDk = Color(0xFFFFD45C) // brightened for OLED pressed states
}
Wire it into a Material 3 lightColorScheme (Date mode) — Bumble’s primary surface is white. Provide a parallel dark scheme; the yellow stays identical because it reads loud on OLED.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val BumbleLight = lightColorScheme(
primary = BumbleColors.Yellow,
onPrimary = BumbleColors.OnYellow, // pure black on yellow — non-negotiable
background = BumbleColors.Canvas,
onBackground = BumbleColors.Black,
surface = BumbleColors.Canvas,
onSurface = BumbleColors.Black,
surfaceVariant = BumbleColors.Surface1,
outline = BumbleColors.Divider,
error = BumbleColors.Error,
)
private val BumbleDark = darkColorScheme(
primary = BumbleColors.Yellow, // unchanged on dark
onPrimary = BumbleColors.OnYellow,
background = BumbleColors.DarkCanvas,
onBackground = BumbleColors.DarkText,
surface = BumbleColors.DarkSurface1,
onSurface = BumbleColors.DarkText,
surfaceVariant = BumbleColors.DarkSurface2,
outline = BumbleColors.DarkDivider,
error = BumbleColors.Error,
)
@Composable
fun BumbleTheme(content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) BumbleDark else BumbleLight,
typography = BumbleTypography,
content = content,
)
2. Typography
Brando is the proprietary Bumble face — slab-influenced, humanist, heavy. Drop the TTFs in res/font/ (lowercase, snake_case). Bumble has no Light or Thin — only 400/500/700/900. Fall back to Roboto (the closest free Android substitute); for a tighter brand match consider bundling Bricolage Grotesque / Manrope Heavy.
// ui/theme/BumbleType.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 Brando = FontFamily(
Font(R.font.brando_regular, FontWeight.Normal), // 400
Font(R.font.brando_medium, FontWeight.Medium), // 500
Font(R.font.brando_bold, FontWeight.Bold), // 700
Font(R.font.brando_black, FontWeight.Black), // 900
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1, same weights/tracking)
object BumbleText {
val MatchHero = TextStyle(Brando, fontWeight = FontWeight.Black, fontSize = 44.sp, lineHeight = 46.sp, letterSpacing = (-0.8).sp)
val Display = TextStyle(Brando, fontWeight = FontWeight.Black, fontSize = 32.sp, lineHeight = 35.sp, letterSpacing = (-0.6).sp)
val ScreenTitle = TextStyle(Brando, fontWeight = FontWeight.Black, fontSize = 24.sp, lineHeight = 28.sp, letterSpacing = (-0.4).sp)
val CardName = TextStyle(Brando, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 31.sp, letterSpacing = (-0.4).sp)
val Section = TextStyle(Brando, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 22.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(Brando, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 22.sp)
val BodyBold = TextStyle(Brando, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 22.sp)
val BodySmall = TextStyle(Brando, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp)
val Button = TextStyle(Brando, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val ButtonLarge = TextStyle(Brando, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 18.sp)
val Tab = TextStyle(Brando, fontWeight = FontWeight.Bold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.2.sp)
val Chip = TextStyle(Brando, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 13.sp)
val Meta = TextStyle(Brando, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 17.sp)
val Counter = TextStyle(Brando, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.2.sp)
val Compliment = TextStyle(Brando, fontWeight = FontWeight.Black, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val BumbleTypography = Typography(
displayLarge = BumbleText.Display,
headlineLarge = BumbleText.ScreenTitle,
titleLarge = BumbleText.CardName,
titleMedium = BumbleText.Section,
bodyMedium = BumbleText.Body,
labelSmall = BumbleText.Tab,
)
The 24-hour countdown is the only place numerals must hold column width — pin tabular figures on Meta (countdown) and Counter (badges) with TextStyle(fontFeatureSettings = "tnum").
3. Signature Components
Hexagon Shape (the brand spatial signature)
Bumble’s hex is point-up. This is the SwiftUI Hexagon: Shape ported to a Compose Shape — reuse it for match avatars, mode-toggle buttons, and the brand mark.
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
val HexagonShape = object : Shape {
override fun createOutline(
size: androidx.compose.ui.geometry.Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
val r = min(size.width, size.height) / 2f
val cx = size.width / 2f
val cy = size.height / 2f
val path = Path()
for (i in 0 until 6) {
val angle = (Math.PI / 3 * i - Math.PI / 2).toFloat() // point-up
val x = cx + r * cos(angle)
val y = cy + r * sin(angle)
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
path.close()
return Outline.Generic(path)
}
}
Yellow Primary CTA
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
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.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.foundation.clickable
import androidx.compose.ui.unit.dp
@Composable
fun BumblePrimaryButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
hasGlow: Boolean = false,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, label = "ctaScale")
val haptics = LocalHapticFeedback.current
Box(
modifier = modifier
.fillMaxWidth()
.height(56.dp)
.scale(scale)
// Yellow glow halo — Android has no colored blur shadow, so a tinted
// ambient/spot shadow approximates the brand glow on hero CTAs.
.then(
if (hasGlow)
Modifier.shadow(24.dp, CircleShape, ambientColor = BumbleColors.Yellow,
spotColor = BumbleColors.Yellow)
else Modifier
)
.clip(CircleShape) // 28dp capsule == full pill at 56dp height
.background(if (pressed) BumbleColors.HoneyDeep else BumbleColors.Yellow)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ .impact(.medium)
onClick()
},
contentAlignment = Alignment.Center,
) {
Text(label, style = BumbleText.Button, color = BumbleColors.OnYellow) // pure black
}
}
Swipe Action Row (under the swipe card)
Five circular buttons; the yellow heart is the hero with a glow halo. Press scales to 0.9; the haptic varies per button.
import androidx.compose.foundation.border
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material3.Icon
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun SwipeActionRow(
onRewind: () -> Unit,
onPass: () -> Unit,
onLike: () -> Unit,
onSuper: () -> Unit,
onCompliment: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
ActionCircle(48.dp, BumbleColors.Surface1, null, HapticFeedbackType.TextHandleMove, onRewind) {
Icon(Icons.AutoMirrored.Filled.Undo, "Rewind", tint = BumbleColors.Mist, modifier = Modifier.size(20.dp))
}
ActionCircle(56.dp, BumbleColors.Canvas, BumbleColors.Black, HapticFeedbackType.TextHandleMove, onPass) {
Icon(Icons.Filled.Close, "Pass", tint = BumbleColors.Black, modifier = Modifier.size(22.dp))
}
// Heart Yes — 64dp hero with yellow glow halo
HeartActionButton(onLike)
ActionCircle(56.dp, BumbleColors.Yellow, null, HapticFeedbackType.LongPress, onSuper) {
Icon(Icons.Filled.Star, "SuperSwipe", tint = BumbleColors.OnYellow, modifier = Modifier.size(22.dp))
}
ActionCircle(56.dp, BumbleColors.Canvas, BumbleColors.Yellow, HapticFeedbackType.TextHandleMove, onCompliment) {
// Bee glyph is proprietary — ship as a vector drawable; placeholder star here
Icon(Icons.Filled.Star, "Compliment", tint = BumbleColors.Yellow, modifier = Modifier.size(22.dp))
}
}
}
@Composable
private fun ActionCircle(
diameter: androidx.compose.ui.unit.Dp,
fill: Color,
stroke: Color?,
haptic: HapticFeedbackType,
onClick: () -> Unit,
content: @Composable () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.9f else 1f, label = "actionScale")
val haptics = LocalHapticFeedback.current
Box(
Modifier
.size(diameter)
.scale(scale)
.clip(CircleShape)
.background(fill)
.then(if (stroke != null) Modifier.border(1.5.dp, stroke, CircleShape) else Modifier)
.clickable(interaction, indication = null) { haptics.performHapticFeedback(haptic); onClick() },
contentAlignment = Alignment.Center,
) { content() }
}
@Composable
private fun HeartActionButton(onLike: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 1.2f else 1f,
animationSpec = spring(dampingRatio = 0.6f, stiffness = 600f),
label = "heartScale",
)
val haptics = LocalHapticFeedback.current
Box(
Modifier
.size(64.dp)
.scale(scale)
.shadow(16.dp, CircleShape, ambientColor = BumbleColors.Yellow, spotColor = BumbleColors.Yellow)
.clip(CircleShape)
.background(BumbleColors.Yellow)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ .impact(.medium)
onLike()
},
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Favorite, "Like", tint = BumbleColors.OnYellow, modifier = Modifier.size(28.dp)) }
}
Swipe Card (the hero component)
3:4 photo, 12dp radius (firmer edge than Hinge), 4-segment Stories-style progress bar at the top, bottom gradient carrying name + verified check + bio.
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.material.icons.filled.Verified
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
@Composable
fun SwipeCard(
photoUrls: List<String>,
name: String,
age: Int,
bio: String,
isVerified: Boolean,
onSwiped: (liked: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
var current by remember { mutableStateOf(0) }
var dragX by remember { mutableStateOf(0f) }
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(3f / 4f)
.shadow(16.dp, RoundedCornerShape(12.dp), spotColor = Color.Black.copy(alpha = 0.12f))
.clip(RoundedCornerShape(12.dp))
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = { if (kotlin.math.abs(dragX) > size.width * 0.3f) onSwiped(dragX > 0); dragX = 0f },
) { _, drag -> dragX += drag }
},
) {
AsyncImage(
model = photoUrls[current],
contentDescription = "$name, $age",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Bottom gradient overlay (bottom 30%, transparent → 0.7 black)
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.fillMaxHeight(0.3f)
.background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.7f))))
)
// Photo progress bar — 4 segments, 3dp tall, 4dp gap
Row(
Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
repeat(photoUrls.size) { i ->
Box(
Modifier
.weight(1f)
.height(3.dp)
.clip(CircleShape)
.background(if (i == current) Color.White else Color.White.copy(alpha = 0.4f))
)
}
}
// Name + age + verified + bio
Column(
Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("$name, $age", style = BumbleText.CardName, color = Color.White)
if (isVerified) {
Icon(Icons.Filled.Verified, "Verified", tint = BumbleColors.Verified, modifier = Modifier.size(18.dp))
}
}
Text(bio, style = BumbleText.Body, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
24-Hour Countdown Chip (the core mechanic)
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.ui.draw.alpha
@Composable
fun CountdownChip(remainingSeconds: Long, modifier: Modifier = Modifier) {
val expired = remainingSeconds <= 0
val label = if (expired) "Time's up — extend?"
else "Your turn: ${remainingSeconds / 3600}h ${(remainingSeconds % 3600) / 60}m"
val t = rememberInfiniteTransition(label = "countdownPulse")
val a by t.animateFloat(
initialValue = 0.95f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1500), RepeatMode.Reverse),
label = "pulse",
)
Box(
modifier
.fillMaxWidth()
.height(32.dp)
.alpha(a)
.clip(CircleShape)
.background(if (expired) BumbleColors.Error else BumbleColors.Yellow)
.border(1.dp, if (expired) BumbleColors.Error else BumbleColors.HoneyDeep, CircleShape)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
style = BumbleText.Meta.copy(fontWeight = FontWeight.Bold, fontFeatureSettings = "tnum"),
color = if (expired) Color.White else BumbleColors.OnYellow, // pure black on yellow
)
}
}
It’s a Match! Celebration
import androidx.compose.animation.core.Animatable
import androidx.compose.ui.draw.scale
@Composable
fun MatchCelebration(
myAvatarUrl: String,
theirAvatarUrl: String,
theirName: String,
onSendMessage: () -> Unit,
onKeepSwiping: () -> Unit,
) {
val heartScale = remember { Animatable(0.2f) }
val haptics = LocalHapticFeedback.current
LaunchedEffect(Unit) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ .notification(.success)
heartScale.animateTo(1f, spring(dampingRatio = 0.55f, stiffness = 500f)) // bounce in
}
Box(Modifier.fillMaxSize().background(BumbleColors.Yellow)) { // full-screen yellow canvas
Column(
Modifier.fillMaxSize().padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(contentAlignment = Alignment.Center) {
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
HexAvatar(myAvatarUrl)
HexAvatar(theirAvatarUrl)
}
Icon(
Icons.Filled.Favorite, contentDescription = null,
tint = BumbleColors.MatchPink, // ONLY place this pink appears
modifier = Modifier.size(48.dp).scale(heartScale.value),
)
}
Spacer(Modifier.height(24.dp))
Text("It's a Match!", style = BumbleText.MatchHero, color = BumbleColors.Black)
Text("You and $theirName want to chat", style = BumbleText.Body, color = BumbleColors.Black)
Spacer(Modifier.height(12.dp))
Text("She has 24 hours to make the first move", style = BumbleText.Meta,
color = BumbleColors.Black.copy(alpha = 0.8f))
Spacer(Modifier.height(48.dp))
// Primary CTA is WHITE here (yellow canvas) with warm-black text
Box(
Modifier.fillMaxWidth().height(56.dp).clip(CircleShape).background(Color.White)
.clickable { onSendMessage() },
contentAlignment = Alignment.Center,
) { Text("Send a Message", style = BumbleText.ButtonLarge, color = BumbleColors.Black) }
Spacer(Modifier.height(16.dp))
Text("Keep Swiping", style = BumbleText.Button, color = BumbleColors.Black,
modifier = Modifier.clickable { onKeepSwiping() })
}
}
}
@Composable
fun HexAvatar(url: String, size: androidx.compose.ui.unit.Dp = 120.dp) {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier
.size(size)
.clip(HexagonShape)
.border(4.dp, Color.White, HexagonShape),
contentScale = ContentScale.Crop,
)
}
Chat Input with Send Button
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.ui.text.input.TextFieldValue
@Composable
fun BumbleChatInput(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
modifier: Modifier = Modifier,
) {
val canSend = value.text.isNotBlank()
val haptics = LocalHapticFeedback.current
Row(
modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
Modifier.weight(1f).clip(CircleShape).background(BumbleColors.Surface1)
.padding(horizontal = 20.dp, vertical = 14.dp),
) {
BasicTextField(
value = value, onValueChange = onValueChange,
textStyle = BumbleText.Body.copy(color = BumbleColors.Black),
decorationBox = { inner ->
if (value.text.isEmpty()) {
Text("Type your message…", style = BumbleText.Body, color = BumbleColors.Mist)
}
inner()
},
)
}
Box(
Modifier
.size(40.dp)
.clip(CircleShape)
.background(if (canSend) BumbleColors.Yellow else BumbleColors.Surface1)
.clickable(enabled = canSend) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onSend()
},
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.ArrowUpward, "Send",
tint = if (canSend) BumbleColors.OnYellow else BumbleColors.Mist,
modifier = Modifier.size(18.dp))
}
}
}
4. Mode Switching (the distinctive system)
Bumble’s defining structural feature is one app, three products: Date (yellow) / BFF (teal) / Bizz (orange). Switching modes recolors all brand chrome while the hexagon shape stays constant. Drive this with a CompositionLocal so every composable reads the active accent without prop-drilling.
enum class BumbleMode(val accent: Color, val pressed: Color) {
Date(BumbleColors.Yellow, BumbleColors.HoneyDeep),
BFF(BumbleColors.BFFTeal, Color(0xFF0E908E)),
Bizz(BumbleColors.BizzOrange, Color(0xFFE57300)),
}
val LocalBumbleMode = androidx.compose.runtime.staticCompositionLocalOf { BumbleMode.Date }
@Composable
fun BumbleModeProvider(mode: BumbleMode, content: @Composable () -> Unit) {
androidx.compose.runtime.CompositionLocalProvider(LocalBumbleMode provides mode, content = content)
}
// A hexagonal mode-toggle button — literally hex-shaped, not rectangular
@Composable
fun ModeHexButton(mode: BumbleMode, active: Boolean, onClick: () -> Unit) {
val haptics = LocalHapticFeedback.current
Box(
Modifier
.size(64.dp)
.clip(HexagonShape)
.then(
if (active) Modifier.background(mode.accent)
else Modifier.border(2.dp, BumbleColors.Mist, HexagonShape)
)
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ≈ .selection
onClick()
},
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Star, mode.name, // mode glyph (hive/heart/briefcase) — vector drawable in prod
tint = if (active) BumbleColors.OnYellow else BumbleColors.Mist, modifier = Modifier.size(24.dp))
}
}
Then read LocalBumbleMode.current.accent anywhere yellow appears (CTAs, tab indicator, match ring) so the whole app retints on switch. The hex is never replaced with a circle.
5. Navigation
Bumble has 5 bottom tabs: People / Hives / Matches / Chats / Profile — the Hives icon is a literal honeycomb. Use Material 3 NavigationBar. The iOS tab bar is opaque (Bumble likes hard surfaces, no blur) — Android has no live blur anyway, so use a solid Canvas container with a 0.5dp top divider. Active tint is warm Bumble Black with a 4dp yellow indicator dot below the label — not the Material pill.
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Hexagon
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
@Composable
fun BumbleBottomBar(selected: Int, onSelect: (Int) -> Unit) {
val accent = LocalBumbleMode.current.accent
NavigationBar(containerColor = BumbleColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
"People" to Icons.Filled.People,
"Hives" to Icons.Filled.Hexagon,
"Matches" to Icons.Filled.Favorite,
"Chats" to Icons.Filled.Chat,
"Profile" to Icons.Filled.Person,
)
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 = BumbleText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = BumbleColors.Black, // warm black, not yellow
selectedTextColor = BumbleColors.Black,
unselectedIconColor = BumbleColors.Mist,
unselectedTextColor = BumbleColors.Mist,
indicatorColor = accent.copy(alpha = 0.0f), // no Material pill — Bumble has none
),
)
}
}
}
Render the 4dp yellow (or mode-accent) indicator dot manually 4dp beneath the selected item’s label if you want exact parity — NavigationBar has no built-in dot affordance. The top header is a slim 56dp row (no large-title) with ScreenTitle text leading and trailing action glyphs.
6. Motion
| Moment | Compose recipe |
|---|---|
| Heart tap | animateFloatAsState 1 → 1.2 with spring(dampingRatio = 0.6f); HapticFeedbackType.LongPress (.impact medium) |
| SuperSwipe (star) | full-screen yellow flash 200ms via Animatable alpha 0→1→0; HapticFeedbackType.LongPress (.success) |
| Swipe commit | animateFloatAsState rotationZ → 8° + offset 500dp off-screen over 350ms (spring) |
| Yes/No confetti | rememberInfiniteTransition / Animatable driving falling yellow hexes for 800ms |
| Match celebration | photos slide in from edges; pink heart Animatable.animateTo(1f, spring(dampingRatio = 0.55f)) |
| 24h chip pulse | rememberInfiniteTransition alpha 0.95 → 1.0 over 1500ms RepeatMode.Reverse |
| Hex loading state | rememberInfiniteTransition rotationZ 0 → 360 over 1200ms on a 6-hex honeycomb |
| Tab switch | 200ms tween; indicator dot fadeIn; HapticFeedbackType.TextHandleMove (.selection) |
// SuperSwipe full-screen yellow flash
@Composable
fun SuperSwipeFlash(trigger: Boolean) {
val flash = remember { Animatable(0f) }
val haptics = LocalHapticFeedback.current
LaunchedEffect(trigger) {
if (trigger) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ .notification(.success)
flash.animateTo(1f, tween(200))
flash.animateTo(0f, tween(200))
}
}
if (flash.value > 0f) {
Box(Modifier.fillMaxSize().alpha(flash.value).background(BumbleColors.Yellow))
}
}
Honor Reduce Motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0f, skip the hex confetti and the heart scale-bounce on match — keep the haptics. For richer haptics use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(10, ...) to approximate iOS’s .soft Compliment tap. For the match-celebration shared photo, prefer SharedTransitionLayout + Modifier.sharedElement() (Compose 1.7+).
7. Icons
Bumble uses outline/filled pairs; the closest first-party set is androidx.compose.material:material-icons-extended. The bee glyph and the hexagonal “B” logomark are proprietary — ship them as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Heart action (filled) | heart.fill |
Icons.Filled.Favorite |
| X Pass | xmark |
Icons.Filled.Close |
| Star SuperSwipe | star.fill |
Icons.Filled.Star |
| Rewind | arrow.uturn.backward |
Icons.AutoMirrored.Filled.Undo |
| Bee Compliment | ant.fill (placeholder) |
custom vector drawable (proprietary bee) |
| Match heart | heart.fill (Match Pink) |
Icons.Filled.Favorite tinted MatchPink |
| Send (chat input) | arrow.up |
Icons.Filled.ArrowUpward |
| Verified check | checkmark.seal.fill |
Icons.Filled.Verified (tint Verified) |
| People tab | person.2 / .fill |
Icons.Filled.People |
| Hives tab | hexagon / .fill |
Icons.Filled.Hexagon |
| Matches tab | heart / .fill |
Icons.Filled.Favorite |
| Chats tab | bubble.left / .fill |
Icons.Filled.Chat |
| Profile tab | person.crop.circle / .fill |
Icons.Filled.Person |
| Filters | slider.horizontal.3 |
Icons.Filled.Tune |
| Settings | gearshape |
Icons.Filled.Settings |
| Brand “B” logomark | (hex bee mark) | custom vector drawable |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion +
SharedTransitionLayoutcomfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity. The light Date canvas wants dark system-bar icons (WindowCompatlight appearance); the dark scheme wants light icons. Pin the chat input above the IME withModifier.imePadding()and the tab bar above the gesture nav withScaffoldinsets. - Font scaling:
sphonors the user’s font scale automatically — keep it on match hero, display, screen titles, body, bio, section headers. Pin layout-sensitive text (10sp tab labels, 11sp counter badges, 13sp chip labels, the 24-hour countdown) by deriving size fromdpor wrapping inCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: announce the match screen as one node —
Modifier.semantics(mergeDescendants = true) { contentDescription = "It's a Match. You and $name want to chat. She has 24 hours. Send a Message button." }. Give the swipe card an accessibility action set ("Like","Pass","SuperSwipe","Compliment") so users can act without dragging. Re-announce the countdown chip when it crosses a 30-minute boundary. - Touch targets: Material guidance is 48.dp minimum. The 64dp heart and 56dp CTA clear it; the 48dp Rewind and 40dp send button are at/above the floor — keep the send button’s 40dp visual inside a 48dp hit area via padding.
- Contrast: Bumble Yellow
#FFC629requires pure black (#000000) text — warm#1F1F1Fon yellow fails WCAG AA at small sizes. UseBumbleColors.OnYellowon every yellow surface, no exceptions.#9C9C9CMist on white is borderline at body size — keep it to ≥14sp metadata and validate with a contrast checker. - Dynamic color: do not enable Material You
dynamicColorScheme()— Bumble’s identity is the fixed#FFC629yellow (and the teal/orange mode accents) regardless of wallpaper. (Material You suits Google/Material-first apps; Bumble is a strong-brand exception.) The yellow stays identical across light and dark; only canvas/surface/text tokens invert.