The League (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports The League’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the embossed prospect card + concierge note, 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 League’s near-true-black canvas, the single rationed gold, the no-shadow hairline system, the serif/sans split, the calling-card prospect) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,BorderStrokeinstead of a shadow,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for prospect photos. The League’s palette is fixed black + one gold — no Palette extraction. The League is dark-native: there is no light scheme — force the dark aesthetic regardless of system setting.
1. Color Tokens
// ui/theme/LeagueColors.kt
import androidx.compose.ui.graphics.Color
object LeagueColors {
// Canvas & Surfaces (dark-native — the only theme)
val Canvas = Color(0xFF0A0A0A) // near-true black — the club after dark
val Surface1 = Color(0xFF141414)
val Surface2 = Color(0xFF1C1A17)
val CardBase = Color(0xFF100F0C)
val Hairline = Color(0xFF2A2620) // the 1px gold-tinted border — does ALL depth work
// Text
val TextPrimary = Color(0xFFEDE9E2) // warm off-white — never pure white
val TextSecondary = Color(0xFF9E988C)
val TextTertiary = Color(0xFF6B665C)
val OnGold = Color(0xFF1A1408)
// Accent — the SINGLE accent is gold
val Gold = Color(0xFFC8A35A)
val GoldBright = Color(0xFFDBBA71)
val GoldDeep = Color(0xFFA8863F)
val Champagne = Color(0xFFE8DCC0) // premium / celebration only
// Semantic
val Success = Color(0xFF6FAE8A) // muted sage
val Error = Color(0xFFC77A7A) // dusty rose
}
Wire it into a dark-only scheme. The League has no light mode in spirit — force dark.
// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
private val LeagueDark = darkColorScheme(
primary = LeagueColors.Gold,
onPrimary = LeagueColors.OnGold,
background = LeagueColors.Canvas,
onBackground = LeagueColors.TextPrimary,
surface = LeagueColors.Surface1,
onSurface = LeagueColors.TextPrimary,
surfaceVariant = LeagueColors.Surface2,
outline = LeagueColors.Hairline,
error = LeagueColors.Error,
)
@Composable
fun LeagueTheme(content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = LeagueDark, // always dark — never a light variant
typography = LeagueTypography,
content = content,
)
2. Typography
The League pairs a high-contrast display serif (Cormorant Garamond) with a clean geometric sans (Jost) — both SIL OFL, drop the TTFs in res/font/. The serif/sans split IS the hierarchy: serif = the person & institution, sans = the machine & metadata. Never collapse to one family.
// ui/theme/LeagueType.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 Cormorant = FontFamily(
Font(R.font.cormorant_regular, FontWeight.Normal),
Font(R.font.cormorant_medium, FontWeight.Medium),
Font(R.font.cormorant_semibold, FontWeight.SemiBold),
Font(R.font.cormorant_bold, FontWeight.Bold),
Font(R.font.cormorant_medium_italic, FontWeight.Medium, FontStyle.Italic),
)
val Jost = FontFamily(
Font(R.font.jost_regular, FontWeight.Normal),
Font(R.font.jost_medium, FontWeight.Medium),
Font(R.font.jost_semibold, FontWeight.SemiBold),
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object LeagueText {
val Wordmark = TextStyle(Cormorant, fontWeight = FontWeight.Bold, fontSize = 34.sp, letterSpacing = 0.2.sp)
val Name = TextStyle(Cormorant, fontWeight = FontWeight.Bold, fontSize = 26.sp, letterSpacing = 0.2.sp)
val AgeInline = TextStyle(Cormorant, fontWeight = FontWeight.Normal, fontSize = 26.sp)
val ScreenTitle = TextStyle(Cormorant, fontWeight = FontWeight.SemiBold, fontSize = 21.sp, letterSpacing = 0.2.sp)
val Concierge = TextStyle(Cormorant, fontWeight = FontWeight.Medium, fontSize = 16.sp, fontStyle = FontStyle.Italic, lineHeight = 26.sp)
val Stat = TextStyle(Cormorant, fontWeight = FontWeight.SemiBold, fontSize = 21.sp)
val Subtitle = TextStyle(Jost, fontWeight = FontWeight.Medium, fontSize = 16.sp, letterSpacing = 0.3.sp)
val Body = TextStyle(Jost, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 24.sp)
val Credential = TextStyle(Jost, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 22.sp)
val Eyebrow = TextStyle(Jost, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, letterSpacing = 2.sp)
val Button = TextStyle(Jost, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, letterSpacing = 1.6.sp)
val Caption = TextStyle(Jost, fontWeight = FontWeight.Normal, fontSize = 12.sp)
val Tab = TextStyle(Jost, fontWeight = FontWeight.Medium, fontSize = 9.sp, letterSpacing = 1.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val LeagueTypography = Typography(
headlineLarge = LeagueText.Name,
headlineMedium = LeagueText.ScreenTitle,
titleMedium = LeagueText.Subtitle,
bodyMedium = LeagueText.Body,
labelSmall = LeagueText.Tab,
)
Compose has no automatic
text-transform. For uppercase eyebrows / buttons / tabs, call.uppercase()on the string — never bake casing into the font.
3. Signature Components
Prospect Card (the embossed calling card)
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
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.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun ProspectCard(
photoUrl: String,
name: String,
age: Int,
title: String, // "VP Strategy · Goldman Sachs"
school: String, // "Harvard Business School"
location: String, // "2 mi · Tribeca, New York"
verified: Boolean,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(6.dp),
colors = CardDefaults.cardColors(containerColor = LeagueColors.CardBase),
border = BorderStroke(1.dp, LeagueColors.Hairline), // NO elevation — depth is the hairline
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Box {
AsyncImage(
model = photoUrl,
contentDescription = "$name, $age",
modifier = Modifier.fillMaxWidth().height(280.dp),
contentScale = ContentScale.Crop,
)
if (verified) {
Box(
Modifier
.align(Alignment.TopEnd).padding(14.dp)
.size(30.dp).clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.6f))
.border(1.dp, LeagueColors.Gold, RoundedCornerShape(50)),
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Star, null, tint = LeagueColors.Gold, modifier = Modifier.size(15.dp)) }
}
}
Column(Modifier.padding(horizontal = 18.dp, vertical = 14.dp)) {
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(name, style = LeagueText.Name, color = LeagueColors.TextPrimary)
Text("$age", style = LeagueText.AgeInline, color = LeagueColors.TextSecondary)
}
Box(Modifier.padding(vertical = 8.dp).width(28.dp).height(1.dp).background(LeagueColors.Gold))
Credential(title)
Credential(school)
Credential(location)
}
}
}
@Composable
private fun Credential(text: String) {
Row(Modifier.padding(vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text("◦", style = LeagueText.Credential, color = LeagueColors.Gold)
Text(text, style = LeagueText.Credential, color = LeagueColors.TextSecondary, modifier = Modifier.weight(1f))
}
}
Card Action Row
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun CardActions(onPass: () -> Unit, onHeart: () -> Unit, onMessage: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 18.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CircleAction(Icons.Filled.Close, 48.dp, LeagueColors.TextSecondary, LeagueColors.Hairline, onPass)
// Heart is the focal action — gold OUTLINE, not a fill
CircleAction(Icons.Outlined.FavoriteBorder, 56.dp, LeagueColors.Gold, LeagueColors.Gold, onHeart)
CircleAction(Icons.Outlined.ChatBubbleOutline, 48.dp, LeagueColors.Gold, LeagueColors.Hairline, onMessage)
}
}
@Composable
private fun CircleAction(icon: ImageVector, size: Dp, tint: Color, border: Color, onClick: () -> Unit) {
val haptics = LocalHapticFeedback.current
Box(
Modifier
.size(size).clip(RoundedCornerShape(50))
.background(LeagueColors.Surface1)
.border(1.dp, border, RoundedCornerShape(50))
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // soft analog
onClick()
},
contentAlignment = Alignment.Center,
) { Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(size * 0.4f)) }
}
Concierge Note
import androidx.compose.animation.core.*
import androidx.compose.runtime.*
import androidx.compose.ui.draw.scale
@Composable
fun ConciergeNote(message: String, onIntroduce: (() -> Unit)? = null) {
val ruleScale = remember { Animatable(0f) }
val textAlpha = remember { Animatable(0f) }
LaunchedEffect(Unit) {
ruleScale.animateTo(1f, tween(200))
textAlpha.animateTo(1f, tween(180))
}
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(LeagueColors.Surface2)
.border(1.dp, LeagueColors.Hairline, RoundedCornerShape(4.dp)),
) {
Box(
Modifier
.width(2.dp).fillMaxHeight()
.graphicsLayer { scaleY = ruleScale.value; transformOrigin = TransformOrigin(0f, 0f) }
.background(LeagueColors.Gold)
)
Column(Modifier.padding(18.dp).graphicsLayer { alpha = textAlpha.value }) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Star, null, tint = LeagueColors.Gold, modifier = Modifier.size(12.dp))
Text("YOUR CONCIERGE", style = LeagueText.Eyebrow, color = LeagueColors.Gold)
}
Spacer(Modifier.height(8.dp))
Text(message, style = LeagueText.Concierge, color = LeagueColors.TextPrimary)
if (onIntroduce != null) {
Spacer(Modifier.height(12.dp))
Text(
"MAKE THE INTRODUCTION →",
style = LeagueText.Button, color = LeagueColors.Gold,
modifier = Modifier.clickable { onIntroduce() },
)
}
}
}
}
Batch Banner
@Composable
fun BatchBanner(count: Int) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(LeagueColors.Surface1)
.border(1.dp, LeagueColors.Hairline, RoundedCornerShape(4.dp))
.padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("TODAY'S BATCH", style = LeagueText.Eyebrow, color = LeagueColors.TextSecondary)
Text("$count Prospects", style = LeagueText.Stat, color = LeagueColors.Gold)
}
}
Buttons
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
@Composable
fun LeaguePrimaryButton(title: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
Button(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(3.dp), // sharp engraved-plate geometry
colors = ButtonDefaults.buttonColors(
containerColor = LeagueColors.Gold,
contentColor = LeagueColors.OnGold,
),
contentPadding = PaddingValues(vertical = 15.dp),
) { Text(title.uppercase(), style = LeagueText.Button) }
}
@Composable
fun LeagueOutlineButton(title: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
OutlinedButton(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(3.dp),
border = BorderStroke(1.dp, LeagueColors.Gold),
) { Text(title.uppercase(), style = LeagueText.Button, color = LeagueColors.Gold) }
}
4. Navigation
The League has minimal chrome: a 4-tab bottom strip and a centered serif wordmark top bar. On Android, model the strip as a NavigationBar. There is no Material tint pill — the gold tint alone signals selection, and the top “border” is a 1dp hairline, never a shadow.
@Composable
fun LeagueBottomBar(selected: Int, onSelect: (Int) -> Unit) {
Column {
// 1dp hairline instead of a NavigationBar shadow
Box(Modifier.fillMaxWidth().height(1.dp).background(LeagueColors.Hairline))
NavigationBar(
containerColor = LeagueColors.Canvas, // opaque black — no blur
tonalElevation = 0.dp,
) {
val items = listOf(
"Prospects" to Icons.Outlined.ViewAgenda,
"Matches" to Icons.Outlined.FavoriteBorder,
"Concierge" to Icons.Outlined.ChatBubbleOutline,
"Profile" to Icons.Outlined.PersonOutline,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(21.dp)) },
label = { Text(label.uppercase(), style = LeagueText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = LeagueColors.Gold,
selectedTextColor = LeagueColors.Gold,
unselectedIconColor = LeagueColors.TextTertiary,
unselectedTextColor = LeagueColors.TextTertiary,
indicatorColor = Color.Transparent, // The League has no Material pill
),
)
}
}
}
}
5. Motion
The League’s motion is restrained — fades and draws, never a thrown swipe or confetti. Celebrations are a drawn wax seal.
| Moment | Compose recipe |
|---|---|
| Card advance | current card animateFloatAsState alpha 1f → 0f + offset y 0 → -12.dp, tween(280, easing = EaseOut); next Crossfade in |
| Heart send | outline Icon swaps to filled + scale 1f → 1.12f → 1f via animateFloatAsState tween(120) legs; single soft haptic |
| Concierge arrival | gold rule graphicsLayer { scaleY } 0f → 1f tween(200), then text alpha 0f → 1f tween(180) |
| Batch count | AnimatedContent(targetState = count, transitionSpec = { fadeIn() togetherWith fadeOut() }) (restrained — no odometer) |
| Match celebration | wax-seal Canvas drawArc/Path with animated PathEffect/trim 0f → 1f tween(600); NO confetti |
| Tab switch | instant; tint cross-fades via NavigationBarItemDefaults (no shared-axis) |
| Field focus | bottom border color animateColorAsState Transparent → Gold tween(160) |
| Page push | Nav3/NavHost slide push tween(300); backgrounds stay true-black for seamlessness |
// Concierge rule-draw — the canonical League motion
val ruleScale = remember { Animatable(0f) }
LaunchedEffect(Unit) { ruleScale.animateTo(1f, tween(200, easing = EaseOut)) }
Box(
Modifier
.width(2.dp).fillMaxHeight()
.graphicsLayer { scaleY = ruleScale.value; transformOrigin = TransformOrigin(0f, 0f) }
.background(LeagueColors.Gold)
)
Haptics: prefer LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the single soft impact on heart-send and introduction-accepted. For the lighter tab tick use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK). Nothing buzzy — restraint extends to touch; never use a long/heavy vibration.
6. Icons
The League’s iconography is fine-line, never filled (except the gold star seal). The closest first-party set is androidx.compose.material:material-icons-extended — prefer the Outlined variants.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Prospects (tab) | rectangle.portrait.on.rectangle.portrait |
Icons.Outlined.ViewAgenda |
| Matches (tab) | heart |
Icons.Outlined.FavoriteBorder |
| Concierge (tab) | bubble.left |
Icons.Outlined.ChatBubbleOutline |
| Profile (tab) | person |
Icons.Outlined.PersonOutline |
| Heart (card action) | heart |
Icons.Outlined.FavoriteBorder |
| Pass (card action) | xmark |
Icons.Filled.Close |
| Message (card action) | bubble.left |
Icons.Outlined.ChatBubbleOutline |
| Verification seal | star.fill |
Icons.Filled.Star (gold) |
| Concierge mark | star.fill |
Icons.Filled.Star (gold, 12dp) |
| Search | magnifyingglass |
Icons.Outlined.Search |
| Settings | gearshape |
Icons.Outlined.Settings |
| Membership / premium | crown |
Icons.Outlined.WorkspacePremium |
| Back | chevron.left |
Icons.AutoMirrored.Filled.ArrowBack |
| Credential mark | circle |
text ◦ (not an icon) |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; nothing here needs a higher API — no blur, no shadow elevation).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Dark-native: The League has no light mode. Always pass
LeagueDark; never branch onisSystemInDarkTheme().enableEdgeToEdge()with light system-bar content over the near-true-black canvas. - No shadows: never set
elevation/shadow— separation is the 1dpHairlineBorderStrokeplus the tonal surface lift (#141414vs#0A0A0A). A drop shadow breaks the engraved, black-tie identity. - No gradients, no blur: surfaces are flat; never reach for
Brushgradients orModifier.blur— depth is line, not light. - Font scaling:
sphonors the user’s font scale — keep it on serif names, screen titles, body, credentials, concierge notes. Pin layout-sensitive text (11sp eyebrows, 13sp button labels, 9sp tab labels, the batch count) by deriving fromdpor fixingfontScale = 1fvia a localDensity. - Uppercase: call
String.uppercase()for eyebrows / buttons / tabs — never bake casing into the font; TalkBack should still read the natural-case word. - Serif/sans split: the split is the hierarchy — never substitute Jost for a name or Cormorant for a tab label, even under font-loading failure (provide a serif and a sans system fallback respectively).
- TalkBack: the prospect card is one focusable node announcing “{name}, {age}, {title}, {school}, {location}, verified member”; card actions get
contentDescriptions (“Pass”, “Send heart”, “Message”); the concierge note announces “Concierge: {message}”; the batch banner announces “{count} prospects in today’s batch”. Ensure the gold hairline is never the sole carrier of meaning — pair structural borders with text. - Touch targets: Material guidance is 48.dp — the Heart action is 56.dp (good); Pass / Message are 48.dp (good); pad the 21.dp tab icons to a 48.dp hit; the concierge inline CTA ≥ 48.dp row.
- Contrast:
#EDE9E2on#0A0A0Afar exceeds WCAG AA;#C8A35Aon#0A0A0Apasses AA for gold UI text; the CTA (#1A1408on#C8A35A) passes AA at 13sp bold — re-check if you tweak the gold. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, replace the card drift with an instantCrossfade, show the concierge rule + text immediately, and render the completed wax seal without the draw — keep the success haptic. - No dynamic color: do not enable Material You
dynamicColorScheme()— The League’s black-and-single-gold identity must hold regardless of wallpaper; there is no second accent to harmonize, and a tinted scheme would destroy the engraved members-club aesthetic.