coderef

WhatsApp

WhatsApp's iOS app is a utility-first messenger dressed in a narrow, signature palette of green, teal, and white. The interface is aggressively native: tabbed bottom navigation, stock SF Pro typography, SF Symbols throughout, iOS-native…
DownloadRaw
WhatsApp

Build with the WhatsApp look.

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

Palette

At a glance

Theme
light
Typefaces
SF Pro Text, SF Pro Display, SF Mono
Sections
8
Length
4,345 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · WhatsApp

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

WhatsApp (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports WhatsApp’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, 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 (WhatsApp’s mint-leaf outgoing bubble, single-green verb color, blue double-check read receipts, doodle wallpaper) while making everything idiomatic Android — NavigationBar instead of a UITabBar, a translucent Surface instead of .regularMaterial blur, sp/dp instead of pt. The app is light-first with a blue-black dark mode; both are wired below.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for contact avatars and media. No color extraction — WhatsApp’s palette is fixed brand chrome.

1. Color Tokens

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

object WhatsAppColors {
    // Brand
    val Green        = Color(0xFF25D366)
    val GreenPressed = Color(0xFF1EBE5D)
    val Teal         = Color(0xFF075E54)
    val MidTeal      = Color(0xFF128C7E)
    val DarkTeal     = Color(0xFF054D44)

    // Chat bubbles
    val OutgoingLight = Color(0xFFD9FDD3) // the iconic mint-leaf
    val OutgoingDark  = Color(0xFF005C4B)
    val IncomingLight = Color(0xFFFFFFFF)
    val IncomingDark  = Color(0xFF1F2C34)
    val ReplyStrip    = Color(0xFF06CF9C)

    // Canvas & surfaces
    val WallpaperLight = Color(0xFFECE5DD) // cream doodle canvas
    val WallpaperDark  = Color(0xFF0B141A)
    val CanvasLight    = Color(0xFFFFFFFF)
    val CanvasDark     = Color(0xFF111B21) // blue-black, NOT pure black
    val Surface1Light  = Color(0xFFF7F8FA)
    val Surface1Dark   = Color(0xFF202C33)
    val Surface2Dark   = Color(0xFF2A3942)
    val DividerLight   = Color(0xFFE9EDEF)
    val DividerDark    = Color(0xFF222D34)
    val NavTintLight   = Color(0xFFF0F2F5) // chat-screen top nav / search pill

    // Text
    val TextPrimary       = Color(0xFF111B21)
    val TextSecondary     = Color(0xFF667781)
    val TextTertiary      = Color(0xFF8696A0)
    val TextPrimaryDark   = Color(0xFFE9EDEF)
    val TextSecondaryDark = Color(0xFF8696A0)

    // Semantic
    val ReadBlue   = Color(0xFF53BDEB) // double blue ticks — most load-bearing color
    val SentGray   = Color(0xFF8696A0)
    val ErrorRed   = Color(0xFFF15C6D)
    val EncBanner  = Color(0xFFFFF5C4) // end-to-end encryption pill (light)
}

WhatsApp is light-first with a true dark mode. Provide both schemes; brand greens keep the same hex in either. Active tab tint is brand green (unlike iOS Messages), so primary carries it.

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

private val WhatsAppLight = lightColorScheme(
    primary        = WhatsAppColors.Green,
    onPrimary      = Color.White,
    background     = WhatsAppColors.CanvasLight,
    onBackground   = WhatsAppColors.TextPrimary,
    surface        = WhatsAppColors.CanvasLight,
    onSurface      = WhatsAppColors.TextPrimary,
    surfaceVariant = WhatsAppColors.Surface1Light,
    outline        = WhatsAppColors.DividerLight,
    error          = WhatsAppColors.ErrorRed,
)

private val WhatsAppDark = darkColorScheme(
    primary        = WhatsAppColors.Green,
    onPrimary      = Color.White,
    background      = WhatsAppColors.CanvasDark,
    onBackground   = WhatsAppColors.TextPrimaryDark,
    surface        = WhatsAppColors.Surface1Dark,
    onSurface      = WhatsAppColors.TextPrimaryDark,
    surfaceVariant = WhatsAppColors.Surface2Dark,
    outline        = WhatsAppColors.DividerDark,
    error          = WhatsAppColors.ErrorRed,
)

@Composable
fun WhatsAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (darkTheme) WhatsAppDark else WhatsAppLight,
    typography = WhatsAppTypography,
    content = content,
)

2. Typography

WhatsApp ships no proprietary typeface — it defers entirely to the system font. On Android that is Roboto (FontFamily.Default), the closest analog to iOS’s SF Pro. Do not bundle a custom font; the whole point of WhatsApp’s type system is OS deference and full font-scale support.

// ui/theme/WhatsAppType.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

// WhatsApp uses the system face — Roboto on Android (SF Pro analog).
private val WASans = FontFamily.Default

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object WhatsAppText {
    val LargeTitle      = TextStyle(WASans, fontWeight = FontWeight.Bold,     fontSize = 34.sp, lineHeight = 38.sp, letterSpacing = 0.37.sp)
    val ScreenTitle     = TextStyle(WASans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 20.sp, letterSpacing = (-0.4).sp)
    val ContactName     = TextStyle(WASans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.4).sp)
    val MessagePreview  = TextStyle(WASans, fontWeight = FontWeight.Normal,   fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.2).sp)
    val BubbleBody      = TextStyle(WASans, fontWeight = FontWeight.Normal,   fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.4).sp)
    val GroupSender     = TextStyle(WASans, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 16.sp, letterSpacing = (-0.1).sp)
    val TimestampList   = TextStyle(WASans, fontWeight = FontWeight.Normal,   fontSize = 12.sp, lineHeight = 14.sp)
    val TimestampBubble = TextStyle(WASans, fontWeight = FontWeight.Normal,   fontSize = 11.sp, lineHeight = 11.sp)
    val SectionHeader   = TextStyle(WASans, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 16.sp, letterSpacing = (-0.1).sp)
    val SystemMessage   = TextStyle(WASans, fontWeight = FontWeight.Medium,   fontSize = 12.sp, lineHeight = 16.sp)
    val TabLabel        = TextStyle(WASans, fontWeight = FontWeight.Medium,   fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
    val Button          = TextStyle(WASans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 17.sp, letterSpacing = (-0.4).sp)
    val InputPlaceholder = TextStyle(WASans, fontWeight = FontWeight.Normal,  fontSize = 16.sp, lineHeight = 21.sp, letterSpacing = (-0.3).sp)
    val SettingsRow     = TextStyle(WASans, fontWeight = FontWeight.Normal,   fontSize = 17.sp, lineHeight = 21.sp, letterSpacing = (-0.4).sp)
    val SettingsDetail  = TextStyle(WASans, fontWeight = FontWeight.Normal,   fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.2).sp)
}

// Map onto Material 3 slots so stock components inherit the ramp
val WhatsAppTypography = Typography(
    headlineLarge = WhatsAppText.LargeTitle,
    titleLarge    = WhatsAppText.ScreenTitle,
    titleMedium   = WhatsAppText.ContactName,
    bodyLarge     = WhatsAppText.BubbleBody,
    bodyMedium    = WhatsAppText.MessagePreview,
    labelSmall    = WhatsAppText.TabLabel,
)

3. Signature Components

Send / Mic Morphing Button

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.foundation.layout.Box
import androidx.compose.ui.unit.dp

@Composable
fun WASendButton(
    hasText: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(
        targetValue = if (pressed) 0.92f else 1f,
        animationSpec = spring(dampingRatio = 0.7f, stiffness = 600f),
        label = "sendScale",
    )
    val haptics = LocalHapticFeedback.current

    Box(
        modifier = modifier
            .size(36.dp)
            .scale(scale)
            .clip(CircleShape)
            .background(if (pressed) WhatsAppColors.GreenPressed else WhatsAppColors.Green)
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS soft impact
                onClick()
            },
        contentAlignment = Alignment.Center,
    ) {
        // No SymbolEffect on Android — crossfade the glyph instead (see §6)
        Icon(
            imageVector = if (hasText) Icons.Filled.Send else Icons.Filled.Mic,
            contentDescription = if (hasText) "Send message" else "Record voice message",
            tint = Color.White,
            modifier = Modifier.size(18.dp),
        )
    }
}

Chat Bubble (Outgoing — asymmetric tail + tick row)

Compose has no UnevenRoundedRectangle; use RoundedCornerShape with the bottom-end corner zeroed for the outgoing tail nub.

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.ui.text.style.TextAlign

enum class ReadState { Sent, Delivered, Read }

@Composable
fun WAOutgoingBubble(
    text: String,
    timestamp: String,
    readState: ReadState,
    modifier: Modifier = Modifier,
) {
    Row(modifier.fillMaxWidth().padding(end = 8.dp)) {
        Spacer(Modifier.weight(0.2f)) // 80% max bubble width
        Column(
            modifier = Modifier
                .weight(0.8f, fill = false)
                .clip(
                    RoundedCornerShape(
                        topStart = 12.dp, topEnd = 12.dp,
                        bottomStart = 12.dp, bottomEnd = 0.dp, // tail nub
                    )
                )
                .background(WhatsAppColors.OutgoingLight)
                .padding(horizontal = 12.dp, vertical = 8.dp),
            horizontalAlignment = Alignment.End,
        ) {
            Text(text, style = WhatsAppText.BubbleBody, color = WhatsAppColors.TextPrimary)
            Spacer(Modifier.height(2.dp))
            Row(verticalAlignment = Alignment.CenterVertically) {
                Text(
                    timestamp,
                    style = WhatsAppText.TimestampBubble,
                    color = WhatsAppColors.TextSecondary,
                )
                Spacer(Modifier.width(3.dp))
                TickRow(readState)
            }
        }
    }
}

@Composable
private fun TickRow(state: ReadState) {
    val tint = if (state == ReadState.Read) WhatsAppColors.ReadBlue else WhatsAppColors.SentGray
    val count = if (state == ReadState.Sent) 1 else 2
    // Overlap the two checks by -4.dp so they read as one glyph
    Row(horizontalArrangement = Arrangement.spacedBy((-4).dp)) {
        repeat(count) {
            Icon(
                imageVector = Icons.Filled.Done,
                contentDescription = null,
                tint = tint,
                modifier = Modifier.size(13.dp),
            )
        }
    }
}

Chat List Row (with status ring)

import coil.compose.AsyncImage
import androidx.compose.foundation.border
import androidx.compose.ui.text.style.TextOverflow

@Composable
fun WAChatListRow(
    avatarUrl: String,
    name: String,
    preview: String,
    timestamp: String,
    unreadCount: Int,
    hasStatusRing: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()

    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(72.dp)
            .background(if (pressed) WhatsAppColors.Surface1Light else Color.Transparent)
            .clickable(interaction, indication = null, onClick = onClick)
            .padding(horizontal = 16.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Box(contentAlignment = Alignment.Center) {
            if (hasStatusRing) {
                Box(
                    Modifier
                        .size(54.dp)
                        .border(2.5.dp, WhatsAppColors.Green, CircleShape)
                )
            }
            AsyncImage(
                model = avatarUrl,
                contentDescription = null,
                modifier = Modifier.size(48.dp).clip(CircleShape),
            )
        }
        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
            Text(name, style = WhatsAppText.ContactName, color = WhatsAppColors.TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
            Text(preview, style = WhatsAppText.MessagePreview, color = WhatsAppColors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
        }
        Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
            Text(
                timestamp,
                style = WhatsAppText.TimestampList,
                color = if (unreadCount > 0) WhatsAppColors.Green else WhatsAppColors.TextSecondary,
            )
            if (unreadCount > 0) {
                Box(
                    Modifier
                        .clip(CircleShape)
                        .background(WhatsAppColors.Green)
                        .padding(horizontal = 7.dp, vertical = 2.dp),
                ) {
                    Text("$unreadCount", style = WhatsAppText.TimestampList, color = Color.White)
                }
            }
        }
    }
}

4. Voice Waveform Bubble + Blue Double-Check Read Receipts

WhatsApp’s two defining interactive systems. The voice-message waveform fills its bars green left-to-right synced to playback, with a soft haptic tick as playback crosses each amplitude peak. The read receipt crossfades the overlapping checkmarks gray → blue when the recipient opens the message.

import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.delay
import kotlin.math.max

@Composable
fun WAVoiceWaveformBubble(
    duration: String,
    amplitudes: List<Float>, // normalized 0f..1f, ~48 samples
    modifier: Modifier = Modifier,
) {
    var isPlaying by remember { mutableStateOf(false) }
    var progress by remember { mutableFloatStateOf(0f) } // 0f..1f played fraction
    val haptics = LocalHapticFeedback.current

    // Advance playback while playing; soft tick on amplitude peaks
    LaunchedEffect(isPlaying) {
        if (!isPlaying) return@LaunchedEffect
        while (isPlaying && progress < 1f) {
            delay(80)
            val prevBar = (progress * amplitudes.size).toInt()
            progress = (progress + 0.02f).coerceAtMost(1f)
            val newBar = (progress * amplitudes.size).toInt()
            if (newBar != prevBar && newBar < amplitudes.size && amplitudes[newBar] > 0.85f) {
                haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // subtle peak tick
            }
        }
        if (progress >= 1f) isPlaying = false
    }

    Row(
        modifier = modifier
            .clip(
                RoundedCornerShape(
                    topStart = 12.dp, topEnd = 12.dp,
                    bottomStart = 12.dp, bottomEnd = 0.dp,
                )
            )
            .background(WhatsAppColors.OutgoingLight)
            .padding(horizontal = 12.dp, vertical = 10.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(10.dp),
    ) {
        Box(
            Modifier
                .size(36.dp)
                .clip(CircleShape)
                .background(WhatsAppColors.Green)
                .clickable { isPlaying = !isPlaying },
            contentAlignment = Alignment.Center,
        ) {
            Icon(
                imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
                contentDescription = if (isPlaying) "Pause" else "Play voice message",
                tint = Color.White,
                modifier = Modifier.size(16.dp),
            )
        }
        Row(
            modifier = Modifier.weight(1f).height(24.dp),
            horizontalArrangement = Arrangement.spacedBy(2.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            amplitudes.forEachIndexed { i, amp ->
                val played = i.toFloat() / amplitudes.size <= progress
                Box(
                    Modifier
                        .width(2.dp)
                        .height(max(4f, amp * 20f).dp)
                        .clip(CircleShape)
                        .background(if (played) WhatsAppColors.Green else WhatsAppColors.TextTertiary)
                )
            }
        }
        Text(duration, style = WhatsAppText.TimestampBubble, color = WhatsAppColors.TextTertiary)
    }
}

/** Read receipt that crossfades gray → blue when [readState] flips to Read. */
@Composable
fun AnimatedTickRow(readState: ReadState) {
    val targetTint = if (readState == ReadState.Read) WhatsAppColors.ReadBlue else WhatsAppColors.SentGray
    val tint by androidx.compose.animation.animateColorAsState(
        targetValue = targetTint,
        animationSpec = tween(200, easing = LinearEasing),
        label = "tickColor",
    )
    val count = if (readState == ReadState.Sent) 1 else 2
    Row(horizontalArrangement = Arrangement.spacedBy((-4).dp)) {
        repeat(count) {
            Icon(Icons.Filled.Done, contentDescription = null, tint = tint, modifier = Modifier.size(13.dp))
        }
    }
}

5. Bottom Navigation (Tab Bar)

Use Material 3 NavigationBar. WhatsApp’s iOS tab bar is .regularMaterial blur over canvas; Android has no first-class live blur, so use a 92%-opaque canvas surface. Active tint is brand green — unlike iOS Messages, WhatsApp uses #25D366 as the selected tab tint. The “new chat” FAB is not in the tab bar — it lives as a nav-bar trailing action on the Chats screen.

import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.DonutLarge
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults

@Composable
fun WABottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = WhatsAppColors.CanvasLight.copy(alpha = 0.92f),
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Updates"     to Icons.Outlined.DonutLarge,
            "Calls"       to Icons.Filled.Phone,
            "Communities" to Icons.Filled.Groups,
            "Chats"       to Icons.Filled.Chat,
            "Settings"    to Icons.Filled.Settings,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
                label = { Text(label, style = WhatsAppText.TabLabel) },
                alwaysShowLabel = true,
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = WhatsAppColors.Green, // brand-green active
                    selectedTextColor   = WhatsAppColors.Green,
                    unselectedIconColor = WhatsAppColors.TextTertiary,
                    unselectedTextColor = WhatsAppColors.TextTertiary,
                    indicatorColor      = Color.Transparent, // no Material pill — WhatsApp has none
                ),
            )
        }
    }
}

The compose bar sits in the Scaffold bottomBar slot on the chat screen (not the tab bar): a Surface(color = Surface1Light) row holding a pill TextField plus the WASendButton.

6. Motion

Moment Compose recipe
Send button press animateFloatAsState 1 → 0.92 with spring(dampingRatio = 0.7f), HapticFeedbackType.LongPress
Send/mic glyph morph Crossfade(targetState = hasText) (no iOS symbolEffect)
Read receipt → blue animateColorAsState gray → #53BDEB over 200ms tween
Voice playback fill bars recolor as progress advances; HapticFeedbackType.TextHandleMove on amplitude peaks
Mic record hold outer ring scale = 1f + amplitude * 0.2f driven by decibel state
Swipe-to-reply Modifier.pointerInput horizontal drag; HapticFeedbackType.TextHandleMove at threshold
Reaction tap Animatable keyframes 1 → 1.2 → 1; HapticFeedbackType.LongPress
// Send/mic glyph morph — Android's analog to iOS .contentTransition(.symbolEffect(.replace))
import androidx.compose.animation.Crossfade

@Composable
fun MorphingSendGlyph(hasText: Boolean) {
    Crossfade(targetState = hasText, label = "sendMorph") { showSend ->
        Icon(
            imageVector = if (showSend) Icons.Filled.Send else Icons.Filled.Mic,
            contentDescription = if (showSend) "Send message" else "Record voice message",
            tint = Color.White,
            modifier = Modifier.size(18.dp),
        )
    }
}

Haptics: prefer LocalHapticFeedback. For the soft per-peak tick during voice playback, HapticFeedbackType.TextHandleMove is the lightest stock effect; for richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(8, 40) to approximate iOS’s .soft impact.

7. Icons

WhatsApp uses near-stock iconography; the closest first-party set is androidx.compose.material:material-icons-extended. The Updates “circle.dashed” glyph has no exact Material twin — ship it (and the WhatsApp logomark) as a vector drawable loaded via ImageVector.vectorResource(R.drawable.…).

Purpose SF Symbol (iOS) Material Icon (Compose)
Send paperplane.fill Icons.Filled.Send
Microphone mic.fill Icons.Filled.Mic
Attach paperclip Icons.Filled.AttachFile
Camera camera.fill Icons.Filled.PhotoCamera
Emoji face.smiling Icons.Filled.EmojiEmotions
Read ticks checkmark Icons.Filled.Done
Phone (answer) phone.fill Icons.Filled.Call
Phone (decline) phone.down.fill Icons.Filled.CallEnd
Video video.fill Icons.Filled.Videocam
Lock (E2E) lock.fill Icons.Filled.Lock
Chats tab message.fill Icons.Filled.Chat
Calls tab phone.fill Icons.Filled.Phone
Communities tab person.3.fill Icons.Filled.Groups
Updates tab circle.dashed Icons.Outlined.DonutLarge (or vector drawable)
Settings tab gearshape.fill Icons.Filled.Settings
New chat square.and.pencil Icons.Filled.Edit
Search magnifyingglass Icons.Filled.Search
Ellipsis ellipsis Icons.Filled.MoreVert
Play / Pause (voice) play.fill / pause.fill Icons.Filled.PlayArrow / Icons.Filled.Pause

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; modern motion is comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity. The chat wallpaper should full-bleed behind the system bars; apply Scaffold insets so the compose bar clears the gesture nav and the bottom tab bar clears the home indicator.
  • Font scaling: sp honors the user’s font scale automatically — keep it on bubble body, contact names, and previews so WhatsApp’s Dynamic-Type-first promise carries over. Pin inline timestamps (11sp in-bubble, 12sp list) and the 10sp tab labels by deriving them from a fixed-fontScale LocalDensity so the inline time/tick row never breaks layout.
  • TalkBack: label the send button dynamically ("Send message" vs "Record voice message"); merge bubble text + timestamp + read-state into one node with Modifier.semantics(mergeDescendants = true) and announce read state as content ("Read" / "Delivered"). Do not hide the end-to-end encryption banner — expose it as static text.
  • Touch targets: Material guidance is 48.dp minimum. The 36.dp send/mic and play circles need a 48.dp hit area via surrounding padding even though the glyph is small; chat-list rows at 72.dp are well clear.
  • RTL: the asymmetric bubble tail must flip for Arabic/Hebrew — use RoundedCornerShape with topStart/bottomEnd (logical corners) rather than absolute left/right so Compose mirrors it automatically. Test with a forced RTL locale.
  • Contrast: #D9FDD3 outgoing bubble + #111B21 text far exceeds WCAG AA. Validate the dark-mode #005C4B bubble + #E9EDEF text (passes AA) and the #8696A0 secondary text on #FFFFFF — the latter is borderline at the 11–12sp timestamp size; bump toward #667781 if your build targets strict compliance.
  • Dynamic color: do not enable Material You dynamicColorScheme() — WhatsApp’s brand requires the fixed green verb color and the mint-leaf bubble regardless of wallpaper. Keep the hand-built light/dark schemes above.
All mobile designs →

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