Kik (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Kik’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the bubble + S/D/R receipt + Kik Code, 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 (Kik’s modern Blue brand, the gray/blue bubble pair, the single-letter S/D/R receipt, username-first identity, bots, Kik Codes) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,sp/dp. Kik is light-first; a neutral near-black dark scheme is provided.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for avatars / Kik Code art.
1. Color Tokens
// ui/theme/KikColors.kt
import androidx.compose.ui.graphics.Color
object KikColors {
// Brand Kik Blue (modern) + Green (heritage)
val Blue = Color(0xFF00B0F0)
val BlueDeep = Color(0xFF0093C8)
val BluePressed = Color(0xFF0086B8)
val Cyan = Color(0xFF4FCBF7)
val GreenLegacy = Color(0xFF82BC23) // heritage accent only
val InkOnBlue = Color(0xFF002A36)
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFFFFFFF)
val Incoming = Color(0xFFE9EAEC)
val SurfaceGray = Color(0xFFF3F4F6)
val RowPressed = Color(0xFFE7E8EB)
val Divider = Color(0xFFE1E2E5)
// Canvas & Surfaces (Dark) — neutral near-black
val DarkCanvas = Color(0xFF121316)
val DarkSurface1 = Color(0xFF1B1D21)
val DarkSurface2 = Color(0xFF25282D)
val DarkDivider = Color(0xFF31343A)
// Text
val TextPrimary = Color(0xFF16181B)
val TextPrimaryDark = Color(0xFFF1F2F4)
val TextSecondary = Color(0xFF6C6F77)
val TextSecondaryDark = Color(0xFF9CA0A8)
val TextTertiary = Color(0xFF9A9DA5)
val TextTertiaryDark = Color(0xFF686C74)
// Semantic
val Error = Color(0xFFF0473E)
val Link = Color(0xFF0093C8)
}
Wire it into both schemes. Kik is light-first; dark uses a neutral near-black #121316, never color-cast.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val KikLight = lightColorScheme(
primary = KikColors.Blue,
onPrimary = KikColors.InkOnBlue, // dark-cyan ink on bright blue (AA)
background = KikColors.Canvas,
onBackground = KikColors.TextPrimary,
surface = KikColors.SurfaceGray,
onSurface = KikColors.TextPrimary,
surfaceVariant = KikColors.Incoming,
outline = KikColors.Divider,
error = KikColors.Error,
)
private val KikDark = darkColorScheme(
primary = KikColors.Blue,
onPrimary = KikColors.InkOnBlue,
background = KikColors.DarkCanvas,
onBackground = KikColors.TextPrimaryDark,
surface = KikColors.DarkSurface1,
onSurface = KikColors.TextPrimaryDark,
surfaceVariant = KikColors.DarkSurface2,
outline = KikColors.DarkDivider,
error = KikColors.Error,
)
@Composable
fun KikTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) KikDark else KikLight,
typography = KikTypography,
content = content,
)
2. Typography (M3)
Kik’s brand face is proprietary; bundle Inter (SIL OFL) in res/font/ as the closest free substitute. Heavy 700/800 titles & username headline; body 400 (outgoing bumps to 500 for ink crispness on Blue). pt → sp 1:1.
// ui/theme/KikType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
Font(R.font.inter_extrabold, FontWeight.ExtraBold),
)
object KikText {
val LargeTitle = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 38.sp, letterSpacing = (-0.4).sp)
val ScreenTitle = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
val Title3 = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 27.sp, letterSpacing = (-0.2).sp)
val Headline = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.2).sp)
val RowTitle = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 19.sp)
val BodyOut = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 19.sp)
val BodyEmph = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 19.sp)
val Preview = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 18.sp)
val Footnote = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 18.sp)
val Caption = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.1.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val Receipt = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.2.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.1.sp)
}
val KikTypography = Typography(
headlineLarge = KikText.LargeTitle,
headlineMedium = KikText.ScreenTitle,
titleMedium = KikText.Headline,
bodyLarge = KikText.Body,
labelSmall = KikText.Tab,
)
3. Signature Components
Message Bubble (in / out, with tail)
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
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.unit.dp
import androidx.compose.ui.unit.sp
enum class SDR { Sending, Sent, Delivered, Read, Failed }
@Composable
fun KikBubble(text: String, outgoing: Boolean, tailEnd: Boolean, timestamp: String, receipt: SDR, dark: Boolean) {
val shape = RoundedCornerShape(
topStart = 16.dp, topEnd = 16.dp,
bottomStart = if (!outgoing && tailEnd) 5.dp else 16.dp,
bottomEnd = if ( outgoing && tailEnd) 5.dp else 16.dp,
)
Column(Modifier.fillMaxWidth(), horizontalAlignment = if (outgoing) Alignment.End else Alignment.Start) {
Box(
Modifier
.fillMaxWidth(0.80f)
.wrapContentWidth(if (outgoing) Alignment.End else Alignment.Start)
.clip(shape)
.background(if (outgoing) KikColors.Blue else if (dark) KikColors.DarkSurface2 else KikColors.Incoming)
.padding(horizontal = 13.dp, vertical = 8.dp)
) {
Text(text, style = if (outgoing) KikText.BodyOut else KikText.Body,
color = if (outgoing) KikColors.InkOnBlue else if (dark) KikColors.TextPrimaryDark else KikColors.TextPrimary)
}
if (outgoing) {
Row(Modifier.padding(top = 3.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Text(timestamp, fontSize = 10.sp, color = if (dark) KikColors.TextTertiaryDark else KikColors.TextTertiary)
SDRReceipt(receipt, dark)
}
}
}
}
S / D / R Receipt
@Composable
fun SDRReceipt(state: SDR, dark: Boolean) {
val letter = when (state) {
SDR.Sending -> "…"; SDR.Sent -> "S"; SDR.Delivered -> "D"; SDR.Read -> "R"; SDR.Failed -> "!"
}
val color = when (state) {
SDR.Sending, SDR.Sent -> if (dark) KikColors.TextTertiaryDark else KikColors.TextTertiary
SDR.Delivered -> if (dark) KikColors.TextSecondaryDark else KikColors.TextSecondary
SDR.Read -> KikColors.Blue // the one color moment
SDR.Failed -> KikColors.Error
}
// No animation — instant glyph swap; only the R recolor reads as an event
Text(letter, style = KikText.Receipt, color = color)
}
Conversation Row (person vs bot)
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.graphics.Brush
@Composable
fun KikConvRow(
name: String, preview: String, time: String, isBot: Boolean,
unread: Int?, receipt: SDR?, gradient: List<Color>, dark: Boolean,
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(48.dp)
.clip(if (isBot) RoundedCornerShape(14.dp) else CircleShape)
.background(Brush.linearGradient(gradient)),
contentAlignment = Alignment.Center,
) { Text(name.take(1), color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Bold) }
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Text(name, style = KikText.RowTitle, maxLines = 1,
color = if (dark) KikColors.TextPrimaryDark else KikColors.TextPrimary)
if (isBot) {
Box(
Modifier.clip(RoundedCornerShape(4.dp))
.background(if (dark) KikColors.DarkSurface2 else KikColors.SurfaceGray)
.padding(horizontal = 5.dp, vertical = 1.dp)
) { Text("BOT", fontSize = 9.sp, fontWeight = FontWeight.Bold,
color = if (dark) KikColors.TextSecondaryDark else KikColors.TextSecondary) }
}
}
Text(preview, style = KikText.Preview, maxLines = 1,
color = if (dark) KikColors.TextSecondaryDark else KikColors.TextSecondary)
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(time, fontSize = 11.sp, color = if (dark) KikColors.TextTertiaryDark else KikColors.TextTertiary)
when {
unread != null -> Box(
Modifier.clip(CircleShape).background(KikColors.Blue).defaultMinSize(18.dp, 18.dp).padding(horizontal = 5.dp),
contentAlignment = Alignment.Center,
) { Text("$unread", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = KikColors.InkOnBlue) }
receipt != null -> SDRReceipt(receipt, dark)
}
}
}
}
Kik Code
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
@Composable
fun KikCode(modifier: Modifier = Modifier) {
// Flat, high-contrast — NO shadow (must scan reliably)
Box(
modifier.size(120.dp).clip(RoundedCornerShape(24.dp)).background(KikColors.Blue),
contentAlignment = Alignment.Center,
) {
Canvas(Modifier.size(96.dp)) {
drawCircle(
color = Color.White,
radius = size.minDimension / 2 - 3.dp.toPx(),
center = center,
style = Stroke(width = 6.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(2.dp.toPx(), 6.dp.toPx()))),
)
}
Box(Modifier.size(44.dp).clip(RoundedCornerShape(12.dp)).background(Color.White))
}
}
Compose Bar (text “Send” label)
import androidx.compose.foundation.clickable
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
@Composable
fun KikComposeBar(dark: Boolean, onSend: (String) -> Unit) {
var text by remember { mutableStateOf("") }
val empty = text.isBlank()
Row(
Modifier.fillMaxWidth()
.background(if (dark) KikColors.DarkSurface1 else KikColors.Canvas)
.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(Icons.Filled.Add, "Attach", tint = if (dark) KikColors.TextSecondaryDark else KikColors.TextSecondary, modifier = Modifier.size(22.dp))
Box(
Modifier.weight(1f).heightIn(min = 36.dp).clip(RoundedCornerShape(18.dp))
.background(if (dark) KikColors.DarkSurface2 else KikColors.SurfaceGray)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
BasicTextField(
value = text, onValueChange = { text = it },
textStyle = KikText.Body.copy(color = if (dark) KikColors.TextPrimaryDark else KikColors.TextPrimary),
decorationBox = { inner ->
if (empty) Text("Type a message", style = KikText.Body, color = if (dark) KikColors.TextTertiaryDark else KikColors.TextTertiary)
inner()
},
)
}
Text(
"Send",
style = KikText.Receipt.copy(fontSize = 14.sp),
color = if (empty) (if (dark) KikColors.TextTertiaryDark else KikColors.TextTertiary) else KikColors.Blue,
modifier = Modifier.clickable(enabled = !empty) { onSend(text); text = "" }.padding(horizontal = 6.dp),
)
}
}
4. Navigation
Kik is text/bot-first — no call/video tab. On Android model the strip as a NavigationBar with no tint pill — active is bright Kik Blue.
import androidx.compose.material3.*
import androidx.compose.material.icons.filled.*
@Composable
fun KikBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = KikColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
"Chats" to Icons.Filled.ChatBubble,
"People" to Icons.Filled.People,
"Discover" to Icons.Filled.AutoAwesome,
"Kik Code" to Icons.Filled.QrCode,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, label, Modifier.size(22.dp)) },
label = { Text(label, style = KikText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = KikColors.Blue,
selectedTextColor = KikColors.Blue,
unselectedIconColor = KikColors.TextTertiary,
unselectedTextColor = KikColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Kik has none
),
)
}
}
}
The chat header is a CenterAlignedTopAppBar: leading back ChevronLeft tinted Blue, centered @username Headline, trailing 32.dp gradient avatar (tap → profile / Kik Code). No call/video icons.
5. Motion
Kik motion is soft spring, 0.15–0.32s.
| Moment | Compose recipe |
|---|---|
| Outgoing bubble send | new bubble slideInVertically { it } + scaleIn(initialScale = 0.88f, spring(dampingRatio = 0.8f)) |
| S/D/R receipt | no animation — instant glyph swap; the R-to-Blue recolor is the only “event” |
| List row reorder | LazyColumn items animateItemPlacement(tween(250)) when a chat bumps to the top |
| Bot suggested-reply chips | AnimatedVisibility fadeIn(tween(200)) + slideInVertically { it / 4 } |
| Kik Code match | plate animateFloatAsState scale 1 → 1.1 → 1 pulse, then nav push |
| Tab switch | instant; icon tint animateColorAsState tween(150) |
// Canonical outgoing-bubble entrance
AnimatedVisibility(
visible = true,
enter = slideInVertically(spring(dampingRatio = 0.8f)) { it / 2 } +
scaleIn(initialScale = 0.88f, animationSpec = spring(dampingRatio = 0.8f)),
) { KikBubble(/* … */) }
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for send and bot-button tap; HapticFeedbackConstants.CONFIRM (via LocalView) on Kik Code match / friend added. The receipt change is visual only (no haptic, no animation).
6. Icons
Kik uses a custom brand glyph set; the closest first-party Compose set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Back | chevron.left |
Icons.Filled.ChevronLeft |
| New chat | plus |
Icons.Filled.Add |
| Scan / camera | qrcode.viewfinder |
Icons.Filled.QrCodeScanner |
| Attachment | plus |
Icons.Filled.Add |
| Search | magnifyingglass |
Icons.Filled.Search |
| Chats (tab) | bubble.left.fill |
Icons.Filled.ChatBubble |
| People (tab) | person.2.fill |
Icons.Filled.People |
| Discover (tab) | sparkles |
Icons.Filled.AutoAwesome |
| Kik Code (tab) | qrcode |
Icons.Filled.QrCode |
| GIF | photo.stack |
Icons.Filled.Gif |
| Web content | globe |
Icons.Filled.Public |
| Failed message | exclamationmark.circle.fill |
Icons.Filled.ErrorOutline |
| Group | person.3.fill |
Icons.Filled.Groups |
| Block / report | hand.raised.fill |
Icons.Filled.Block |
| Settings | gearshape.fill |
Icons.Filled.Settings |
| Share Kik Code | square.and.arrow.up |
Icons.Filled.Share |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor 21; spring +
Canvascomfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge:
enableEdgeToEdge(); white canvas wants dark system-bar content (light-content on the neutral dark canvas). Compose bar above the IME —Modifier.imePadding()on the threadLazyColumn. - Font: Kik’s brand face is proprietary — bundle Inter (SIL OFL) in
res/font/; keep ExtraBold/Bold for titles and the username headline.sphonors the user’s font scale. - Font scaling: scale titles/headline/row-title/body/previews; pin layout-sensitive text (timestamps, 11sp tab labels, the S/D/R glyph, “BOT” tag) via a fixed-
fontScaleLocalDensity; keep the Kik Code plate fixed. - TalkBack: spell receipts as values (“Sent”/“Delivered”/“Read”) via
stateDescription, not the bare letter; bot rows announce “{name}, bot”; usernames read with leading “at”; the Kik Code usescontentDescription = "Kik Code, double-tap to share". The Read-Blue receipt is reinforced by the explicit “R” so it survives color-blindness. - Touch targets: Material guidance 48.dp — give the 22.dp attachment icon and the “Send” text label a 48.dp hit box; list rows / tabs / suggested-reply chips are full-press; the Kik Code plate is fully tappable.
- Contrast: dark-cyan ink
#002A36on bright#00B0F0passes WCAG AA — never white on the bright Blue; primary text on#E9EAEC/#25282Dpasses AA. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the bubble spring-in (crossfade) and the Kik Code pulse; the receipt has no animation to disable. - Dark mode: invert via the
Dark*palette — neutral#121316, NOT color-cast; Kik Blue#00B0F0holds; incoming →#25282D, text →#F1F2F4; the R receipt stays#00B0F0in both modes. Shadows fade on dark, so add a 1dpDarkDividerborder to floating menus as the elevation cue; keep the Kik Code plate flat & high-contrast for reliable scanning. Do not enable Material YoudynamicColorScheme()— Kik’s modern-Blue identity must hold regardless of wallpaper.