KakaoTalk (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports KakaoTalk’s visual language to Android with Jetpack Compose (Material 3): a color token object, an M3 Typography, paste-ready @Composables for the rounded-square avatar, the message bubble with its iconic side-docked unread mark, the friends roster, the gifticon card, plus navigation, 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 (Kakao Yellow
#FEE500outbound bubbles on Kakao Brown text, the side-docked yellow unread “N”, rounded-square avatars, the blue-gray chat backdrop, Kakao Friends emoticon stickers, gifticon cards) while making everything idiomatic Android — aNavigationBarinstead of a UITabBar,ModalBottomSheetfor the action menu,dp/spinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for avatars, emoticons, and gifticon images.
1. Color Tokens
// ui/theme/KTColors.kt
import androidx.compose.ui.graphics.Color
object KTColors {
// Brand
val Yellow = Color(0xFFFEE500)
val YellowPressed = Color(0xFFE6CF00)
val Brown = Color(0xFF3C1E1E)
val BrownSoft = Color(0xFF5A3A3A)
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFFFFFFF)
val ChatBgLight = Color(0xFFB2C7DA)
val SurfaceGray = Color(0xFFF5F5F5)
val InboundLight = Color(0xFFFFFFFF)
val DividerLight = Color(0xFFE6E6E6)
// Canvas & Surfaces (Dark) — NOT pure black
val DarkCanvas = Color(0xFF1A1A1A)
val ChatBgDark = Color(0xFF1E2A33)
val DarkSurface1 = Color(0xFF242424)
val InboundDark = Color(0xFF2E2E2E)
val DarkDivider = Color(0xFF333333)
// Text
val TextPrimary = Color(0xFF191919)
val TextSecondary = Color(0xFF6A6A6A)
val TextTertiary = Color(0xFF9A9A9A)
val DarkTextPrimary = Color(0xFFEDEDED)
val DarkTextSecondary = Color(0xFF9A9A9A)
val OnYellow = Color(0xFF3C1E1E)
// Semantic
val Unread = Color(0xFFFEE500)
val Link = Color(0xFF4B9BFF)
val Success = Color(0xFF2ECC71)
val Error = Color(0xFFF15E6C)
val Badge = Color(0xFFFF3B30)
}
// Avatar generated gradients, assigned per friend id
val ktAvatarGradients: List<List<Color>> = listOf(
listOf(Color(0xFFFFB13C), Color(0xFFFF8E53)), // warm
listOf(Color(0xFF6DC0F5), Color(0xFF4B9BFF)), // blue
listOf(Color(0xFFA3D977), Color(0xFF6BBF59)), // green
listOf(Color(0xFFC792EA), Color(0xFF9B6FD6)), // purple
listOf(Color(0xFFFEE500), Color(0xFFE6CF00)), // kakao yellow (self / channel)
)
fun ktGradientForId(id: Int): List<Color> =
ktAvatarGradients[Math.floorMod(id, ktAvatarGradients.size)]
Wire it into both schemes. KakaoTalk is light-first; the dark scheme uses #1A1A1A (never true black) and a #1E2A33 chat backdrop. The yellow outbound bubble holds in both.
// 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 KTLight = lightColorScheme(
primary = KTColors.Yellow,
onPrimary = KTColors.Brown,
background = KTColors.Canvas,
onBackground = KTColors.TextPrimary,
surface = KTColors.SurfaceGray,
onSurface = KTColors.TextPrimary,
surfaceVariant = KTColors.InboundLight,
outline = KTColors.DividerLight,
error = KTColors.Error,
)
private val KTDark = darkColorScheme(
primary = KTColors.Yellow, // brand yellow holds on dark
onPrimary = KTColors.Brown,
background = KTColors.DarkCanvas,
onBackground = KTColors.DarkTextPrimary,
surface = KTColors.DarkSurface1,
onSurface = KTColors.DarkTextPrimary,
surfaceVariant = KTColors.InboundDark,
outline = KTColors.DarkDivider,
error = KTColors.Error,
)
@Composable
fun KTTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) KTDark else KTLight,
typography = KTTypography,
content = content,
)
2. Typography (M3)
KakaoTalk uses the platform system faces. On Android that’s Roboto for Latin and the system Korean face (Noto Sans CJK KR) for Hangul — do not bundle a decorative face. Big titles go FontWeight.Black (KakaoBig feel). sp honors the user’s font scale; keep generous line heights for Hangul.
// ui/theme/KTType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
object KTText {
val LargeTitle = TextStyle(FontFamily.Default, fontWeight = FontWeight.Black, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.4).sp)
val Screen = TextStyle(FontFamily.Default, fontWeight = FontWeight.Bold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
val NavTitle = TextStyle(FontFamily.Default, fontWeight = FontWeight.Black, fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.3).sp)
val ProfileName = TextStyle(FontFamily.Default, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 21.sp)
val Section = TextStyle(FontFamily.Default, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp)
val Body = TextStyle(FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp) // Hangul-friendly
val RowTitle = TextStyle(FontFamily.Default, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Status = TextStyle(FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val Sender = TextStyle(FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 14.sp)
val UnreadMark = TextStyle(FontFamily.Default, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 11.sp)
val BubbleTime = TextStyle(FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 10.sp, lineHeight = 10.sp)
val Tab = TextStyle(FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 10.sp)
val TabBadge = TextStyle(FontFamily.Default, fontWeight = FontWeight.Bold, fontSize = 10.sp, lineHeight = 10.sp)
val Button = TextStyle(FontFamily.Default, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
}
val KTTypography = Typography(
headlineLarge = KTText.LargeTitle,
titleLarge = KTText.NavTitle,
titleMedium = KTText.Section,
bodyMedium = KTText.Body,
labelSmall = KTText.Tab,
)
3. Signature Components
Rounded-Square Avatar (used everywhere — never a circle)
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun KTAvatar(gradient: List<Color>, initials: String, size: Dp = 44.dp) {
Box(
Modifier
.size(size)
.clip(RoundedCornerShape(size * 0.36f)) // rounded square, NOT a circle
.background(Brush.linearGradient(gradient)),
contentAlignment = Alignment.Center,
) {
Text(initials, color = Color.White,
fontSize = (size.value * 0.36f).sp, fontWeight = FontWeight.Bold)
}
}
Message Bubble + iconic side-docked unread mark
@Composable
fun KTMessageBubble(
text: String,
outbound: Boolean,
time: String = "",
unread: Int = 0, // recipients who haven't read (outbound only)
dark: Boolean = false,
) {
val bg = if (outbound) KTColors.Yellow else if (dark) KTColors.InboundDark else KTColors.InboundLight
val fg = if (outbound) KTColors.Brown else if (dark) KTColors.DarkTextPrimary else KTColors.TextPrimary
val metaColor = if (dark) Color.White.copy(alpha = 0.5f) else KTColors.TextSecondary
val shape = RoundedCornerShape(
topStart = if (outbound) 14.dp else 4.dp,
topEnd = if (outbound) 4.dp else 14.dp,
bottomStart = 14.dp, bottomEnd = 14.dp,
)
Row(
Modifier.widthIn(max = 270.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
val meta = @Composable {
Column(horizontalAlignment = if (outbound) Alignment.End else Alignment.Start) {
if (outbound && unread > 0) Text("$unread", style = KTText.UnreadMark, color = KTColors.Unread)
if (time.isNotEmpty()) Text(time, style = KTText.BubbleTime, color = metaColor)
}
}
if (outbound) meta() // meta on the INNER (leading) side of an outbound bubble
Box(Modifier.clip(shape).background(bg).padding(horizontal = 12.dp, vertical = 9.dp)) {
Text(text, style = KTText.Body.copy(fontWeight = if (outbound) FontWeight.Medium else FontWeight.Normal), color = fg)
}
if (!outbound) meta()
}
}
Inbound Sender Row (rounded-square avatar + name above a run)
@Composable
fun KTInboundRow(
senderName: String,
gradient: List<Color>,
initials: String,
bubbles: List<Pair<String, String>>, // (text, time)
dark: Boolean = false,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
KTAvatar(gradient, initials, 36.dp)
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
senderName, style = KTText.Sender,
color = if (dark) Color.White.copy(alpha = 0.78f) else KTColors.TextSecondary,
modifier = Modifier.padding(start = 2.dp),
)
bubbles.forEach { (t, time) -> KTMessageBubble(t, outbound = false, time = time, dark = dark) }
}
}
}
Friend Row (with optional now-playing badge)
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material3.Icon
@Composable
fun KTFriendRow(
name: String,
status: String,
nowPlaying: String?,
gradient: List<Color>,
initials: String,
) {
Row(
Modifier.fillMaxWidth().height(64.dp).padding(horizontal = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
KTAvatar(gradient, initials, 44.dp)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(name, style = KTText.RowTitle, color = KTColors.TextPrimary)
if (nowPlaying != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Filled.MusicNote, null, tint = KTColors.TextTertiary, modifier = Modifier.size(11.dp))
Text(nowPlaying, style = KTText.Status, color = KTColors.TextTertiary, maxLines = 1)
}
} else {
Text(status, style = KTText.Status, color = KTColors.TextSecondary, maxLines = 1)
}
}
}
}
Gifticon Card
import androidx.compose.material.icons.filled.CardGiftcard
import androidx.compose.foundation.clickable
@Composable
fun KTGifticonCard(
brand: String,
product: String,
isRecipient: Boolean,
onUse: () -> Unit,
dark: Boolean = false,
) {
Column(
Modifier
.width(188.dp)
.clip(RoundedCornerShape(16.dp))
.background(if (dark) KTColors.DarkSurface1 else KTColors.Canvas)
) {
Box(
Modifier.fillMaxWidth().height(120.dp)
.background(Brush.linearGradient(listOf(Color(0xFFFFE08A), Color(0xFFFEC84B)))),
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.CardGiftcard, null, tint = KTColors.Brown, modifier = Modifier.size(42.dp)) }
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(brand, style = KTText.BubbleTime.copy(fontSize = 11.sp), color = KTColors.TextTertiary)
Text(product, fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = if (dark) KTColors.DarkTextPrimary else KTColors.TextPrimary)
if (isRecipient) {
Box(
Modifier.fillMaxWidth().clip(RoundedCornerShape(50)).background(KTColors.Yellow)
.clickable { onUse() }.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) { Text("사용하기", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = KTColors.Brown) }
}
}
}
}
Composer
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.outlined.SentimentSatisfied
import androidx.compose.material3.IconButton
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@Composable
fun KTComposer(
value: String,
onValueChange: (String) -> Unit,
onMenu: () -> Unit,
onEmoticon: () -> Unit,
onSend: () -> Unit,
dark: Boolean = false,
) {
val haptics = LocalHapticFeedback.current
Row(
Modifier.fillMaxWidth()
.background(if (dark) KTColors.DarkCanvas else KTColors.Canvas)
.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
IconButton(onClick = onMenu) {
Icon(Icons.Filled.Add, "Menu", tint = KTColors.TextSecondary, modifier = Modifier.size(24.dp))
}
Row(
Modifier.weight(1f).heightIn(min = 38.dp).clip(RoundedCornerShape(50))
.background(if (dark) KTColors.InboundDark else KTColors.SurfaceGray)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.weight(1f)) {
BasicTextField(
value = value, onValueChange = onValueChange,
textStyle = KTText.Body.copy(color = if (dark) KTColors.DarkTextPrimary else KTColors.TextPrimary),
)
}
if (value.isNotBlank()) {
IconButton(onClick = {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove); onSend()
}, modifier = Modifier.size(24.dp)) {
Icon(Icons.Filled.ArrowUpward, "Send", tint = KTColors.Brown,
modifier = Modifier.size(20.dp).clip(RoundedCornerShape(50)).background(KTColors.Yellow))
}
}
}
IconButton(onClick = onEmoticon) {
Icon(Icons.Outlined.SentimentSatisfied, "Emoticons", tint = KTColors.TextSecondary, modifier = Modifier.size(24.dp))
}
}
}
4. Navigation
KakaoTalk is a 5-tab super-app; on Android model it as a NavigationBar. Active is the primary text color with a filled icon — no Material tint pill, no yellow tint.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun KTBottomBar(selected: Int, onSelect: (Int) -> Unit, chatUnread: Int = 0) {
NavigationBar(containerColor = KTColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
Triple("Friends", Icons.Filled.People, 0),
Triple("Chats", Icons.Filled.Chat, chatUnread),
Triple("Open", Icons.Filled.Public, 0),
Triple("Shopping", Icons.Filled.ShoppingBag, 0),
Triple("More", Icons.Filled.Menu, 0),
)
items.forEachIndexed { i, (label, icon, badge) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = {
if (badge > 0) {
BadgedBox(badge = { Badge(containerColor = KTColors.Badge) { Text("$badge", style = KTText.TabBadge, color = Color.White) } }) {
Icon(icon, label, modifier = Modifier.size(24.dp))
}
} else Icon(icon, label, modifier = Modifier.size(24.dp))
},
label = { Text(label, style = KTText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = KTColors.TextPrimary, // primary text, NOT yellow
selectedTextColor = KTColors.TextPrimary,
unselectedIconColor = Color(0xFF777777),
unselectedTextColor = Color(0xFF777777),
indicatorColor = Color.Transparent,
),
)
}
}
}
The Friends list is a LazyColumn of KTFriendRows with section headers (“Friends · N”, “Channels”) and a my-profile banner pinned at the top. The chat screen sits over a Box background of ChatBgDark/ChatBgLight. The + composer menu is a ModalBottomSheet action grid (album, camera, gifticon, pay/송금, location, schedule, voice); the emoticon keyboard is another ModalBottomSheet with character tabs.
5. Motion
KakaoTalk motion is friendly but restrained — short ease-outs, a soft pop for stickers.
| Moment | Compose recipe |
|---|---|
| Message send | AnimatedVisibility slideInVertically { it } + fadeIn(tween(200)) from the composer |
| Unread countdown | re-key the Text and Crossfade(targetState = unread, tween(200)) |
| New inbound message | fadeIn(tween(180)) + 6dp slideInVertically |
| Emoticon send | sticker animateFloatAsState 0.7 → 1.0 spring(MediumBouncy, StiffnessMediumLow); then play the animated loop |
| Action sheet / emoticon keyboard | ModalBottomSheet default ~280ms slide-up + scrim |
| Tab switch | instant content swap; icon tint animateColorAsState(tween(120)); red badge scaleIn |
| Gifticon arrival | card slideInVertically + fadeIn; subtle sparkle accent first time |
| Pull-to-refresh | PullToRefreshContainer with a Kakao-yellow indicator |
// Emoticon sticker pop — the friendly KakaoTalk motion
val scale by animateFloatAsState(
targetValue = if (visible) 1f else 0.7f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow),
label = "stickerPop",
)
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) for the light tick on send; HapticFeedbackType.LongPress for the soft impact on emoticon send. Tab changes use a selection tick via LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK).
6. Icons
KakaoTalk’s iconography is system-standard; the closest first-party set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Friends (tab) | person.2.fill |
Icons.Filled.People |
| Chats (tab) | bubble.left.fill |
Icons.Filled.Chat |
| Open Chat (tab) | globe |
Icons.Filled.Public |
| Shopping (tab) | bag.fill |
Icons.Filled.ShoppingBag |
| More (tab) | line.3.horizontal |
Icons.Filled.Menu |
| Back | chevron.left |
Icons.AutoMirrored.Filled.ArrowBack |
| Search | magnifyingglass |
Icons.Filled.Search |
| Add friend | person.badge.plus |
Icons.Filled.PersonAddAlt |
| Settings / menu | ellipsis |
Icons.Filled.MoreHoriz |
| Composer menu (+) | plus |
Icons.Filled.Add |
| Emoticon keyboard | face.smiling |
Icons.Outlined.SentimentSatisfied |
| Send | arrow.up.circle.fill |
Icons.Filled.ArrowUpward |
| Now-playing badge | music.note |
Icons.Filled.MusicNote |
| Gifticon | gift.fill |
Icons.Filled.CardGiftcard |
| Album / photo | photo.on.rectangle |
Icons.Filled.Image |
| Camera | camera.fill |
Icons.Filled.PhotoCamera |
| Pay / 송금 | wonsign.circle.fill |
Icons.Filled.AccountBalanceWallet |
| Location | mappin.and.ellipse |
Icons.Filled.LocationOn |
| Schedule | calendar |
Icons.Filled.Event |
| Voice | mic.fill |
Icons.Filled.Mic |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion +
ModalBottomSheetare comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(). Nav bars draw under the status bar — applyModifier.statusBarsPadding()to their content; over the chat thread set status-icon contrast to match the backdrop (dark icons on the light blue-gray, light icons on#1E2A33). The composer must sit above the IME — useModifier.imePadding(). - No brand font: KakaoTalk is system-font on purpose — use
FontFamily.Default(Roboto + Noto Sans CJK KR for Hangul). KeeplineHeightgenerous (fontSize × ~1.33) on body/status so Hangul breathes; never tighten below ~1.3. - Pin layout-sensitive text: keep tab labels (10sp), the unread mark (11sp), bubble times (10sp), and tab badges (10sp) fixed by deriving from
dporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f))— bubble run-grouping is alignment-sensitive. - TalkBack: label the unread mark with its meaning (“{n} people have not read this message”); label outbound bubbles “You said: {text}”; inbound “{sender} said: {text}”; label the gifticon “{brand} {product} gift, double-tap to use”; expose the
+action sheet + emoticon keyboard as labeled buttons. - Touch targets: Material guidance is 48.dp — give the 24dp tab/composer icons a 48.dp hit area via padding; friend rows are 64.dp (full-row tappable); the gifticon “Use” pill is ≥ 36.dp.
- Contrast:
#3C1E1Eon#FEE500passes WCAG AA comfortably;#6A6A6Aon#FFFFFFpasses AA for metadata; verify any text drawn over the#B2C7DAlight thread backdrop. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, replace the emoticon scale-pop and bubble slide-in with a plainCrossfade; keep the unread-count crossfade (it conveys state). - Dark mode: invert via the
Dark*palette —#1A1A1Acanvas (NOT true black) +#1E2A33chat backdrop;#191919text becomes#EDEDED, inbound bubbles#2E2E2E. Outbound bubbles stay Kakao Yellow#FEE500with Kakao Brown#3C1E1Etext regardless of scheme. The gifticon card switches to a#242424fill and floating panels gain a 1dp#333333border as the dark elevation cue. Do not enable Material YoudynamicColorScheme()— KakaoTalk’s yellow-and-brown identity must hold regardless of wallpaper. Active tab is the primary text color, never a yellow tint.