coderef

Hinge

Hinge's iOS app reads like a personal journal printed on warm uncoated paper. The canvas is a soft off-white — almost a cream — that intentionally rejects the cold pure-white of most tech apps, because Hinge's premise is intimate: this is…
DownloadRaw
Hinge

Build with the Hinge look.

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

Palette

At a glance

Theme
light
Typefaces
Sailec, Inter, Manrope
Sections
8
Length
4,939 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Hinge

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

Hinge (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Hinge’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the heart-tap, the prompt card, the Rose CTA, and the match celebration.

Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (Hinge’s cream-paper canvas, the warm Hinge Black, the single sacred Rose Gold, and the vertical-scroll-and-tap profile architecture) while making everything idiomatic Android — NavigationBar instead of a UITabBar, SharedTransitionLayout instead of matchedGeometryEffect, a translucent Surface instead of iOS blur, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for profile photos. Hinge does no runtime color extraction, so no Palette dependency is needed.

1. Color Tokens

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

object HingeColors {
    // Canvas & Surfaces (the cream paper)
    val Cream       = Color(0xFFFDF8F2) // canvas
    val Paper       = Color(0xFFFAF6F0) // cards
    val Sand        = Color(0xFFF2EBE0) // chips / inputs
    val Sand2       = Color(0xFFE8DFD0) // pressed
    val DividerBone = Color(0xFFE0D6C5)

    // Text (warm-tinted greys, never neutral)
    val Black        = Color(0xFF1A1A1A) // primary, the "H" mark
    val BlackPressed = Color(0xFF0A0A0A)
    val Graphite     = Color(0xFF4A4239) // secondary
    val Stone        = Color(0xFF7A7268) // tertiary
    val Bone         = Color(0xFFB0A89C) // disabled

    // Rose Gold (the single accent — Standouts, Roses, premium)
    val Rose      = Color(0xFFE8A04D)
    val RoseDeep  = Color(0xFFC57E2E) // pressed
    val RoseLight = Color(0xFFF5D9A8) // halo / Standouts strip

    // Semantic
    val MatchGreen = Color(0xFF2D7A4B)
    val Warning    = Color(0xFFD88B2E)
    val Error      = Color(0xFFB33A2F)
    val Info       = Color(0xFF5A6273)

    // Shadow base (warm-tinted — never rgba(0,0,0))
    val ShadowWarm = Color(0xFF1C140A)

    // Dark mode (warm dark — preserves paper feel)
    val DarkCanvas   = Color(0xFF16130E)
    val DarkSurface  = Color(0xFF1E1A14)
    val DarkSurface2 = Color(0xFF2A2520)
    val DarkDivider  = Color(0xFF2F2A22)
    val DarkText     = Color(0xFFEFE8DA)
    val DarkTextSec  = Color(0xFFA89E8E)
    val RoseDark     = Color(0xFFF0B05C) // OLED-brightened
}

Hinge defaults to the warm cream light surface (dark mode added 2022, also warm). Wire a Material 3 lightColorScheme so ripples and default component colors inherit the brand; provide the warm darkColorScheme for isSystemInDarkTheme().

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

private val HingeLight = lightColorScheme(
    primary        = HingeColors.Black,
    onPrimary      = HingeColors.Paper,        // warm paper-white on black, never pure white
    secondary      = HingeColors.Rose,         // the sacred gold
    background     = HingeColors.Cream,
    onBackground   = HingeColors.Black,
    surface        = HingeColors.Paper,
    onSurface      = HingeColors.Black,
    surfaceVariant = HingeColors.Sand,
    outline        = HingeColors.DividerBone,
    error          = HingeColors.Error,
)

private val HingeDark = darkColorScheme(
    primary        = HingeColors.DarkText,
    onPrimary      = HingeColors.DarkCanvas,
    secondary      = HingeColors.RoseDark,
    background     = HingeColors.DarkCanvas,
    onBackground   = HingeColors.DarkText,
    surface        = HingeColors.DarkSurface,
    onSurface      = HingeColors.DarkText,
    surfaceVariant = HingeColors.DarkSurface2,
    outline        = HingeColors.DarkDivider,
)

@Composable
fun HingeTheme(content: @Composable () -> Unit) =
    MaterialTheme(
        colorScheme = if (isSystemInDarkTheme()) HingeDark else HingeLight,
        typography  = HingeTypography,
        content     = content,
    )

2. Typography

Sailec is the Hinge brand face (display + prompts). Drop the TTFs in res/font/ (lowercase, snake_case). Use Inter for UI chrome at compact sizes (12-15sp). Fall back to the system font for both — its grotesque-humanist tone is the closest free substitute on Android.

// ui/theme/HingeType.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 Sailec = FontFamily(
    Font(R.font.sailec_light,   FontWeight.Light),    // 300
    Font(R.font.sailec_regular, FontWeight.Normal),   // 400
    Font(R.font.sailec_medium,  FontWeight.Medium),   // 500
    Font(R.font.sailec_bold,    FontWeight.Bold),     // 700
    Font(R.font.sailec_black,   FontWeight.Black),    // 900
    Font(R.font.sailec_italic,  FontWeight.Medium, FontStyle.Italic), // italic prompts
)

val Inter = FontFamily(
    Font(R.font.inter_regular,  FontWeight.Normal),
    Font(R.font.inter_medium,   FontWeight.Medium),
    Font(R.font.inter_semibold, FontWeight.SemiBold),
)

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object HingeText {
    val Display      = TextStyle(Sailec, fontWeight = FontWeight.Bold,     fontSize = 36.sp, lineHeight = 40.sp, letterSpacing = (-0.6).sp)
    val Name         = TextStyle(Sailec, fontWeight = FontWeight.Bold,     fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.4).sp)
    val PromptQ      = TextStyle(Sailec, fontWeight = FontWeight.Medium,   fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
    val PromptA      = TextStyle(Sailec, fontWeight = FontWeight.Bold,     fontSize = 24.sp, lineHeight = 30.sp, letterSpacing = (-0.3).sp)
    val Section      = TextStyle(Sailec, fontWeight = FontWeight.Bold,     fontSize = 18.sp, lineHeight = 22.sp, letterSpacing = (-0.1).sp)
    val Button       = TextStyle(Sailec, fontWeight = FontWeight.Bold,     fontSize = 16.sp, lineHeight = 16.sp)
    val Body         = TextStyle(Inter,  fontWeight = FontWeight.Normal,   fontSize = 16.sp, lineHeight = 22.sp)
    val BodyBold     = TextStyle(Inter,  fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 22.sp)
    val Chip         = TextStyle(Inter,  fontWeight = FontWeight.Medium,   fontSize = 14.sp, lineHeight = 18.sp)
    val Meta         = TextStyle(Inter,  fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 17.sp)
    val Caption      = TextStyle(Inter,  fontWeight = FontWeight.Normal,   fontSize = 12.sp, lineHeight = 16.sp)
    val Tab          = TextStyle(Sailec, fontWeight = FontWeight.Medium,   fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.2.sp)
    val CommentInput = TextStyle(Inter,  fontWeight = FontWeight.Normal,   fontSize = 16.sp, lineHeight = 22.sp)
    val MatchBanner  = TextStyle(Sailec, fontWeight = FontWeight.Bold,     fontSize = 14.sp, lineHeight = 14.sp, letterSpacing = 0.2.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val HingeTypography = Typography(
    displayMedium = HingeText.Display,
    headlineSmall = HingeText.Name,
    titleLarge    = HingeText.PromptA,
    titleMedium   = HingeText.Section,
    bodyMedium    = HingeText.Body,
    labelSmall    = HingeText.Tab,
)

3. Signature Components

Heart Tap (under every prompt and photo — the most-pressed button)

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.Spring
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp

@Composable
fun HeartTap(
    isFilled: Boolean,
    onTap: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val haptics = LocalHapticFeedback.current
    val scale = remember { Animatable(1f) }

    LaunchedEffect(isFilled) {
        if (isFilled) {
            // 1.0 → 0.88 → 1.0 over ~250ms, spring damping 0.6
            scale.animateTo(0.88f, spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessHigh))
            scale.animateTo(1f, spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessMedium))
        }
    }

    Box(
        modifier
            .size(44.dp)
            .scale(scale.value)
            .clip(CircleShape)
            .background(if (isFilled) HingeColors.Black else HingeColors.Paper)
            .border(1.dp, HingeColors.Black, CircleShape)
            .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ≈ iOS light impact
                onTap()
            },
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            Icons.Filled.Favorite,
            contentDescription = if (isFilled) "Liked" else "Like",
            tint = if (isFilled) HingeColors.Paper else HingeColors.Black,
            modifier = Modifier.size(18.dp).scale(if (isFilled) 1f else 0.92f),
        )
    }
}

Prompt Card (the hero component)

The canonical reactive surface — tapping anywhere (not just the heart) opens the comment sheet.

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontStyle

@Composable
fun PromptCard(
    question: String,
    answer: String,
    isLiked: Boolean,
    onLikeToggle: () -> Unit,
    onCommentTap: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier
            .fillMaxWidth()
            .shadow(8.dp, RoundedCornerShape(16.dp), spotColor = HingeColors.ShadowWarm.copy(alpha = 0.06f))
            .clip(RoundedCornerShape(16.dp))
            .background(HingeColors.Paper)
            .border(0.5.dp, HingeColors.DividerBone, RoundedCornerShape(16.dp))
            .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {
                onCommentTap()
            }
            .padding(24.dp),
    ) {
        Text(
            question,
            style = HingeText.PromptQ.copy(fontStyle = FontStyle.Italic), // Hinge italicizes certain templates
            color = HingeColors.Graphite,
        )
        Spacer(Modifier.height(12.dp))
        Text(answer, style = HingeText.PromptA, color = HingeColors.Black)
        Spacer(Modifier.height(16.dp))
        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
            HeartTap(isFilled = isLiked, onTap = { onLikeToggle(); onCommentTap() })
        }
    }
}

Photo Card (4:5 portrait)

import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage

@Composable
fun PhotoCard(
    photoUrl: String,
    isLiked: Boolean,
    onLikeToggle: () -> Unit,
    onCommentTap: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Box(
        modifier
            .fillMaxWidth()
            .aspectRatio(4f / 5f)
            .shadow(8.dp, RoundedCornerShape(16.dp), spotColor = HingeColors.ShadowWarm.copy(alpha = 0.06f))
            .clip(RoundedCornerShape(16.dp))
            .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onCommentTap),
    ) {
        AsyncImage(
            model = photoUrl,
            contentDescription = "Profile photo",
            modifier = Modifier.matchParentSize(),
            contentScale = ContentScale.Crop,
        )
        HeartTap(
            isFilled = isLiked,
            onTap = { onLikeToggle(); onCommentTap() },
            modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
        )
    }
}

Attribute Chip Row (Vitals)

import androidx.compose.material.icons.filled.Verified
import androidx.compose.ui.graphics.vector.ImageVector

@Composable
fun AttributeChip(
    glyph: ImageVector,
    label: String,
    isVerified: Boolean = false,
) {
    Row(
        Modifier
            .clip(RoundedCornerShape(percent = 50))
            .background(HingeColors.Sand)
            .padding(horizontal = 14.dp, vertical = 8.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(6.dp),
    ) {
        Icon(glyph, contentDescription = null, tint = HingeColors.Black, modifier = Modifier.size(13.dp))
        Text(label, style = HingeText.Chip, color = HingeColors.Black)
        if (isVerified) {
            Icon(Icons.Filled.Verified, contentDescription = "Verified", tint = HingeColors.MatchGreen, modifier = Modifier.size(12.dp))
        }
    }
}

Standouts Hero Card (the only gold-bordered surface)

import androidx.compose.material.icons.filled.Star
import androidx.compose.ui.graphics.Brush

@Composable
fun StandoutsCard(
    photoUrl: String,
    answer: String,
    onSendRose: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier
            .fillMaxWidth()
            .shadow(12.dp, RoundedCornerShape(20.dp), spotColor = HingeColors.ShadowWarm.copy(alpha = 0.10f))
            .clip(RoundedCornerShape(20.dp))
            .background(HingeColors.Paper)
            .border(
                1.dp,
                Brush.linearGradient(listOf(HingeColors.Rose, HingeColors.RoseLight, HingeColors.Rose)),
                RoundedCornerShape(20.dp),
            ),
    ) {
        Row(
            Modifier
                .fillMaxWidth()
                .height(32.dp)
                .background(HingeColors.RoseLight)
                .padding(horizontal = 16.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(6.dp),
        ) {
            Icon(Icons.Filled.Star, contentDescription = null, tint = HingeColors.Rose, modifier = Modifier.size(14.dp))
            Text("Standout", style = HingeText.Tab.copy(fontSize = 12.sp), color = HingeColors.Black)
        }
        AsyncImage(
            model = photoUrl,
            contentDescription = "Standout photo",
            modifier = Modifier.fillMaxWidth().aspectRatio(1f),
            contentScale = ContentScale.Crop,
        )
        Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
            Text(answer, style = HingeText.PromptA, color = HingeColors.Black)
            RoseCTA(label = "Send a Rose", onClick = onSendRose)
        }
    }
}

Rose CTA (premium — heavy haptic, it costs currency)

import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.material.icons.filled.LocalFlorist

@Composable
fun RoseCTA(label: String = "Send a Rose", onClick: () -> Unit, modifier: Modifier = Modifier) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val haptics = LocalHapticFeedback.current

    Row(
        modifier
            .fillMaxWidth()
            .height(56.dp)
            .clip(CircleShape) // full capsule (28.dp radius on 56.dp)
            .background(if (pressed) HingeColors.BlackPressed else HingeColors.Black)
            .border(1.dp, HingeColors.Rose, CircleShape)
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ iOS heavy impact
                onClick()
            },
        horizontalArrangement = Arrangement.Center,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(Icons.Filled.LocalFlorist, contentDescription = null, tint = HingeColors.Rose, modifier = Modifier.size(16.dp))
        Spacer(Modifier.width(10.dp))
        Text(label, style = HingeText.Button, color = HingeColors.Paper)
    }
}

Primary CTA (Send Like / Match / Continue)

@Composable
fun HingePrimaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by androidx.compose.animation.core.animateFloatAsState(if (pressed) 0.97f else 1f, label = "ctaScale")
    val haptics = LocalHapticFeedback.current

    Box(
        modifier
            .fillMaxWidth()
            .scale(scale)
            .height(56.dp)
            .clip(CircleShape)
            .background(if (pressed) HingeColors.BlackPressed else HingeColors.Black)
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ iOS medium impact
                onClick()
            },
        contentAlignment = Alignment.Center,
    ) {
        Text(label, style = HingeText.Button, color = HingeColors.Paper)
    }
}

Comment Sheet (tap-to-comment pattern)

The tapped source pins to the top; the input is a capsule with a circular send button that dims when empty.

import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.Surface

@Composable
fun CommentSheet(
    pinnedSource: @Composable () -> Unit,
    onSend: (String) -> Unit,
) {
    var comment by remember { mutableStateOf("") }
    val haptics = LocalHapticFeedback.current

    Surface(color = HingeColors.Paper, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)) {
        Column(Modifier.fillMaxWidth().padding(top = 24.dp)) {
            Box(Modifier.weight(1f, fill = false).padding(horizontal = 16.dp)) { pinnedSource() }
            Spacer(Modifier.height(16.dp))
            Row(
                Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 24.dp),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.spacedBy(10.dp),
            ) {
                Box(
                    Modifier
                        .weight(1f)
                        .clip(CircleShape)
                        .background(HingeColors.Sand)
                        .padding(horizontal = 20.dp, vertical = 14.dp),
                ) {
                    BasicTextField(
                        value = comment,
                        onValueChange = { comment = it },
                        textStyle = HingeText.CommentInput.copy(color = HingeColors.Black),
                        decorationBox = { inner ->
                            if (comment.isEmpty()) {
                                Text("Add a comment about her response", style = HingeText.CommentInput, color = HingeColors.Stone)
                            }
                            inner()
                        },
                    )
                }
                Box(
                    Modifier
                        .size(44.dp)
                        .clip(CircleShape)
                        .background(if (comment.isBlank()) HingeColors.Bone else HingeColors.Black)
                        .clickable(enabled = comment.isNotBlank()) {
                            haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                            onSend(comment)
                        },
                    contentAlignment = Alignment.Center,
                ) {
                    Icon(Icons.Filled.ArrowUpward, contentDescription = "Send", tint = HingeColors.Paper, modifier = Modifier.size(16.dp))
                }
            }
        }
    }
}

Match Celebration (Rose Gold confetti)

import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import kotlin.random.Random

@Composable
fun MatchCelebration(
    myAvatarUrl: String,
    theirAvatarUrl: String,
    theirName: String,
    onMessage: () -> Unit,
    onKeepBrowsing: () -> Unit,
) {
    val fall = remember { Animatable(0f) }
    val haptics = LocalHapticFeedback.current
    val confetti = remember {
        List(24) { Triple(Random.nextFloat(), Random.nextInt(4, 10).dp, Random.nextInt(0, 40) * 0.001f) }
    }

    LaunchedEffect(Unit) {
        haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ iOS success notification
        fall.animateTo(1f, tween(1800)) // Rose Gold particle fall
    }

    Box(Modifier.fillMaxSize().background(HingeColors.Cream)) {
        Canvas(Modifier.matchParentSize()) {
            confetti.forEach { (xFrac, dotSize, _) ->
                drawCircle(
                    color = HingeColors.Rose,
                    radius = dotSize.toPx() / 2f,
                    center = Offset(xFrac * size.width, fall.value * (size.height + 300f) - 150f),
                    alpha = 1f - fall.value,
                )
            }
        }
        Column(
            Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center,
        ) {
            Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
                listOf(myAvatarUrl, theirAvatarUrl).forEach {
                    AsyncImage(
                        model = it,
                        contentDescription = null,
                        modifier = Modifier.size(120.dp).clip(CircleShape),
                        contentScale = ContentScale.Crop,
                    )
                }
            }
            Spacer(Modifier.height(32.dp))
            Text("It's a match!", style = HingeText.Display, color = HingeColors.Black)
            Text("You and $theirName liked each other", style = HingeText.Body, color = HingeColors.Graphite)
            Spacer(Modifier.height(32.dp))
            HingePrimaryButton("Send a message", onMessage, Modifier.padding(horizontal = 24.dp))
            Spacer(Modifier.height(12.dp))
            Text(
                "Keep browsing",
                style = HingeText.Chip,
                color = HingeColors.Graphite,
                modifier = Modifier.clickable(onClick = onKeepBrowsing),
            )
        }
    }
}

4. Heart-Tap on Reactive Surfaces — the distinctive system

Hinge does no color extraction; its most distinctive interaction is the 44pt heart-tap on every reactive surface (the implementation lives in §3). The architecture is the brand: a vertical-scroll profile where each photo and prompt is its own independently tappable card. Compose this with a LazyColumn — never a swipe stack.

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items

sealed interface ProfileBlock {
    data class Header(val name: String, val age: Int) : ProfileBlock
    data class Vitals(val chips: List<Pair<ImageVector, String>>) : ProfileBlock
    data class Photo(val url: String) : ProfileBlock
    data class Prompt(val question: String, val answer: String) : ProfileBlock
}

@Composable
fun ProfileScroll(blocks: List<ProfileBlock>, onComment: (ProfileBlock) -> Unit) {
    LazyColumn(
        Modifier.fillMaxSize().background(HingeColors.Cream),
        contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp), // the 16dp gap defines the profile rhythm
    ) {
        items(blocks) { block ->
            when (block) {
                is ProfileBlock.Header -> Text("${block.name}, ${block.age}", style = HingeText.Name, color = HingeColors.Black)
                is ProfileBlock.Vitals -> FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    block.chips.forEach { (g, l) -> AttributeChip(g, l) }
                }
                is ProfileBlock.Photo -> {
                    var liked by remember { mutableStateOf(false) }
                    PhotoCard(block.url, liked, { liked = !liked }, { onComment(block) })
                }
                is ProfileBlock.Prompt -> {
                    var liked by remember { mutableStateOf(false) }
                    PromptCard(block.question, block.answer, liked, { liked = !liked }, { onComment(block) })
                }
            }
        }
    }
}

There is no swipe gesture — advancement is read-and-tap. Do not add a Tinder-style escalation glyph; Hinge has Roses, full stop.

5. Navigation (Bottom Tab Bar)

Use Material 3 NavigationBar. Hinge’s iOS tab bar is a .regularMaterial blur over cream; Android has no first-class live blur, so use a 92%-opaque cream surface. Hinge uses no accent color on the active tab — it relies on filled icons + warm Hinge Black, with a small indicator dot below the icon (no Material pill).

import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.PersonOutline
import androidx.compose.material3.*

@Composable
fun HingeBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    val items = listOf(
        "Discover"  to Icons.Filled.Explore,
        "Likes You" to Icons.Filled.FavoriteBorder,
        "Standouts" to Icons.Filled.Star,
        "Matches"   to Icons.Filled.ChatBubbleOutline,
        "Profile"   to Icons.Filled.PersonOutline,
    )
    val haptics = LocalHapticFeedback.current
    NavigationBar(
        containerColor = HingeColors.Cream.copy(alpha = 0.92f),
        tonalElevation = 0.dp,
    ) {
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = {
                    haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                    onSelect(i)
                },
                icon = {
                    Column(horizontalAlignment = Alignment.CenterHorizontally) {
                        Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp))
                        if (selected == i) {
                            Spacer(Modifier.height(4.dp))
                            Box(Modifier.size(4.dp).clip(CircleShape).background(HingeColors.Black))
                        }
                    }
                },
                label = { Text(label, style = HingeText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = HingeColors.Black,
                    selectedTextColor   = HingeColors.Black,
                    unselectedIconColor = HingeColors.Stone,
                    unselectedTextColor = HingeColors.Stone,
                    indicatorColor      = Color.Transparent, // Hinge has no Material pill
                ),
            )
        }
    }
}

6. Motion

Moment Compose recipe
Heart tap (most-repeated) Animatable 1 → 0.88 → 1 with spring(dampingRatio = 0.6f) over ~250ms + TextHandleMove haptic
Card → comment sheet SharedTransitionLayout + Modifier.sharedElement() on the tapped card (Compose 1.7+); fallback AnimatedVisibility + slideInVertically
Match confetti Animatable.animateTo(1f, tween(1800)) driving falling Rose-Gold circles + LongPress (success) haptic
Rose send pulse halo rememberInfiniteTransition scale 1.0 → 1.05 → 1.0 over 1500ms while the sheet is open
Standouts card transition gold border alpha animates 0.6f → 1.0f via animateFloatAsState on active state
Tab switch 200ms crossfade outlined→filled + indicator dot fade-in + TextHandleMove haptic
// Rose send pulse halo
@Composable
fun RosePulse(content: @Composable () -> Unit) {
    val t = rememberInfiniteTransition(label = "rosePulse")
    val s by t.animateFloat(
        1f, 1.05f,
        infiniteRepeatable(tween(1500, easing = androidx.compose.animation.core.EaseInOut), RepeatMode.Reverse),
        label = "roseScale",
    )
    Box(Modifier.scale(s)) { content() }
}

Haptics: prefer LocalHapticFeedback. Hinge’s vocabulary maps TextHandleMove ≈ iOS .light/.selection (heart-tap, tab switch), LongPress ≈ .medium/.heavy (Send Like, Rose send). Currency gets weight — for a stronger Rose-send cue, use LocalView.current.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) or a Vibrator VibrationEffect.createOneShot(20, 200).

7. Icons

The SF Symbols map to Material Icons (androidx.compose.material:material-icons-extended). The proprietary “H” mark and the rose glyph are custom — ship as vector drawables and load via ImageVector.vectorResource(R.drawable.…).

Component SF Symbol (iOS) Material Icon (Compose)
Heart-tap (filled) heart.fill Icons.Filled.Favorite
X decline xmark Icons.Filled.Close
Send arrow (comment) arrow.up Icons.Filled.ArrowUpward
Standouts star star.fill Icons.Filled.Star
Rose currency proprietary rose glyph Icons.Filled.LocalFlorist (custom vector preferred)
Verified attribute checkmark.seal.fill Icons.Filled.Verified (Match Green)
Attribute — height ruler Icons.Filled.Straighten
Attribute — job briefcase.fill Icons.Filled.Work
Attribute — location mappin.and.ellipse Icons.Filled.LocationOn
Attribute — education graduationcap.fill Icons.Filled.School
Attribute — religion book.closed.fill Icons.Filled.MenuBook
Discover tab safari / safari.fill Icons.Filled.Explore
Likes You tab heart / heart.fill Icons.Filled.FavoriteBorder / Favorite
Standouts tab star / star.fill Icons.Outlined.StarBorder / Icons.Filled.Star
Matches tab bubble.left / bubble.left.fill Icons.Filled.ChatBubbleOutline
Profile tab person / person.fill Icons.Filled.PersonOutline / Person
Back chevron.left Icons.Filled.ChevronLeft
Settings gearshape Icons.Filled.Settings

The “H” logomark renders only in Hinge Black #1A1A1A on cream, or Paper White on a Hinge Black circle — never gold, never any other color (except the rare Standouts launch-screen variant).

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; SharedTransitionLayout, Animatable, modern motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity. The cream canvas wants dark-content system bars (isAppearanceLightStatusBars = true); warm dark mode flips to light-content. Apply Scaffold insets so the sticky bottom action bar and tab bar clear the gesture nav.
  • Font scaling: sp honors the user’s font scale automatically — keep it on prompt question, prompt answer, name, body, comment input. Pin layout-sensitive text (attribute chips, 10.sp tab labels, the “H” mark) by wrapping in CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, fontScale = 1f)).
  • TalkBack: group the prompt card as one element — Modifier.semantics(mergeDescendants = true) { contentDescription = "Prompt: Two truths and a lie. Answer: I have never seen Star Wars. Double-tap to comment." }. The heart-tap is a separate node with state-dependent contentDescription (“Like” / “Liked”). Announce the remaining Rose count after a send.
  • Touch targets: Material guidance is 48.dp minimum. The 44.dp heart-tap is below it — give it 8.dp of hit-slop padding (or wrap in a 48.dp clickable Box) even though the visual circle stays 44.dp. The 56.dp CTAs and Rose CTA are clear.
  • Contrast: Hinge Black #1A1A1A on cream #FDF8F2 meets WCAG AAA at all sizes; Hinge Graphite #4A4239 on cream meets AAA at body sizes and above. Rose Gold #E8A04D is decorative/icon only — never small body text on cream without verification.
  • Reduce Motion: skip the match-celebration confetti when Settings.Global.ANIMATOR_DURATION_SCALE == 0f — fall back to a 200ms crossfade, preserving the success haptic. Skip the Rose pulse halo.
  • Material You: do not enable dynamicColorScheme() — Hinge’s brand requires the fixed cream canvas, warm Hinge Black, and the single sacred Rose Gold regardless of wallpaper. Dark mode is the deliberate warm #16130E, not a neutral dark.
All mobile designs →

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