Grindr (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Grindr’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 (Grindr’s true-black canvas, single-yellow accent, proximity cascade, scrimmed overlay text) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,LazyVerticalGridfor the cascade,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for profile photography.
1. Color Tokens
// ui/theme/GrindrColors.kt
import androidx.compose.ui.graphics.Color
object GrindrColors {
// Canvas & Surfaces (dark-only)
val Canvas = Color(0xFF000000)
val Surface1 = Color(0xFF1A1A1A)
val Surface2 = Color(0xFF222222)
val Surface3 = Color(0xFF2C2C2C)
val Divider = Color(0xFF2C2C2C)
// Text
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF999999)
val TextTertiary = Color(0xFF5C5C5C)
// Brand
val Yellow = Color(0xFFFFDE00)
val YellowPressed = Color(0xFFE6C700)
val YellowTint = Color(0xFFFFF2A8)
// Semantic
val Online = Color(0xFF4CD964)
val Error = Color(0xFFFF3B30)
val Info = Color(0xFF3D8BFF)
}
Wire it into a Material 3 darkColorScheme so ripples, dividers, and default component colors inherit the brand. Grindr is dark-only; do not provide a light scheme.
// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
private val GrindrScheme = darkColorScheme(
primary = GrindrColors.Yellow,
onPrimary = Color.Black, // intentional: black on yellow
background = GrindrColors.Canvas,
onBackground = GrindrColors.TextPrimary,
surface = GrindrColors.Surface1,
onSurface = GrindrColors.TextPrimary,
surfaceVariant = GrindrColors.Surface2,
outline = GrindrColors.Divider,
error = GrindrColors.Error,
)
@Composable
fun GrindrTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = GrindrScheme, typography = GrindrTypography, content = content)
2. Typography
Grindr ships a custom product grotesque. The closest free substitute is Inter — drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the system grotesque (Roboto) if Inter is unavailable.
// ui/theme/GrindrType.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 Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal), // 400
Font(R.font.inter_bold, FontWeight.Bold), // 700
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object GrindrText {
val ProfileName = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 28.sp, letterSpacing = (-0.3).sp)
val Title = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
val Section = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
val TileName = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 18.sp)
val RowTitle = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 21.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Message = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 22.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val Distance = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 14.sp, letterSpacing = 0.2.sp)
val LabelUpper = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.2.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val GrindrTypography = Typography(
headlineLarge = GrindrText.ProfileName,
headlineSmall = GrindrText.Title,
titleMedium = GrindrText.RowTitle,
bodyMedium = GrindrText.Body,
labelSmall = GrindrText.Tab,
)
3. Signature Components
Cascade Grid Tile
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.border
import coil.compose.AsyncImage
@Composable
fun GrindrTile(
name: String,
distance: String,
photoUrl: String,
online: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Box(
modifier
.aspectRatio(1f)
.scale(if (pressed) 0.97f else 1f)
.clickable(interaction, indication = null, onClick = onClick),
) {
AsyncImage(
model = photoUrl,
contentDescription = "$name, $distance${if (online) ", online" else ""}",
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop,
)
// Bottom scrim for overlay legibility
Box(
Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
0.55f to Color.Transparent,
1f to Color.Black.copy(alpha = 0.65f),
)
)
)
if (online) {
Box(
Modifier
.align(Alignment.TopEnd).padding(8.dp)
.size(10.dp).clip(CircleShape)
.background(GrindrColors.Online)
.border(2.dp, Color.Black, CircleShape)
)
}
Column(
Modifier.align(Alignment.BottomStart).padding(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(name, style = GrindrText.TileName, color = Color.White,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(distance, style = GrindrText.Distance, color = Color.White)
}
}
}
Yellow Tap Button
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.LocalFireDepartment
import androidx.compose.material3.Icon
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.Dp
@Composable
fun GrindrTapButton(
tapped: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Dp = 56.dp,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
if (pressed) 0.92f else 1f,
spring(dampingRatio = 0.7f, stiffness = 600f), label = "tapScale",
)
val haptics = LocalHapticFeedback.current
Box(
modifier
.size(size)
.scale(scale)
.clip(CircleShape)
.then(
if (tapped) Modifier.border(1.dp, GrindrColors.TextSecondary, CircleShape)
else Modifier.background(if (pressed) GrindrColors.YellowPressed else GrindrColors.Yellow)
)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onClick()
},
contentAlignment = Alignment.Center,
) {
Icon(
if (tapped) Icons.Filled.Check else Icons.Filled.LocalFireDepartment,
contentDescription = if (tapped) "Tapped" else "Tap",
tint = if (tapped) Color.White else Color.Black, // intentional: black on yellow
modifier = Modifier.size(size * 0.42f),
)
}
}
Primary Pill (Send / Chat)
enum class PillStyle { Filled, Outline }
@Composable
fun GrindrPillButton(
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
.scale(scale)
.clip(CircleShape)
.then(
if (filled) Modifier.background(if (pressed) GrindrColors.YellowPressed else GrindrColors.Yellow)
else Modifier.border(1.dp, GrindrColors.TextSecondary, CircleShape)
)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = if (filled) 28.dp else 24.dp, vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(text, style = GrindrText.Button,
color = if (filled) Color.Black else GrindrColors.TextPrimary)
}
}
Chat Bubble
import androidx.compose.foundation.shape.RoundedCornerShape
@Composable
fun GrindrBubble(text: String, outgoing: Boolean, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(
topStart = 18.dp, topEnd = 18.dp,
bottomEnd = if (outgoing) 4.dp else 18.dp,
bottomStart = if (outgoing) 18.dp else 4.dp,
)
Box(
modifier
.fillMaxWidth()
.wrapContentWidth(if (outgoing) Alignment.End else Alignment.Start),
) {
Box(
Modifier
.widthIn(max = 280.dp)
.clip(shape)
.background(if (outgoing) GrindrColors.Yellow else GrindrColors.Surface2)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(text, style = GrindrText.Message,
color = if (outgoing) Color.Black else Color.White)
}
}
}
Message List Row
@Composable
fun GrindrMessageRow(
name: String,
preview: String,
time: String,
avatarUrl: String,
online: Boolean,
unread: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Row(
modifier
.fillMaxWidth()
.height(72.dp)
.background(if (pressed) GrindrColors.Surface1 else GrindrColors.Canvas)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box {
AsyncImage(avatarUrl, null,
Modifier.size(52.dp).clip(CircleShape), contentScale = ContentScale.Crop)
if (online) {
Box(
Modifier.align(Alignment.TopEnd)
.size(12.dp).clip(CircleShape)
.background(GrindrColors.Online)
.border(2.dp, Color.Black, CircleShape)
)
}
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(name, style = GrindrText.RowTitle, color = Color.White,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(preview, style = GrindrText.Meta,
color = if (unread) Color.White.copy(alpha = 0.85f) else GrindrColors.TextSecondary,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(time, style = GrindrText.Meta.copy(fontSize = 12.sp), color = GrindrColors.TextSecondary)
if (unread) Box(Modifier.size(8.dp).clip(CircleShape).background(GrindrColors.Yellow))
}
}
}
4. Cascade Grid (lazy-load fade)
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.core.tween
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
@Composable
fun GrindrCascade(profiles: List<Profile>, onOpen: (Profile) -> Unit) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.background(GrindrColors.Canvas),
) {
items(profiles, key = { it.id }) { p ->
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
AnimatedVisibility(visible, enter = fadeIn(tween(180))) {
GrindrTile(p.name, p.distance, p.photoUrl, p.online, { onOpen(p) })
}
}
}
}
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. Grindr’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use a 94%-opaque black surface. Active tint is Grindr Yellow.
@Composable
fun GrindrBottomBar(selected: Int, onSelect: (Int) -> Unit, unreadTaps: Int = 0, unreadMsgs: Int = 0) {
NavigationBar(
containerColor = GrindrColors.Canvas.copy(alpha = 0.94f),
tonalElevation = 0.dp,
) {
data class Item(val label: String, val icon: ImageVector, val badge: Int)
val items = listOf(
Item("Browse", Icons.Filled.GridView, 0),
Item("Taps", Icons.Filled.LocalFireDepartment, unreadTaps),
Item("Messages", Icons.Filled.ChatBubble, unreadMsgs),
Item("Profile", Icons.Filled.Person, 0),
)
items.forEachIndexed { i, it ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = {
if (it.badge > 0) {
BadgedBox(badge = {
Badge(containerColor = GrindrColors.Yellow, contentColor = Color.Black) {
Text("${it.badge}", style = GrindrText.Tab)
}
}) { Icon(it.icon, it.label, Modifier.size(26.dp)) }
} else {
Icon(it.icon, it.label, Modifier.size(26.dp))
}
},
label = { Text(it.label, style = GrindrText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = GrindrColors.Yellow,
selectedTextColor = GrindrColors.Yellow,
unselectedIconColor = GrindrColors.TextSecondary,
unselectedTextColor = GrindrColors.TextSecondary,
indicatorColor = Color.Transparent, // Grindr has no Material pill
),
)
}
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Tap send | animateFloatAsState 1 → 0.92 spring(dampingRatio = 0.7f); HapticFeedbackType.LongPress (≈ iOS success) |
| Cascade lazy-load | AnimatedVisibility(enter = fadeIn(tween(180))) per tile as it composes |
| Profile sheet present | SharedTransitionLayout + Modifier.sharedElement() on the tile photo → hero (Compose 1.7+); fallback slideInVertically |
| Photo carousel | HorizontalPager with a PagerState; dot indicator from currentPage |
| Message send | outgoing bubble AnimatedVisibility(enter = scaleIn(initialScale = 0.9f) + fadeIn()) |
| Pull-to-refresh | PullToRefreshContainer tinted GrindrColors.Yellow |
// Tap success "flame pulse" — a brief expanding ring behind the button
@Composable
fun FlamePulse(trigger: Boolean) {
val r = remember { Animatable(0f) }
LaunchedEffect(trigger) {
if (trigger) { r.snapTo(0f); r.animateTo(1f, tween(360)) }
}
Box(
Modifier
.size(56.dp)
.scale(1f + r.value * 0.6f)
.clip(CircleShape)
.background(GrindrColors.Yellow.copy(alpha = (1f - r.value) * 0.5f))
)
}
Haptics: prefer LocalHapticFeedback. For richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONFIRM) (API 30+) or a Vibrator VibrationEffect.createOneShot(12, ...) to approximate iOS’s success notification.
7. Icons
Closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Grindr’s glyphs (mask logomark, tap flame) as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Tap action | flame.fill |
Icons.Filled.LocalFireDepartment |
| Tapped (sent) | checkmark |
Icons.Filled.Check |
| Favorite | star / star.fill |
Icons.Outlined.StarBorder / Icons.Filled.Star |
| Block | nosign |
Icons.Filled.Block |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Send arrow | arrow.up.circle.fill |
Icons.Filled.ArrowCircleUp |
| Photo / camera | camera.fill |
Icons.Filled.PhotoCamera |
| Filter | slider.horizontal.3 |
Icons.Filled.Tune |
| Search | magnifyingglass |
Icons.Filled.Search |
| Settings | gearshape.fill |
Icons.Filled.Settings |
| Browse (tab) | square.grid.3x3.fill |
Icons.Filled.GridView |
| Taps (tab) | flame.fill |
Icons.Filled.LocalFireDepartment |
| Messages (tab) | bubble.left.fill |
Icons.Filled.ChatBubble |
| Profile (tab) | person.fill |
Icons.Filled.Person |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; Coil + modern motion comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity; the true-black canvas wants light system-bar icons viaWindowCompat. The cascade goes edge-to-edge horizontally; useScaffoldinsets so the tab bar clears the gesture nav and the chat composer clears the IME (Modifier.imePadding()). - Font scaling:
sphonors the user’s font scale automatically — keep it on chat text, message-list names, profile body. Pin layout-locked text (grid overlay name/distance, tab labels) by deriving size fromdpor a fixedDensityoverride so the cascade overlay never reflows over photos. - TalkBack: each tile is one node — set a combined
contentDescription(“Alex, 220 feet, online”); the Tap button announces its sent state; merge message-row name + preview withModifier.semantics(mergeDescendants = true). - Touch targets: Material guidance is 48.dp minimum. The 56.dp Tap button and 72.dp rows are clear; the full cascade tile (~129.dp) is the tap target.
- Contrast: overlay text is white weight 700 over a
Color.Black.copy(alpha = 0.65f)scrim — validate over the brightest photos and raise the scrim to 0.7 if it fails WCAG AA. Black on#FFDE00passes AAA — never use white text on the yellow button. - Dynamic color: do not enable Material You
dynamicDarkColorScheme()— Grindr’s brand requires the fixed true-black canvas and single-yellow accent regardless of wallpaper.