coderef

happn

happn's entire product — and therefore its design system — is built around a single, very specific idea: the people you have crossed paths with in real life.
DownloadRaw
happn

Build with the happn look.

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

Palette

At a glance

Theme
light
Typefaces
Poppins, Inter, Poppins carries warmth
Sections
7
Length
4,006 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · happn

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

happn (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports happn’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the Crossings timeline + Charm gesture, 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 (happn’s Crossings timeline on a connector spine, the pink Charm heart, the pink→magenta gradient, reserved Crush gold, location+time stamps) while making everything idiomatic Android — NavigationBar instead of a UITabBar, LazyColumn for the timeline, Maps Compose markers, dp/sp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for avatars, and maps-compose4.x for the Map view. No Material You — happn’s pink is a fixed brand color and must not be overridden by wallpaper extraction.

1. Color Tokens

// ui/theme/HappnColors.kt
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color

object HappnColors {
    // Canvas & Surfaces (Dark)
    val Canvas   = Color(0xFF0E0E12) // cool graphite — NOT pure black
    val Surface1 = Color(0xFF18181F)
    val Surface2 = Color(0xFF21212B)
    val Surface3 = Color(0xFF2C2C38)
    val Divider  = Color(0xFF2A2A33) // also the timeline spine

    // Canvas & Surfaces (Light)
    val CanvasLight  = Color(0xFFFFFFFF)
    val Surface1Light = Color(0xFFF6F6F8)
    val Surface2Light = Color(0xFFEDEDF1)
    val DividerLight  = Color(0xFFE6E6EC)

    // Brand
    val Pink      = Color(0xFFFF4865)
    val PinkPress = Color(0xFFE5354F)
    val Magenta   = Color(0xFFE91E63)
    val Rose      = Color(0xFFFF7B93)
    val Gold      = Color(0xFFFFC24B) // Crush / premium ONLY

    // Text
    val TextPrimary      = Color(0xFFF4F4F6)
    val TextSecondary    = Color(0xFFA0A0AE)
    val TextTertiary     = Color(0xFF6C6C7A)
    val TextPrimaryLight = Color(0xFF15151B)
    val OnPink           = Color(0xFFFFFFFF)
    val OnGold           = Color(0xFF1A1A1A)

    // Semantic
    val Success = Color(0xFF4ED9A4)
    val Error   = Color(0xFFFF5C5C)

    // Hero gradient — primary "Say hi" / Crush star / splash
    val HeroGradient = Brush.linearGradient(listOf(Pink, Magenta))
}

happn ships both themes; the brand is most recognizable on dark. The brand pink, magenta, gradient, and Crush gold are identical across schemes.

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

private val HappnDark = darkColorScheme(
    primary        = HappnColors.Pink,
    onPrimary      = HappnColors.OnPink,
    secondary      = HappnColors.Magenta,
    background      = HappnColors.Canvas,
    onBackground   = HappnColors.TextPrimary,
    surface        = HappnColors.Surface1,
    onSurface      = HappnColors.TextPrimary,
    surfaceVariant = HappnColors.Surface2,
    outline        = HappnColors.Divider,
    error          = HappnColors.Error,
)

private val HappnLight = lightColorScheme(
    primary        = HappnColors.Pink,
    onPrimary      = HappnColors.OnPink,
    secondary      = HappnColors.Magenta,
    background      = HappnColors.CanvasLight,
    onBackground   = HappnColors.TextPrimaryLight,
    surface        = HappnColors.Surface1Light,
    onSurface      = HappnColors.TextPrimaryLight,
    surfaceVariant = HappnColors.Surface2Light,
    outline        = HappnColors.DividerLight,
    error          = HappnColors.Error,
)

@Composable
fun HappnTheme(
    dark: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (dark) HappnDark else HappnLight,
    typography  = HappnTypography,
    content     = content,
)

2. Typography

happn pairs Poppins (warmth: wordmark, names, headings, buttons, crossing count) with Inter (legibility: body, location, chat). Both SIL OFL — drop the TTFs in res/font/. Weights 400–700.

// ui/theme/HappnType.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 Poppins = FontFamily(
    Font(R.font.poppins_semibold, FontWeight.SemiBold),
    Font(R.font.poppins_bold,     FontWeight.Bold),
)
val Inter = FontFamily(
    Font(R.font.inter_regular, FontWeight.Normal),
    Font(R.font.inter_medium,  FontWeight.Medium),
)

object HappnText {
    val Display    = TextStyle(Poppins, fontWeight = FontWeight.Bold,     fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.5).sp)
    val Name       = TextStyle(Poppins, fontWeight = FontWeight.Bold,     fontSize = 26.sp, lineHeight = 30.sp, letterSpacing = (-0.4).sp)
    val Section    = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
    val Subsection = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 24.sp)
    val CardName   = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
    val Button     = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 16.sp)
    val Count      = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 16.sp)
    val Tab        = TextStyle(Poppins, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.2.sp)
    val Body       = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
    val BodyMeta   = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
    val Location   = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
    val Caption    = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
}

val HappnTypography = Typography(
    headlineLarge  = HappnText.Display,
    headlineMedium = HappnText.Name,
    titleLarge     = HappnText.Section,
    bodyMedium     = HappnText.Body,
    labelSmall     = HappnText.Tab,
)

3. Signature Components

Crossings Timeline

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage

data class Crossing(
    val id: String,
    val avatarUrl: String,
    val name: String,        // "Camille, 27"
    val place: String,       // "Le Marais"
    val timeAgo: String,     // "11 min ago"
    val crossCount: Int,
    val charmed: Boolean,
)

@Composable
fun CrossingsTimeline(items: List<Crossing>, onCharm: (String) -> Unit) {
    LazyColumn(
        modifier = Modifier.fillMaxSize().background(HappnColors.Canvas),
        contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
        verticalArrangement = Arrangement.spacedBy(14.dp),
    ) {
        items(items, key = { it.id }) { c -> CrossingRow(c) { onCharm(c.id) } }
    }
}

@Composable
fun CrossingRow(c: Crossing, onCharm: () -> Unit) {
    Row(Modifier.fillMaxWidth()) {
        // Connector spine + dot
        Box(Modifier.width(18.dp).fillMaxHeight(), contentAlignment = Alignment.TopCenter) {
            Box(
                Modifier
                    .width(2.dp).fillMaxHeight().padding(top = 6.dp)
                    .background(HappnColors.Divider)
            )
            Box(
                Modifier
                    .padding(top = 6.dp).size(12.dp)
                    .clip(CircleShape).background(HappnColors.Pink)
                    .border(4.dp, HappnColors.Pink.copy(alpha = 0.18f), CircleShape)
            )
        }

        // Card
        Row(
            Modifier
                .weight(1f).padding(start = 8.dp)
                .clip(RoundedCornerShape(18.dp))
                .background(HappnColors.Surface1)
                .border(1.dp, HappnColors.Divider, RoundedCornerShape(18.dp))
                .padding(12.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            AsyncImage(
                model = c.avatarUrl, contentDescription = null,
                modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp)),
            )
            Column(Modifier.weight(1f)) {
                Text(c.name, style = HappnText.CardName, color = HappnColors.TextPrimary,
                    maxLines = 1, overflow = TextOverflow.Ellipsis)
                Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
                    Icon(Icons.Filled.LocationOn, null, tint = HappnColors.TextTertiary, modifier = Modifier.size(11.dp))
                    Text("${c.place} · ${c.timeAgo}", style = HappnText.Location, color = HappnColors.TextSecondary,
                        maxLines = 1, overflow = TextOverflow.Ellipsis)
                }
                Spacer(Modifier.height(5.dp))
                Text(
                    if (c.crossCount == 1) "You crossed paths once" else "You crossed paths ${c.crossCount} times",
                    style = HappnText.Count, color = HappnColors.Pink,
                )
            }
            CharmButton(charmed = c.charmed, onCharm = onCharm, size = 40.dp)
        }
    }
}

Charm Button (heart)

import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.launch

@Composable
fun CharmButton(charmed: Boolean, onCharm: () -> Unit, size: Dp = 40.dp) {
    val scope = rememberCoroutineScope()
    val scale = remember { Animatable(1f) }
    val haptics = LocalHapticFeedback.current

    Box(
        Modifier
            .size(size)
            .then(if (charmed) Modifier.shadow(8.dp, CircleShape, spotColor = HappnColors.Pink.copy(alpha = 0.5f)) else Modifier)
            .scale(scale.value)
            .clip(CircleShape)
            .background(if (charmed) HappnColors.Pink else HappnColors.Surface2)
            .then(if (charmed) Modifier else Modifier.border(1.dp, HappnColors.Divider, CircleShape))
            .clickable {
                scope.launch {
                    scale.animateTo(1.25f, spring(dampingRatio = 0.45f))
                    scale.animateTo(1f, spring(dampingRatio = 0.7f))
                }
                haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                onCharm()
            },
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            if (charmed) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
            contentDescription = if (charmed) "Charm sent" else "Send a Charm",
            tint = if (charmed) HappnColors.OnPink else HappnColors.TextSecondary,
            modifier = Modifier.size(size * 0.45f),
        )
    }
}

Crush Celebration

import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween

@Composable
fun CrushCelebration(
    leftAvatar: Brush, rightAvatar: Brush, onStartChat: () -> Unit,
) {
    var together by remember { mutableStateOf(false) }
    val offset by animateDpAsState(if (together) 28.dp else 160.dp, tween(360), label = "crush")
    val haptics = LocalHapticFeedback.current

    LaunchedEffect(Unit) {
        together = true
        haptics.performHapticFeedback(HapticFeedbackType.LongPress) // success analog
    }

    Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.92f)), contentAlignment = Alignment.Center) {
        Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(28.dp)) {
            Row {
                Box(Modifier.offset(x = -offset).size(96.dp).clip(CircleShape).background(leftAvatar))
                Box(Modifier.offset(x = offset).size(96.dp).clip(CircleShape).background(rightAvatar))
            }
            Text("It's a Crush!", style = HappnText.Display, color = HappnColors.TextPrimary)
            // Gold sparkle layer here — the ONLY place HappnColors.Gold appears (besides premium)
            Box(
                Modifier
                    .clip(RoundedCornerShape(999.dp))
                    .background(HappnColors.HeroGradient)
                    .clickable(onClick = onStartChat)
                    .padding(horizontal = 30.dp, vertical = 15.dp),
            ) { Text("Start chatting", style = HappnText.Button, color = HappnColors.OnPink) }
        }
    }
}

Map Crossing Pin (teardrop)

import androidx.compose.ui.draw.rotate

@Composable
fun CrossingPin(kind: PinKind) {
    Box(
        Modifier
            .size(44.dp)
            .rotate(45f)
            .clip(RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp, bottomEnd = 22.dp, bottomStart = 6.dp))
            .then(
                when (kind) {
                    PinKind.Standard -> Modifier.background(HappnColors.Pink)
                    PinKind.Mutual   -> Modifier.background(HappnColors.HeroGradient)
                    PinKind.Ghost    -> Modifier.background(HappnColors.Surface2).border(1.dp, HappnColors.Divider, RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp, bottomEnd = 22.dp, bottomStart = 6.dp))
                }
            ),
        contentAlignment = Alignment.Center,
    ) {
        Box(
            Modifier
                .size(30.dp).rotate(-45f).clip(CircleShape)
                .background(if (kind == PinKind.Ghost) HappnColors.TextTertiary else Color.White)
        )
    }
}

enum class PinKind { Standard, Mutual, Ghost }

Use as a maps-compose MarkerComposable content; keep the +45° body / -45° inner counter-rotation so the dot stays upright.

4. Navigation

happn has a transparent top bar and a 4-tab bottom strip with no tint pill — active is the pink icon + pink label.

@Composable
fun HappnBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = Color(0xFF0E0E12).copy(alpha = 0.94f),
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            Icons.Filled.Favorite to "Timeline",
            Icons.Filled.Map to "Map",
            Icons.Filled.ChatBubble to "Chats",
            Icons.Filled.Person to "Profile",
        )
        items.forEachIndexed { i, (icon, label) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, label, modifier = Modifier.size(23.dp)) },
                label = { Text(label, style = HappnText.Tab) },
                alwaysShowLabel = true,
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor = HappnColors.Pink,       // pink icon + pink label
                    selectedTextColor = HappnColors.Pink,
                    unselectedIconColor = HappnColors.TextTertiary,
                    unselectedTextColor = HappnColors.TextTertiary,
                    indicatorColor = Color.Transparent,         // NO Material pill — happn has none
                ),
            )
        }
    }
}

The top bar is a transparent Row: leading wordmark “happn” (Poppins 22sp Bold, trailing “n” in HappnColors.Pink), trailing search icon (17dp in a 34dp Surface2 circle).

5. Motion

happn motion is warm and emotional — the Charm bounce and the gold Crush takeover are the signatures.

Moment Compose recipe
Charm tap scale.animateTo(1.25f, spring(0.45f)) then animateTo(1f, spring(0.7f)) + long-press haptic; ghost → filled
Crush reveal avatars animateDpAsState from edge offset to center tween(360); gold sparkle layer; success haptic
Timeline new crossing LazyColumn item animateItemPlacement() + AnimatedVisibility(slideInVertically + fadeIn, tween(240))
Dot pulse (fresh) halo Animatable scale 1 → 1.4 → 1 once, tween(300) each leg
Card press Modifier.scale 0.98 via interactionSource, tween(80)
Tab switch icon tint cross-dissolves to pink tween(180); no pill slide
Map pin tap pin offset y -6dp + spring, then profile ModalBottomSheet up
Sheet present ModalBottomSheet slide-up (Material default) + scrim
// Charm bounce — the canonical happn motion
scope.launch {
    scale.animateTo(1.25f, spring(dampingRatio = 0.45f))
    scale.animateTo(1f,    spring(dampingRatio = 0.70f))
}

Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for Charm sent and the Crush moment; a lighter HapticFeedbackConstants.CLOCK_TICK (via LocalView) for tab switch and map-pin tap.

6. Icons

happn iconography is rounded/simple; androidx.compose.material:material-icons-extended covers it.

Purpose SF Symbol (iOS) Material Icon (Compose)
Timeline (tab) heart.text.square Icons.Filled.Favorite
Map (tab) map Icons.Filled.Map
Chats (tab) bubble.left.and.bubble.right Icons.Filled.ChatBubble
Profile (tab) person Icons.Filled.Person
Charm (heart) heart / heart.fill Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite
Crush (star) star.fill Icons.Filled.Star
Location stamp mappin.and.ellipse Icons.Filled.LocationOn
Search magnifyingglass Icons.Filled.Search
Back chevron.left Icons.Filled.ArrowBack
Send message arrow.up.circle.fill Icons.Filled.ArrowCircleUp
FlashNote text.bubble Icons.Filled.ChatBubbleOutline
Block / report exclamationmark.shield Icons.Filled.Shield
Settings gearshape Icons.Filled.Settings

7. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; LazyColumn animateItemPlacement, maps-compose markers comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); the graphite canvas wants light system-bar icons on dark, dark on light. The wordmark + first card sit below the camera cutout; the last card scrolls clear of the nav inset.
  • Both themes: drive scheme by isSystemInDarkTheme(); the brand is most recognizable on dark. Keep Pink, Magenta, HeroGradient, and Gold identical across schemes — only canvas/surface/text swap. Do not enable dynamicColorScheme() — happn pink is a fixed brand color and must hold regardless of wallpaper.
  • Font scaling: sp honors the user’s scale — keep it on display, name, section, body. Pin the crossing count, 10sp tab labels, and location/time meta via a fixed Density(fontScale = 1f) CompositionLocalProvider so the timeline row doesn’t reflow and misalign the connector dot.
  • Card name: maxLines = 1 + overflow = Ellipsis so a long name never pushes the card to 2 lines and breaks dot alignment.
  • TalkBack: give each CrossingRow Modifier.semantics { contentDescription = "Crossed paths with ${c.name}, near ${c.place}, ${c.timeAgo}, ${c.crossCount} times" }; label the Charm button “Send a Charm to {name}” / “Charm sent”; the Crush screen announces “It’s a Crush with {name}”. The crossing count must be exposed to screen readers — it is meaning, not decoration.
  • Touch targets: Material guidance is 48dp — give the 40dp Charm a 48dp hit via padding; the 44dp map pin a 48dp hit; top-bar search a 48dp hit; the full crossing card is tappable (→ profile).
  • Contrast: #F4F4F6 on #0E0E12 is strong; #FFFFFF on #FF4865 passes AA for the Charm; the pink crossing count (#FF4865 on #18181F) passes for 12sp semibold — keep the weight.
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the dot pulse, the Charm 1.25 overshoot (use a fill Crossfade), and the Crush avatar slide; keep the haptic.
  • Gold discipline: HappnColors.Gold is reserved for the Crush celebration and premium upsell only — never use it as a status, accent, or TalkBack highlight color elsewhere.
All mobile designs →

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