coderef

Grindr

Grindr's iOS app is an unapologetically dark, photo-first grid. The canvas is pure black (#000000) — not the softened "not-quite-black" of media apps, but true black, because the entire screen is wallpapered with profile photography and…
DownloadRaw
Grindr

Build with the Grindr look.

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

Palette

At a glance

Theme
dark
Typefaces
Inter
Sections
8
Length
3,489 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Grindr

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

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 — NavigationBar instead of a UITabBar, LazyVerticalGrid for the cascade, sp/dp instead of pt.

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 BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity; the true-black canvas wants light system-bar icons via WindowCompat. The cascade goes edge-to-edge horizontally; use Scaffold insets so the tab bar clears the gesture nav and the chat composer clears the IME (Modifier.imePadding()).
  • Font scaling: sp honors 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 from dp or a fixed Density override 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 with Modifier.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 #FFDE00 passes 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.
All mobile designs →

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