coderef

Tinder

Tinder's iOS app is built around a single, unforgettable gesture: the swipe. Every primary screen is really just a stage for the swipeable card — a 3:4 portrait photo that takes up roughly 80% of the viewport, adorned with a name, age, and…
DownloadRaw
Tinder

Build with the Tinder look.

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

Palette

At a glance

Theme
light
Typefaces
Tinder Sans, Tinder Sans Display
Sections
8
Length
4,366 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Tinder

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

Tinder (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Tinder’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables (the swipe-card stack, stamps, the 5-button action bar, the match screen), 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 (the 3:4 full-bleed swipe card, the pink→orange flame gradient, the five-verb action colors, the rotated LIKE/NOPE stamps) while making everything idiomatic Android — a NavigationBar instead of a UITabBar, pointerInput/detectDragGestures instead of DragGesture, LocalHapticFeedback instead of .sensoryFeedback, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for remote profile photos. No color extraction, so androidx.palette is not required.

1. Color Tokens

// ui/theme/TinderColors.kt
import androidx.compose.ui.graphics.Color

object TinderColors {
    // Canvas (Light)
    val Canvas        = Color(0xFFFFFFFF)
    val SurfaceMuted  = Color(0xFFF5F5F5)
    val SurfaceTint   = Color(0xFFFAFAFA)
    val Divider       = Color(0xFFE5E5E5)

    // Text
    val TextPrimary   = Color(0xFF424242) // intentional: softer than pure black
    val TextSecondary = Color(0xFF737373)
    val TextTertiary  = Color(0xFF9E9E9E)

    // Brand
    val Pink          = Color(0xFFFD267A)
    val Orange        = Color(0xFFFF6036)

    // Action colors (the 5 verbs)
    val NopeRed       = Color(0xFFFF4458)
    val SuperLikeBlue = Color(0xFF5D8DF1)
    val BoostPurple   = Color(0xFFA952FF)
    val RewindGold    = Color(0xFFFFBD3B)
    val LikeStampGreen = Color(0xFF00D68F)

    // Semantic
    val VerifiedBlue  = Color(0xFF29B0FF)
    val MatchGlow     = Color(0xFF00D68F)

    // Dark
    val DarkCanvas    = Color(0xFF121212)
    val DarkSurface1  = Color(0xFF1D1D1D)
    val DarkSurface2  = Color(0xFF2A2A2A)
    val DarkDivider   = Color(0xFF3A3A3A)
    val DarkTextPrimary   = Color(0xFFFFFFFF)
    val DarkTextSecondary = Color(0xFFB8B8B8)
}

// The flame brand gradient — Match screen + premium CTAs ONLY
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.geometry.Offset

val TinderBrandGradient = Brush.linearGradient(
    colors = listOf(TinderColors.Pink, TinderColors.Orange),
    start = Offset(0f, 0f),
    end = Offset(Float.POSITIVE_INFINITY, 0f), // horizontal: leading → trailing
)

Tinder is a light-first app (white canvas, photos do the work). Wire a Material 3 lightColorScheme so ripples, dividers, and stock component defaults inherit the brand. Provide the dark scheme for night mode.

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

private val TinderLight = lightColorScheme(
    primary        = TinderColors.Pink,
    onPrimary      = Color.White,
    background     = TinderColors.Canvas,
    onBackground   = TinderColors.TextPrimary,
    surface        = TinderColors.SurfaceMuted,
    onSurface      = TinderColors.TextPrimary,
    surfaceVariant = TinderColors.SurfaceTint,
    outline        = TinderColors.Divider,
    error          = TinderColors.NopeRed,
)

private val TinderDark = darkColorScheme(
    primary        = TinderColors.Pink,
    onPrimary      = Color.White,
    background     = TinderColors.DarkCanvas,
    onBackground   = TinderColors.DarkTextPrimary,
    surface        = TinderColors.DarkSurface1,
    onSurface      = TinderColors.DarkTextPrimary,
    surfaceVariant = TinderColors.DarkSurface2,
    outline        = TinderColors.DarkDivider,
    error          = TinderColors.NopeRed,
)

@Composable
fun TinderTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) =
    MaterialTheme(
        colorScheme = if (darkTheme) TinderDark else TinderLight,
        typography = TinderTypography,
        content = content,
    )

2. Typography

Tinder Sans is proprietary (licensed from the brand team). Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the system font (Roboto) — its humanist proportions are the closest free substitute; the match-hero italic display cut falls back to a bold italic.

// ui/theme/TinderType.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.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

val TinderSans = FontFamily(
    Font(R.font.tinder_sans_regular,  FontWeight.Normal),                    // 400
    Font(R.font.tinder_sans_medium,   FontWeight.Medium),                    // 500
    Font(R.font.tinder_sans_bold,     FontWeight.Bold),                      // 700
    Font(R.font.tinder_sans_black,    FontWeight.Black),                     // 800
    Font(R.font.tinder_sans_black_italic, FontWeight.Black, FontStyle.Italic), // 800 italic — match hero + stamps
)

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object TinderText {
    val MatchHero    = TextStyle(TinderSans, fontWeight = FontWeight.Black,  fontStyle = FontStyle.Italic, fontSize = 48.sp, lineHeight = 53.sp, letterSpacing = (-0.5).sp)
    val ScreenTitle  = TextStyle(TinderSans, fontWeight = FontWeight.Bold,   fontSize = 28.sp, lineHeight = 34.sp, letterSpacing = (-0.3).sp)
    val ProfileName  = TextStyle(TinderSans, fontWeight = FontWeight.Bold,   fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.3).sp)
    val ProfileAge   = TextStyle(TinderSans, fontWeight = FontWeight.Normal, fontSize = 24.sp, lineHeight = 28.sp)
    val Section      = TextStyle(TinderSans, fontWeight = FontWeight.Bold,   fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
    val Button       = TextStyle(TinderSans, fontWeight = FontWeight.Bold,   fontSize = 16.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
    val ButtonSmall  = TextStyle(TinderSans, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 15.sp)
    val Body         = TextStyle(TinderSans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
    val CardMeta     = TextStyle(TinderSans, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
    val Chat         = TextStyle(TinderSans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp)
    val ChatTime     = TextStyle(TinderSans, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 13.sp)
    val Stamp        = TextStyle(TinderSans, fontWeight = FontWeight.Black,  fontStyle = FontStyle.Italic, fontSize = 36.sp, lineHeight = 36.sp, letterSpacing = 2.sp)
    val SuperStamp   = TextStyle(TinderSans, fontWeight = FontWeight.Black,  fontStyle = FontStyle.Italic, fontSize = 32.sp, lineHeight = 32.sp, letterSpacing = 2.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val TinderTypography = Typography(
    headlineLarge = TinderText.ScreenTitle,
    headlineSmall = TinderText.Section,
    titleMedium   = TinderText.ProfileName,
    bodyMedium    = TinderText.Body,
    labelLarge    = TinderText.Button,
)

3. Signature Components

Swipe Card (drag + rotation + stamps)

The hero. Compose drags via Modifier.pointerInput + detectDragGestures; rotation is (offsetX / 12) clamped to ±15°, exactly mirroring the SwiftUI guide.

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.material3.Text
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
import kotlin.math.abs
import kotlinx.coroutines.launch

enum class SwipeDirection { LEFT, RIGHT, UP }

@Composable
fun TinderSwipeCard(
    name: String,
    age: Int,
    distance: String,
    occupation: String,
    photoUrls: List<String>,
    modifier: Modifier = Modifier,
    onSwipe: (SwipeDirection) -> Unit = {},
) {
    val offsetX = remember { Animatable(0f) }
    val offsetY = remember { Animatable(0f) }
    var currentPhoto by remember { mutableIntStateOf(0) }
    val scope = rememberCoroutineScope()
    val haptics = LocalHapticFeedback.current
    val threshold = 150f

    val rotation = (offsetX.value / 12f).coerceIn(-15f, 15f)
    val likeOpacity  = (offsetX.value / 160f).coerceIn(0f, 1f)
    val nopeOpacity  = (-offsetX.value / 160f).coerceIn(0f, 1f)
    val superOpacity = (-offsetY.value / 160f).coerceIn(0f, 1f)

    Box(
        modifier = modifier
            .fillMaxWidth()
            .aspectRatio(3f / 4f) // load-bearing — never change
            .graphicsLayer {
                translationX = offsetX.value
                translationY = offsetY.value
                rotationZ = rotation
            }
            .clip(RoundedCornerShape(16.dp))
            .pointerInput(Unit) {
                detectDragGestures(
                    onDrag = { change, drag ->
                        change.consume()
                        scope.launch { offsetX.snapTo(offsetX.value + drag.x) }
                        scope.launch { offsetY.snapTo(offsetY.value + drag.y) }
                    },
                    onDragEnd = {
                        when {
                            offsetX.value > threshold -> commit(scope, offsetX, offsetY, 1600f, 0f) {
                                haptics.performHapticFeedback(HapticFeedbackType.LongPress); onSwipe(SwipeDirection.RIGHT)
                            }
                            offsetX.value < -threshold -> commit(scope, offsetX, offsetY, -1600f, 0f) {
                                haptics.performHapticFeedback(HapticFeedbackType.LongPress); onSwipe(SwipeDirection.LEFT)
                            }
                            offsetY.value < -threshold -> commit(scope, offsetX, offsetY, 0f, -2000f) {
                                haptics.performHapticFeedback(HapticFeedbackType.LongPress); onSwipe(SwipeDirection.UP)
                            }
                            else -> { // snap back, spring damping ~0.7
                                scope.launch { offsetX.animateTo(0f, spring(0.7f, 200f)) }
                                scope.launch { offsetY.animateTo(0f, spring(0.7f, 200f)) }
                            }
                        }
                    },
                )
            }
            .pointerInput(photoUrls.size) {
                detectTapGestures { tap ->
                    currentPhoto =
                        if (tap.x < size.width / 2) (currentPhoto - 1).coerceAtLeast(0)
                        else (currentPhoto + 1).coerceAtMost(photoUrls.lastIndex)
                }
            },
        contentAlignment = Alignment.BottomStart,
    ) {
        AsyncImage(
            model = photoUrls[currentPhoto],
            contentDescription = "$name, $age",
            modifier = Modifier.fillMaxSize(),
            contentScale = ContentScale.Crop,
        )
        // Bottom legibility gradient over the lower ~50%
        Box(
            Modifier
                .fillMaxSize()
                .background(
                    Brush.verticalGradient(
                        0.5f to Color.Transparent,
                        1f to Color.Black.copy(alpha = 0.7f),
                    )
                )
        )
        // Photo paginator — thin white segments at top
        Row(
            Modifier.fillMaxWidth().align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.spacedBy(3.dp),
        ) {
            repeat(photoUrls.size) { i ->
                Box(
                    Modifier
                        .weight(1f)
                        .height(3.dp)
                        .clip(RoundedCornerShape(1.5.dp))
                        .background(if (i == currentPhoto) Color.White else Color.White.copy(alpha = 0.4f))
                )
            }
        }
        // Name + age + meta
        Column(Modifier.padding(20.dp)) {
            Row(verticalAlignment = Alignment.Bottom) {
                Text(name, style = TinderText.ProfileName, color = Color.White)
                Text(", $age", style = TinderText.ProfileAge, color = Color.White)
            }
            Text(
                "$distance · $occupation",
                style = TinderText.CardMeta,
                color = Color.White.copy(alpha = 0.9f),
                maxLines = 1, overflow = TextOverflow.Ellipsis,
            )
        }
        // Stamps — rotated, opacity tied to drag progress
        TinderStamp("LIKE", TinderColors.LikeStampGreen, -15f, Modifier.align(Alignment.TopStart).padding(32.dp).graphicsLayer { alpha = likeOpacity })
        TinderStamp("NOPE", TinderColors.NopeRed, 15f, Modifier.align(Alignment.TopEnd).padding(32.dp).graphicsLayer { alpha = nopeOpacity })
        TinderStamp("SUPER LIKE", TinderColors.SuperLikeBlue, 0f, Modifier.align(Alignment.TopCenter).padding(top = 64.dp).graphicsLayer { alpha = superOpacity }, isSuper = true)
    }
}

private fun commit(
    scope: kotlinx.coroutines.CoroutineScope,
    x: Animatable<Float, *>,
    y: Animatable<Float, *>,
    tx: Float, ty: Float,
    onDone: () -> Unit,
) {
    onDone()
    scope.launch { x.animateTo(tx, tween(400)) }
    scope.launch { y.animateTo(ty, tween(400)) }
}

Swipe Stamp

import androidx.compose.foundation.border

@Composable
fun TinderStamp(
    text: String,
    color: Color,
    rotation: Float,
    modifier: Modifier = Modifier,
    isSuper: Boolean = false,
) {
    Text(
        text = text,
        style = if (isSuper) TinderText.SuperStamp else TinderText.Stamp,
        color = color,
        modifier = modifier
            .graphicsLayer { rotationZ = rotation }
            .border(4.dp, color, RoundedCornerShape(6.dp))
            .padding(horizontal = 12.dp, vertical = 6.dp),
    )
}

Five Action Buttons Row

import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.clickable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Icon
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp

@Composable
fun TinderActionBar(
    onRewind: () -> Unit,
    onNope: () -> Unit,
    onSuper: () -> Unit,
    onLike: () -> Unit,
    onBoost: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        TinderActionButton(48.dp, TinderColors.RewindGold,     Icons.Filled.Replay,    "Rewind",     onRewind)
        TinderActionButton(56.dp, TinderColors.NopeRed,        Icons.Filled.Close,     "Pass",       onNope)
        TinderActionButton(40.dp, TinderColors.SuperLikeBlue,  Icons.Filled.Star,      "Super Like", onSuper)
        TinderActionButton(56.dp, TinderColors.LikeStampGreen, Icons.Filled.Favorite,  "Like",       onLike)
        TinderActionButton(48.dp, TinderColors.BoostPurple,    Icons.Filled.Bolt,      "Boost",      onBoost)
    }
}

@Composable
fun TinderActionButton(
    size: Dp,
    color: Color,
    icon: ImageVector,
    label: String,
    onClick: () -> Unit,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.92f else 1f, spring(0.5f, 600f), label = "actionScale")
    val haptics = LocalHapticFeedback.current

    Box(
        modifier = Modifier
            .size(size)
            .scale(scale)
            .clip(CircleShape)
            .background(TinderColors.Canvas)
            .border(2.dp, color, CircleShape)
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS medium impact
                onClick()
            }
            .semantics { contentDescription = label },
        contentAlignment = Alignment.Center,
    ) {
        Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(size * 0.42f))
    }
}

Primary Brand CTA (flame gradient)

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

    Box(
        modifier = modifier
            .scale(scale)
            .fillMaxWidth()
            .heightIn(min = 52.dp)
            .clip(CircleShape)
            .background(TinderBrandGradient) // pink→orange, branded
            .clickable(interaction, indication = null, onClick = onClick)
            .padding(horizontal = 32.dp, vertical = 14.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(text, style = TinderText.Button, color = Color.White)
    }
}

Match Screen

@Composable
fun TinderMatchScreen(
    yourPhotoUrl: String,
    theirPhotoUrl: String,
    theirName: String,
    onSendMessage: () -> Unit,
    onKeepPlaying: () -> Unit,
) {
    val haptics = LocalHapticFeedback.current
    LaunchedEffect(Unit) { haptics.performHapticFeedback(HapticFeedbackType.LongPress) } // ~success

    Box(
        Modifier.fillMaxSize().background(TinderBrandGradient), // full-screen flame gradient
        contentAlignment = Alignment.Center,
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(24.dp),
        ) {
            Text("It's a Match!", style = TinderText.MatchHero, color = Color.White)
            Text("You and $theirName liked each other", style = TinderText.Body, color = Color.White.copy(alpha = 0.8f))
            Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
                MatchAvatar(yourPhotoUrl)
                MatchAvatar(theirPhotoUrl)
            }
            Column(
                Modifier.fillMaxWidth().padding(horizontal = 24.dp),
                verticalArrangement = Arrangement.spacedBy(12.dp),
            ) {
                // White pill with gradient text
                Box(
                    Modifier.fillMaxWidth().clip(CircleShape).background(Color.White)
                        .clickable(onClick = onSendMessage).padding(vertical = 14.dp),
                    contentAlignment = Alignment.Center,
                ) { Text("Send Message", style = TinderText.Button, color = TinderColors.Pink) }
                // Outline pill
                Box(
                    Modifier.fillMaxWidth().clip(CircleShape).border(2.dp, Color.White, CircleShape)
                        .clickable(onClick = onKeepPlaying).padding(vertical = 14.dp),
                    contentAlignment = Alignment.Center,
                ) { Text("Keep Playing", style = TinderText.Button, color = Color.White) }
            }
        }
    }
}

@Composable
private fun MatchAvatar(url: String) {
    AsyncImage(
        model = url,
        contentDescription = null,
        modifier = Modifier.size(120.dp).clip(CircleShape).border(3.dp, Color.White, CircleShape),
        contentScale = ContentScale.Crop,
    )
}

Chat Bubble (gradient sender / muted self)

enum class ChatSender { ME, THEM }

@Composable
fun TinderChatBubble(text: String, sender: ChatSender, modifier: Modifier = Modifier) {
    val isThem = sender == ChatSender.THEM
    Row(
        modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp),
        horizontalArrangement = if (isThem) Arrangement.Start else Arrangement.End,
    ) {
        Box(
            Modifier
                .widthIn(max = 280.dp)
                .clip(RoundedCornerShape(20.dp))
                .then(
                    if (isThem) Modifier.background(TinderBrandGradient) // matched sender = flame gradient
                    else Modifier.background(TinderColors.SurfaceMuted)
                )
                .padding(horizontal = 14.dp, vertical = 10.dp)
        ) {
            Text(text, style = TinderText.Chat, color = if (isThem) Color.White else TinderColors.TextPrimary)
        }
    }
}

4. Swipe Card Stack (the distinctive interaction)

Tinder has no color extraction; its defining dynamic system is the peeking card stack. The next card sits behind at scale 0.95 and rises to 1.0 over 250ms as the top card commits off-screen — mirroring DESIGN.md §5 (“subsequent cards peek behind, slight scale offset 0.95”).

@Composable
fun <T> SwipeCardStack(
    items: List<T>,
    modifier: Modifier = Modifier,
    onSwiped: (T, SwipeDirection) -> Unit,
    card: @Composable (T) -> Unit,
) {
    var topIndex by remember { mutableIntStateOf(0) }

    Box(modifier, contentAlignment = Alignment.Center) {
        // Render up to two cards: the peeking next card, then the active top card
        val nextIndex = topIndex + 1
        if (nextIndex <= items.lastIndex) {
            val rise by animateFloatAsState(0.95f, spring(0.8f, 300f), label = "nextRise")
            Box(Modifier.graphicsLayer { scaleX = rise; scaleY = rise }) {
                card(items[nextIndex])
            }
        }
        if (topIndex <= items.lastIndex) {
            key(topIndex) {
                Box {
                    // TinderSwipeCard is the active card; on commit, advance the stack
                    card(items[topIndex])
                }
            }
        }
    }
    // Caller wires TinderSwipeCard's onSwipe to: onSwiped(items[topIndex], dir); topIndex++
}

Hoist topIndex and the per-card swipe callback into a ViewModel; the action bar’s Like/Nope/Super buttons drive the same commit(...) path so users who cannot drag still have full parity.

5. Bottom Navigation (icon-only)

Tinder’s iOS tab bar is icon-only (Flame | Star | Chat | Profile), no labels, taller than typical, with the active tint = flame gradient. Material 3’s NavigationBar always reserves label space, so we render a custom row to keep it spartan. Android has no live blur — use a 96%-opaque canvas surface with a hairline top border.

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.ui.draw.drawBehind

@Composable
fun TinderBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    val items = listOf(
        "Discover" to Icons.Filled.Whatshot,
        "Top Picks" to Icons.Filled.Star,
        "Matches"   to Icons.Filled.ChatBubble,
        "Profile"   to Icons.Filled.Person,
    )
    Row(
        Modifier
            .fillMaxWidth()
            .background(TinderColors.Canvas.copy(alpha = 0.96f))
            .drawBehind {
                drawLine(TinderColors.Divider, androidx.compose.ui.geometry.Offset(0f, 0f),
                    androidx.compose.ui.geometry.Offset(size.width, 0f), 0.5.dp.toPx())
            }
            .windowInsetsPadding(WindowInsets.navigationBars)
            .height(64.dp),
        horizontalArrangement = Arrangement.SpaceEvenly,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        items.forEachIndexed { i, (label, icon) ->
            val active = selected == i
            Box(
                Modifier.size(48.dp).clip(CircleShape).clickable { onSelect(i) }
                    .semantics { contentDescription = label },
                contentAlignment = Alignment.Center,
            ) {
                if (active) {
                    Icon(icon, null, modifier = Modifier.size(28.dp)
                        .graphicsLayer(compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen)
                        .drawWithContent {
                            drawContent()
                            drawRect(TinderBrandGradient, blendMode = androidx.compose.ui.graphics.BlendMode.SrcAtop)
                        }, tint = Color.Unspecified)
                } else {
                    Icon(icon, null, tint = TinderColors.TextTertiary, modifier = Modifier.size(28.dp))
                }
            }
        }
    }
}

6. Motion

Moment Compose recipe
Swipe drag graphicsLayer { translationX/Y; rotationZ = (dx/12).coerceIn(-15f,15f) } driven by detectDragGestures + Animatable.snapTo
Snap back Animatable.animateTo(0f, spring(dampingRatio = 0.7f, stiffness = 200f)) over ~400ms
Swipe commit animateTo(±1600f, tween(400)) off-screen + HapticFeedbackType.LongPress
Next card rise animateFloatAsState(1f, spring(dampingRatio = 0.8f)) 0.95 → 1.0 over ~250ms
Action button press animateFloatAsState(0.92f, spring(dampingRatio = 0.5f, stiffness = 600f)) + glyph fill flash
Match reveal full-screen gradient + confetti via Animatable keyframes (see below); success-style haptic
New-match glow rememberInfiniteTransition ring scale 1.0 → 1.2, alpha 1 → 0, 1.5s RepeatMode.Restart
// Pulsing green "new match" ring around an avatar (DESIGN.md §4 New Match Glow)
@Composable
fun NewMatchGlow(content: @Composable () -> Unit) {
    val t = rememberInfiniteTransition(label = "glow")
    val scale by t.animateFloat(1f, 1.2f, infiniteRepeatable(tween(1500), RepeatMode.Restart), label = "glowScale")
    val alpha by t.animateFloat(1f, 0f, infiniteRepeatable(tween(1500), RepeatMode.Restart), label = "glowAlpha")
    Box(contentAlignment = Alignment.Center) {
        Box(
            Modifier.matchParentSize()
                .graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
                .border(2.dp, TinderColors.MatchGlow, CircleShape)
        )
        content()
    }
}

Haptics: prefer LocalHapticFeedback. For richer control on commit/match use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(12, ...) to approximate the iOS .medium impact; chain two effects for the Boost activation’s heavy+medium pattern.

7. Icons

Tinder ships custom glyphs (the flame logo especially); the closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Tinder’s flame logo and the rounded action glyphs as vector drawables and load via ImageVector.vectorResource(R.drawable.…).

Purpose SF Symbol (iOS) Material Icon (Compose)
Flame (Discover tab) flame.fill Icons.Filled.Whatshot (or custom flame drawable)
Star (Top Picks / Super Like) star.fill Icons.Filled.Star
Chat (tab) bubble.left.fill Icons.Filled.ChatBubble
Profile (tab) person.fill Icons.Filled.Person
Rewind (action) arrow.counterclockwise Icons.Filled.Replay
Nope (action) xmark Icons.Filled.Close
Like (action) heart.fill Icons.Filled.Favorite
Boost (action) bolt.fill Icons.Filled.Bolt
Verified checkmark.seal.fill Icons.Filled.Verified
Info (card ⓘ) info.circle.fill Icons.Filled.Info
Send arrow.up Icons.AutoMirrored.Filled.Send
GIF picker photo.on.rectangle.angled Icons.Filled.Gif
Settings gearshape.fill Icons.Filled.Settings

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; gesture + spring motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity. The swipe screen extends under the status bar with a transparent top; apply Modifier.windowInsetsPadding(WindowInsets.systemBars) so the card’s 16.dp margins and the action bar clear the gesture nav and the home indicator.
  • Font scaling: sp honors the user’s font scale automatically — keep it on the profile name/age, bio, and chat. Pin layout-sensitive text: the rotated LIKE/NOPE/SUPER LIKE stamps and the photo paginator are glance-/layout-critical — derive their size from dp or wrap in CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). Tab area has no labels, so nothing to pin there.
  • TalkBack: the swipe card needs Modifier.semantics { contentDescription = "$name, $age, $distance, $occupation. Double tap for profile." } and custom accessibility actions for Like/Pass/Super so non-drag users have parity (the action-bar buttons already expose discrete targets with clear contentDescriptions — “Rewind”, “Pass”, “Super Like”, “Like”, “Boost”).
  • Touch targets: Material guidance is 48.dp minimum. The 56.dp Like/Nope buttons clear it; the 40.dp Super Like and the 32.dp card info button must carry a 48.dp hit area via padding even though the glyph is small. Card drag area is the full 3:4 surface.
  • Contrast: white name/age sits on a rgba(0,0,0,0.7) legibility gradient — always keep the gradient under text over photos. The flame-gradient CTAs use pure white text; validate the lighter #FF6036 end against white at 16sp Bold (it passes WCAG AA at that weight). #737373 meta on white passes AA at 14sp+.
  • Reduce data / motion: respect a reduced-motion preference by skipping the confetti burst and the card rotation during drag (keep translation only), and replacing the match-screen entrance with a plain fade.
  • Dynamic color: do not enable Material You dynamicLightColorScheme() / dynamicDarkColorScheme() — Tinder is a strong-brand app: the flame pink→orange gradient and the fixed five-verb action colors must not shift with the user’s wallpaper.
All mobile designs →

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