Character.AI (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Character.AI’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the asymmetric tuck bubble + roleplay parsing + streaming, 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 (Character.AI’s near-black canvas, the chat as the product, the 4dp tuck bubble that differentiates speakers by shape not color, italic
*roleplay*, the rationed accent blue) while making everything idiomatic Android —NavigationBarinstead of a UITabBar,LazyColumnfor the stream,AnnotatedStringfor roleplay,dp/spinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for character art. No Material You — Character.AI’s calm blue is a fixed brand accent and must not be replaced by wallpaper extraction. Dark-first; a light scheme is provided.
1. Color Tokens
// ui/theme/CaiColors.kt
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
object CaiColors {
// Canvas & Surfaces (Dark — primary)
val Canvas = Color(0xFF0F0F10) // near-black neutral — NOT pure black
val Surface1 = Color(0xFF161618)
val Surface2 = Color(0xFF1E1E21)
val BubbleAI = Color(0xFF1C1C1F)
val BubbleUser = Color(0xFF26262A)
val Divider = Color(0xFF2A2A2E)
// Canvas & Surfaces (Light)
val CanvasLight = Color(0xFFFFFFFF)
val BubbleAILight = Color(0xFFF1F1F3)
val BubbleUserLight = Color(0xFFE4E8F2)
val DividerLight = Color(0xFFE6E6EA)
// Brand
val Accent = Color(0xFF3A7BFD) // rationed: send/CTA/links/active tab — never a bubble
val AccentPress = Color(0xFF2E63D6)
val AccentSoft = Color(0xFF1B2A4A)
val AccentSoftTx = Color(0xFF9FC0FF)
val Lilac = Color(0xFF9D7BFF) // avatar gradient only — NOT a control color
// Text
val TextPrimary = Color(0xFFECECEE)
val TextSecondary = Color(0xFF9A9AA2)
val TextTertiary = Color(0xFF66666E)
val OnAccent = Color(0xFFFFFFFF)
// Semantic
val Success = Color(0xFF4ED99A)
val Error = Color(0xFFFF6B6B)
// Character avatar gradients — the single warm focal point
fun avatarBlue(r: Float) = Brush.radialGradient(
listOf(Color(0xFF6FA0FF), Accent, Color(0xFF274FB0)),
center = Offset(r * 0.36f, r * 0.30f), radius = r
)
fun avatarLilac(r: Float) = Brush.radialGradient(
listOf(Color(0xFFC7A8FF), Lilac, Color(0xFF6A47C9)),
center = Offset(r * 0.36f, r * 0.30f), radius = r
)
}
// 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 CaiDark = darkColorScheme(
primary = CaiColors.Accent,
onPrimary = CaiColors.OnAccent,
background = CaiColors.Canvas,
onBackground = CaiColors.TextPrimary,
surface = CaiColors.Surface1,
onSurface = CaiColors.TextPrimary,
surfaceVariant = CaiColors.Surface2,
outline = CaiColors.Divider,
error = CaiColors.Error,
)
private val CaiLight = lightColorScheme(
primary = CaiColors.Accent,
onPrimary = CaiColors.OnAccent,
background = CaiColors.CanvasLight,
onBackground = Color(0xFF16161A),
surface = Color(0xFFF4F4F6),
onSurface = Color(0xFF16161A),
surfaceVariant = CaiColors.BubbleAILight,
outline = CaiColors.DividerLight,
error = CaiColors.Error,
)
@Composable
fun CaiTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) CaiDark else CaiLight,
typography = CaiTypography,
content = content,
)
2. Typography
Character.AI pairs Sora (identity: titles, names, buttons) with Inter (reading comfort: all message text, descriptions). Both SIL OFL — drop the TTFs (incl. Inter italic) in res/font/. Weights 400–700.
// ui/theme/CaiType.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 Sora = FontFamily(
Font(R.font.sora_semibold, FontWeight.SemiBold),
Font(R.font.sora_bold, FontWeight.Bold),
)
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_italic, FontWeight.Normal, FontStyle.Italic),
Font(R.font.inter_medium, FontWeight.Medium),
)
object CaiText {
val Display = TextStyle(Sora, fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 38.sp, letterSpacing = (-0.5).sp)
val ScreenTitle = TextStyle(Sora, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 30.sp, letterSpacing = (-0.3).sp)
val GreetingName = TextStyle(Sora, fontWeight = FontWeight.SemiBold, fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
val HeaderName = TextStyle(Sora, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 22.sp)
val CardName = TextStyle(Sora, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Button = TextStyle(Sora, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 15.sp)
val Tab = TextStyle(Sora, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.2.sp)
val Message = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 23.sp)
val Roleplay = TextStyle(Inter, fontWeight = FontWeight.Normal, fontStyle = FontStyle.Italic, fontSize = 15.sp, lineHeight = 23.sp)
val CardDesc = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 19.sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 25.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp)
}
val CaiTypography = Typography(
headlineLarge = CaiText.Display,
headlineMedium = CaiText.ScreenTitle,
titleMedium = CaiText.HeaderName,
bodyMedium = CaiText.Message,
labelSmall = CaiText.Tab,
)
3. Signature Components
Asymmetric Tuck Bubble (with roleplay AnnotatedString)
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
private const val TUCK = 4
private const val ROUND = 18
// AI tucks the TOP-START corner (points back at the avatar); user mirrors to TOP-END.
fun bubbleShape(isAi: Boolean) = RoundedCornerShape(
topStart = (if (isAi) TUCK else ROUND).dp,
topEnd = (if (!isAi) TUCK else ROUND).dp,
bottomEnd = ROUND.dp,
bottomStart = ROUND.dp,
)
// `*roleplay actions*` → italic + secondary. Load-bearing Character.AI signature.
fun parseMessage(raw: String): AnnotatedString = buildAnnotatedString {
raw.split("*").forEachIndexed { i, seg ->
if (seg.isEmpty()) return@forEachIndexed
if (i % 2 == 1) {
withStyle(SpanStyle(color = CaiColors.TextSecondary, fontStyle = FontStyle.Italic)) { append(seg) }
} else {
withStyle(SpanStyle(color = CaiColors.TextPrimary)) { append(seg) }
}
}
}
@Composable
fun MessageBubble(isAi: Boolean, text: String, avatar: Brush) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 14.dp),
horizontalArrangement = if (isAi) Arrangement.Start else Arrangement.End,
verticalAlignment = Alignment.Top,
) {
if (isAi) {
Box(Modifier.padding(top = 2.dp, end = 9.dp).size(26.dp).clip(CircleShape).background(avatar))
}
Box(
Modifier
.widthIn(max = 320.dp) // ≈ 86% on phone; tune to screen width
.clip(bubbleShape(isAi))
.background(if (isAi) CaiColors.BubbleAI else CaiColors.BubbleUser)
.then(if (isAi) Modifier.border(1.dp, CaiColors.Divider, bubbleShape(true)) else Modifier)
.padding(vertical = 11.dp, horizontal = 14.dp),
) {
Text(parseMessage(text), style = CaiText.Message)
}
}
}
Typing Indicator
import androidx.compose.animation.core.*
@Composable
fun TypingIndicator(avatar: Brush) {
val t = rememberInfiniteTransition(label = "typing")
Row(
Modifier.fillMaxWidth().padding(horizontal = 14.dp),
verticalAlignment = Alignment.Top,
) {
Box(Modifier.padding(top = 2.dp, end = 9.dp).size(26.dp).clip(CircleShape).background(avatar))
Row(
Modifier
.clip(bubbleShape(true))
.background(CaiColors.BubbleAI)
.border(1.dp, CaiColors.Divider, bubbleShape(true))
.padding(vertical = 14.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(3) { i ->
val a by t.animateFloat(
0.4f, 1f,
infiniteRepeatable(tween(600, delayMillis = i * 160), RepeatMode.Reverse),
label = "dot$i",
)
Box(Modifier.size(6.dp).clip(CircleShape).background(CaiColors.TextTertiary.copy(alpha = a)))
}
}
}
}
Greeting Header
@Composable
fun GreetingHeader(avatar: Brush, name: String, tagline: String) {
var shown by remember { mutableStateOf(false) }
val p by animateFloatAsState(if (shown) 1f else 0f, tween(260), label = "greeting")
LaunchedEffect(Unit) { shown = true }
Column(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.size(64.dp).graphicsLayer { alpha = p; scaleX = 0.92f + 0.08f * p; scaleY = 0.92f + 0.08f * p }
.clip(CircleShape).background(avatar)
)
Text(name, style = CaiText.GreetingName, color = CaiColors.TextPrimary, modifier = Modifier.graphicsLayer { alpha = p })
Text(tagline, style = CaiText.CardDesc, color = CaiColors.TextTertiary, modifier = Modifier.graphicsLayer { alpha = p })
}
}
Character Card (Discover)
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material3.Icon
import androidx.compose.ui.text.style.TextOverflow
@Composable
fun CharacterCard(avatar: Brush, name: String, desc: String, chats: String, onClick: () -> Unit) {
Column(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(CaiColors.Surface2)
.border(1.dp, CaiColors.Divider, RoundedCornerShape(16.dp))
.clickable(onClick = onClick)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(Modifier.size(48.dp).clip(CircleShape).background(avatar))
Text(name, style = CaiText.CardName, color = CaiColors.TextPrimary)
Text(desc, style = CaiText.CardDesc, color = CaiColors.TextSecondary, maxLines = 2, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Icon(Icons.Filled.ChatBubbleOutline, null, tint = CaiColors.TextTertiary, modifier = Modifier.size(11.dp))
Text(chats, style = CaiText.Meta, color = CaiColors.TextTertiary)
}
}
}
Composer
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Stop
@Composable
fun Composer(
value: String,
onValueChange: (String) -> Unit,
characterName: String,
streaming: Boolean,
onSend: () -> Unit,
onStop: () -> Unit,
) {
val empty = value.isBlank() && !streaming
Row(
Modifier
.fillMaxWidth()
.drawBehind { drawLine(CaiColors.Divider, Offset(0f, 0f), Offset(size.width, 0f), 1f) }
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
Modifier
.weight(1f)
.heightIn(min = 44.dp, max = 132.dp)
.clip(RoundedCornerShape(22.dp))
.background(CaiColors.Surface2)
.border(1.dp, CaiColors.Divider, RoundedCornerShape(22.dp))
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
BasicTextField(
value = value, onValueChange = onValueChange,
textStyle = CaiText.Message.copy(color = CaiColors.TextPrimary),
cursorBrush = SolidColor(CaiColors.Accent),
decorationBox = { inner ->
if (value.isEmpty()) Text("Message $characterName…", style = CaiText.Message, color = CaiColors.TextTertiary)
inner()
},
)
}
Box(
Modifier
.size(40.dp).clip(CircleShape)
.background(if (empty) CaiColors.BubbleUser else CaiColors.Accent)
.clickable { if (streaming) onStop() else onSend() },
contentAlignment = Alignment.Center,
) {
Icon(
if (streaming) Icons.Filled.Stop else Icons.Filled.ArrowUpward,
contentDescription = if (streaming) "Stop generating" else "Send",
tint = if (empty) CaiColors.TextTertiary else CaiColors.OnAccent,
modifier = Modifier.size(18.dp),
)
}
}
}
Buttons
@Composable
fun CaiPrimaryButton(title: String, onClick: () -> Unit) {
Box(
Modifier
.clip(RoundedCornerShape(999.dp)).background(CaiColors.Accent)
.clickable(onClick = onClick).padding(horizontal = 26.dp, vertical = 13.dp),
contentAlignment = Alignment.Center,
) { Text(title, style = CaiText.Button, color = CaiColors.OnAccent) }
}
@Composable
fun CaiSoftButton(title: String, onClick: () -> Unit) {
Box(
Modifier
.clip(RoundedCornerShape(999.dp)).background(CaiColors.AccentSoft)
.clickable(onClick = onClick).padding(horizontal = 26.dp, vertical = 13.dp),
contentAlignment = Alignment.Center,
) { Text(title, style = CaiText.Button, color = CaiColors.AccentSoftTx) }
}
4. Navigation
Character.AI has a thin header and a 4-tab bottom strip with no tint pill — active is the blue icon + blue label.
@Composable
fun CaiBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = CaiColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
Icons.Filled.AutoAwesome to "Discover",
Icons.Filled.ChatBubbleOutline to "Chats",
Icons.Filled.AddCircleOutline to "Create",
Icons.Filled.PersonOutline 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 = CaiText.Tab) },
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
selectedIconColor = CaiColors.Accent, // blue icon + blue label
selectedTextColor = CaiColors.Accent,
unselectedIconColor = CaiColors.TextTertiary,
unselectedTextColor = CaiColors.TextTertiary,
indicatorColor = Color.Transparent, // NO Material pill — CAI has none
),
)
}
}
}
The chat header is a thin Row (48dp): back chevron, 40dp avatar Box with the radial brush, name CaiText.HeaderName, sub “by @lumen · 2.4M chats” CaiText.Meta, trailing ⋮; underline = a 0.5dp Divider.
5. Motion
Character.AI motion is minimal and reading-focused — streaming and the typing dots are the only continuous motion; nothing flashy.
| Moment | Compose recipe |
|---|---|
| Message send | new item AnimatedVisibility(fadeIn() + slideInVertically({ it / 4 }), tween(120)); lazyListState.animateScrollToItem(last) |
| AI streaming | append tokens to a mutableStateOf string in the message model; LazyColumn re-measures the bubble (no remount); blink a tail caret via rememberInfiniteTransition |
| Typing indicator | per-dot animateFloat 0.4 → 1f infiniteRepeatable(tween(600, delay i*160), Reverse) |
| Greeting entrance | avatar animateFloatAsState alpha + scale 0.92 → 1.0 tween(260) |
| Send ↔ Stop | icon + bg swap on streaming state, tween(150) color/Crossfade |
| Tab switch | tint cross-dissolves to blue tween(180); no pill |
| Card press | Modifier.clickable ripple + scale 0.99 |
| Sheet present | ModalBottomSheet slide-up (Material default) + scrim |
// Tail caret while streaming — the only "generating" affordance
val caret = rememberInfiniteTransition(label = "caret")
val caretAlpha by caret.animateFloat(1f, 0f, infiniteRepeatable(tween(500), RepeatMode.Reverse), label = "blink")
// append "▍" with alpha=caretAlpha at the end of the streaming bubble text
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) for send tap and discrete long-press actions (Copy/Retry/Rate). Never fire haptics during token streaming — it would buzz continuously.
6. Icons
androidx.compose.material:material-icons-extended covers Character.AI’s simple iconography.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Discover (tab) | sparkles |
Icons.Filled.AutoAwesome |
| Chats (tab) | bubble.left.and.bubble.right |
Icons.Filled.ChatBubbleOutline |
| Create (tab) | plus.circle |
Icons.Filled.AddCircleOutline |
| Profile (tab) | person |
Icons.Filled.PersonOutline |
| Send | arrow.up |
Icons.Filled.ArrowUpward |
| Stop generation | stop.fill |
Icons.Filled.Stop |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Overflow / chat menu | ellipsis |
Icons.Filled.MoreVert |
| Chat-count (card) | bubble.left.and.bubble.right |
Icons.Filled.ChatBubbleOutline |
| New chat | square.and.pencil |
Icons.Filled.EditNote |
| Retry | arrow.clockwise |
Icons.Filled.Refresh |
| Copy | doc.on.doc |
Icons.Filled.ContentCopy |
| Rate up / down | hand.thumbsup / .thumbsdown |
Icons.Filled.ThumbUpOffAlt / ThumbDownOffAlt |
| Settings | gearshape |
Icons.Filled.Settings |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
LazyColumnstreaming +AnnotatedStringcomfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the near-black canvas wants light system-bar icons on dark, dark on light. The header sits below the camera cutout; the composer usesModifier.imePadding()so it rises with the IME and the message list scrolls clear. - Dark-first, light available: drive scheme by
isSystemInDarkTheme(); only canvas/surface/text + bubble greys swap. KeepAccent,Lilac, and the avatar gradients identical across schemes. Do not enabledynamicColorScheme()— Character.AI’s calm blue is a fixed brand accent and must never become a wallpaper color (and must never be a bubble fill in either theme). - Roleplay parsing:
parseMessagebuilds anAnnotatedStringwhere*…*spans are italic +TextSecondary. This is a load-bearing signature — keep it everywhere AI text renders, including previews and notifications. - Font scaling:
sphonors the user’s scale — keep it on display/titles/names/message/body so long AI replies grow comfortably; pin only the 10sp tab labels and “by @creator · N chats” meta via a fixedDensity(fontScale = 1f)provider. - Streaming: mutate the message model’s text
Stateand letLazyColumnre-measure the bubble; never key the bubble on text (it would remount and flicker). The blinking tail caret is the only “generating” cue. - TalkBack: give AI bubbles
Modifier.semantics { contentDescription = "Aria said: $speech. Action: $roleplay" }so the narration vs speech distinction the italics encode is also spoken; label the typing indicator “Aria is typing”; the send button “Send”/“Stop generating”. The*action*semantics must survive to screen readers — don’t strip them. - Touch targets: Material guidance is 48dp — give the 40dp send button and header back/overflow a 48dp hit via padding; the full character card and full bubble (long-press for Copy/Retry/Rate) are the targets.
- Contrast:
#ECECEEon#0F0F10is strong;#FFFFFFon#3A7BFDpasses AA for send/CTAs; the italic roleplay#9A9AA2on#1C1C1Fpasses AA at 15sp — keep the size. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, show three static typing dots, skip the greeting scale, and make send↔stop an instant swap; keep token streaming (it’s content, not decoration). - No streaming haptics: only fire haptics on send and discrete actions — continuous streaming buzz is hostile. The avatar gradient is the only warmth; never rely on blue-vs-lilac alone to identify a character — the name label must carry identity.