Quora (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Quora’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 (Quora’s paper-white reading surface, the serif-question / sans-answer split, the single red accent, the vote pill) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,ScrollableTabRowfor the top segments,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for avatars.
1. Color Tokens
// ui/theme/QuoraColors.kt
import androidx.compose.ui.graphics.Color
object QuoraColors {
// Canvas & Surface
val Canvas = Color(0xFFFFFFFF)
val Surface = Color(0xFFF7F7F8)
val Divider = Color(0xFFE0E0E0)
// Text
val TextPrimary = Color(0xFF282829)
val TextSecondary = Color(0xFF636466)
val TextTertiary = Color(0xFF9A9A9C)
// Brand
val Red = Color(0xFFB92B27)
val RedPressed = Color(0xFF9E1F1B)
val RedWash = Color(0xFFFBEAEA)
val Upvote = Color(0xFF2E69FF)
val UpvoteWash = Color(0xFFEAF0FF)
val Success = Color(0xFF1FA463)
// Dark (system-dark users)
val DarkCanvas = Color(0xFF1A1A1B)
val DarkSurface = Color(0xFF262627)
val DarkDivider = Color(0xFF383839)
val DarkTextPrimary = Color(0xFFE8E8E8)
val DarkTextSecondary = Color(0xFFA0A0A2)
}
Wire it into Material 3 schemes so ripples, dividers, and stock component colors inherit the brand. Quora is light-first; provide a dark scheme for system-dark users.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.foundation.isSystemInDarkTheme
private val QuoraLight = lightColorScheme(
primary = QuoraColors.Red,
onPrimary = Color.White,
background = QuoraColors.Canvas,
onBackground = QuoraColors.TextPrimary,
surface = QuoraColors.Canvas,
onSurface = QuoraColors.TextPrimary,
surfaceVariant = QuoraColors.Surface,
outline = QuoraColors.Divider,
secondary = QuoraColors.Upvote,
error = Color(0xFFD93025),
)
private val QuoraDark = darkColorScheme(
primary = QuoraColors.Red,
onPrimary = Color.White,
background = QuoraColors.DarkCanvas,
onBackground = QuoraColors.DarkTextPrimary,
surface = QuoraColors.DarkSurface,
onSurface = QuoraColors.DarkTextPrimary,
surfaceVariant = QuoraColors.DarkSurface,
outline = QuoraColors.DarkDivider,
secondary = QuoraColors.Upvote,
)
@Composable
fun QuoraTheme(content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) QuoraDark else QuoraLight,
typography = QuoraTypography,
content = content,
)
2. Typography
The split is the brand: Georgia for questions (ship the TTFs in res/font/ — georgia.ttf, georgia_bold.ttf — since Georgia is not a system font on Android), Inter for answers and chrome.
// ui/theme/QuoraType.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
// Georgia — the serif (question) voice
val Georgia = FontFamily(
Font(R.font.georgia, FontWeight.Normal),
Font(R.font.georgia_bold, FontWeight.Bold),
)
// Inter — the sans (answer / UI) voice
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal), // 400
Font(R.font.inter_semibold, FontWeight.SemiBold), // 600
Font(R.font.inter_bold, FontWeight.Bold), // 700
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object QuoraText {
// Questions — Georgia
val QuestionDetail = TextStyle(fontFamily = Georgia, fontWeight = FontWeight.Bold, fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = (-0.2).sp)
val QuestionCard = TextStyle(fontFamily = Georgia, fontWeight = FontWeight.Bold, fontSize = 19.sp, lineHeight = 26.sp, letterSpacing = (-0.1).sp)
val QuestionRelated = TextStyle(fontFamily = Georgia, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 22.sp)
val QuestionPrompt = TextStyle(fontFamily = Georgia, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 23.sp)
// Answers / UI — Inter
val AnswerBody = TextStyle(fontFamily = Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 26.sp) // 1.6
val AnswerLead = TextStyle(fontFamily = Inter, fontWeight = FontWeight.Normal, fontSize = 17.sp, lineHeight = 27.sp)
val Credential = TextStyle(fontFamily = Inter, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 18.sp)
val AuthorName = TextStyle(fontFamily = Inter, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 20.sp)
val Section = TextStyle(fontFamily = Inter, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
val Meta = TextStyle(fontFamily = Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
val VoteCount = TextStyle(fontFamily = Inter, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 14.sp)
val Button = TextStyle(fontFamily = Inter, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 15.sp, letterSpacing = 0.1.sp)
val ButtonSec = TextStyle(fontFamily = Inter, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 14.sp)
val Tab = TextStyle(fontFamily = Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
val Segment = TextStyle(fontFamily = Inter, fontWeight = FontWeight.Bold, fontSize = 14.sp, lineHeight = 14.sp)
val Caption = TextStyle(fontFamily = Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val QuoraTypography = Typography(
headlineMedium = QuoraText.QuestionDetail,
titleLarge = QuoraText.QuestionCard,
bodyLarge = QuoraText.AnswerBody,
titleMedium = QuoraText.AuthorName,
labelSmall = QuoraText.Tab,
)
3. Signature Components
The Serif/Sans Split — Question Feed Card
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
@Composable
fun QuestionFeedCard(
authorName: String,
credential: String,
question: String,
answerPreview: String,
modifier: Modifier = Modifier,
) {
Column(
modifier
.fillMaxWidth()
.background(QuoraColors.Canvas)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.size(24.dp).clip(CircleShape).background(QuoraColors.Surface))
Column {
Text(authorName, style = QuoraText.AuthorName, color = QuoraColors.TextPrimary)
Text(credential, style = QuoraText.Credential, color = QuoraColors.TextSecondary)
}
}
// QUESTION — Georgia, always
Text(question, style = QuoraText.QuestionCard, color = QuoraColors.TextPrimary)
// ANSWER — Inter, always, 3-line clamp + (more)
val preview = buildAnnotatedString {
append(answerPreview)
withStyle(SpanStyle(color = QuoraColors.Upvote)) { append(" (more)") }
}
Text(preview, style = QuoraText.AnswerBody, color = QuoraColors.TextPrimary,
maxLines = 3, overflow = TextOverflow.Ellipsis)
Row(horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically) {
VotePill(count = "1.2K")
Text("💬 84", style = QuoraText.ButtonSec, color = QuoraColors.TextSecondary)
Text("Share", style = QuoraText.ButtonSec, color = QuoraColors.TextSecondary)
}
}
}
Upvote / Downvote Pill (Signature)
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
private enum class Vote { None, Up, Down }
@Composable
fun VotePill(count: String, modifier: Modifier = Modifier) {
var vote by remember { mutableStateOf(Vote.None) }
val haptics = LocalHapticFeedback.current
fun tap(next: Vote) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ≈ iOS light impact
vote = if (vote == next) Vote.None else next
}
val upActive = vote == Vote.Up
Row(
modifier
.height(32.dp)
.clip(CircleShape)
.border(BorderStroke(1.dp, QuoraColors.Divider), CircleShape),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
Modifier
.background(if (upActive) QuoraColors.UpvoteWash else Color.Transparent)
.clickable(MutableInteractionSource(), indication = null) { tap(Vote.Up) }
.padding(horizontal = 12.dp).height(32.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.ArrowDropUp, contentDescription = "Upvote",
tint = if (upActive) QuoraColors.Upvote else QuoraColors.TextSecondary)
Text(count, style = QuoraText.VoteCount,
color = if (upActive) QuoraColors.Upvote else QuoraColors.TextSecondary)
}
Box(Modifier.width(1.dp).height(20.dp).background(QuoraColors.Divider))
Box(
Modifier
.clickable(MutableInteractionSource(), indication = null) { tap(Vote.Down) }
.padding(horizontal = 12.dp).height(32.dp),
contentAlignment = Alignment.Center,
) {
// downvote stays neutral — never red
Icon(Icons.Filled.ArrowDropDown, contentDescription = "Downvote",
tint = QuoraColors.TextSecondary)
}
}
}
Primary “Answer” CTA
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.Edit
import androidx.compose.ui.draw.scale
@Composable
fun QuoraAnswerButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "answerScale")
Row(
modifier
.scale(scale)
.clip(RoundedCornerShape(6.dp))
.background(if (pressed) QuoraColors.RedPressed else QuoraColors.Red)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 24.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.Edit, contentDescription = null, tint = Color.White,
modifier = Modifier.size(15.dp))
Text(text, style = QuoraText.Button, color = Color.White)
}
}
Answer Detail Block (Credential Byline)
import androidx.compose.material3.HorizontalDivider
@Composable
fun AnswerDetailBlock(
authorName: String,
credential: String,
timestamp: String,
body: String,
) {
Column(
Modifier
.fillMaxWidth()
.background(QuoraColors.Canvas)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(Modifier.size(40.dp).clip(CircleShape).background(QuoraColors.Surface))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(authorName, style = QuoraText.AuthorName, color = QuoraColors.TextPrimary)
Text(credential, style = QuoraText.Credential, color = QuoraColors.TextSecondary)
Text(timestamp, style = QuoraText.Meta, color = QuoraColors.TextSecondary)
}
}
Text(body, style = QuoraText.AnswerBody, color = QuoraColors.TextPrimary)
Row(horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically) {
VotePill(count = "3.4K")
Text("💬 212", style = QuoraText.ButtonSec, color = QuoraColors.TextSecondary)
Text("Bookmark", style = QuoraText.ButtonSec, color = QuoraColors.TextSecondary)
}
HorizontalDivider(color = QuoraColors.Divider)
}
}
4. Spaces Carousel (Dynamic Surface)
The only place per-community color enters the otherwise-monochrome feed. Each tile carries its Space banner color, driven by data.
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
data class SpaceTile(val name: String, val members: String, val banner: Color)
@Composable
fun SpacesRow(spaces: List<SpaceTile>) {
Column {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Spaces for you", style = QuoraText.Section, color = QuoraColors.TextPrimary)
Text("See all", style = QuoraText.ButtonSec, color = QuoraColors.Upvote)
}
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(spaces) { s ->
Column(
Modifier
.width(140.dp)
.clip(RoundedCornerShape(10.dp))
.border(BorderStroke(1.dp, QuoraColors.Divider), RoundedCornerShape(10.dp))
.background(QuoraColors.Canvas),
) {
Box(Modifier.fillMaxWidth().height(64.dp).background(s.banner))
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(s.name, style = QuoraText.AuthorName, color = QuoraColors.TextPrimary)
Text(s.members, style = QuoraText.Credential, color = QuoraColors.TextSecondary)
}
}
}
}
}
}
5. Navigation
Bottom: Material 3 NavigationBar (Home / Following / Answer / Spaces / Notifications) — the center Answer glyph is always red. Top: a ScrollableTabRow with a red underline indicator. Android has no live blur, so both bars use an opaque white surface with a hairline.
import androidx.compose.material3.*
import androidx.compose.material.icons.filled.*
@Composable
fun QuoraBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = QuoraColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
Triple("Home", Icons.Filled.Home, false),
Triple("Following", Icons.Filled.People, false),
Triple("Answer", Icons.Filled.Edit, true), // red anchor
Triple("Spaces", Icons.Filled.ViewAgenda, false),
Triple("Notifications", Icons.Filled.Notifications, false),
)
items.forEachIndexed { i, (label, icon, isAnswer) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = {
Icon(icon, contentDescription = label,
tint = if (isAnswer) QuoraColors.Red
else if (selected == i) QuoraColors.TextPrimary
else QuoraColors.TextSecondary,
modifier = Modifier.size(24.dp))
},
label = { Text(label, style = QuoraText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = QuoraColors.TextPrimary,
selectedTextColor = QuoraColors.TextPrimary,
unselectedIconColor = QuoraColors.TextSecondary,
unselectedTextColor = QuoraColors.TextSecondary,
indicatorColor = Color.Transparent, // Quora has no Material pill
),
)
}
}
}
@Composable
fun QuoraTopTabs(selected: Int, onSelect: (Int) -> Unit) {
val tabs = listOf("For You", "Following", "Answer", "Spaces")
ScrollableTabRow(
selectedTabIndex = selected,
containerColor = QuoraColors.Canvas,
edgePadding = 16.dp,
indicator = { pos ->
TabRowDefaults.SecondaryIndicator(
Modifier.tabIndicatorOffset(pos[selected]),
height = 2.dp,
color = QuoraColors.Red,
)
},
divider = { HorizontalDivider(color = QuoraColors.Divider) },
) {
tabs.forEachIndexed { i, t ->
Tab(selected = selected == i, onClick = { onSelect(i) }, text = {
Text(t, style = QuoraText.Segment,
color = if (selected == i) QuoraColors.TextPrimary else QuoraColors.TextSecondary)
})
}
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Upvote tap | animateColorAsState wash + arrow over 200ms tween; count via AnimatedContent; HapticFeedbackType.TextHandleMove |
| Pill press | animateFloatAsState 1 → 0.96 → 1, 150ms |
| Card → detail | AnimatedVisibility crossfade + slideInVertically { 8 }, 280ms |
| Composer present | ModalBottomSheet / full-screen Dialog with slideInVertically, spring (dampingRatio 0.85) |
| Follow toggle | animateColorAsState border + AnimatedContent label crossfade, 180ms |
// Upvote count roll
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
@Composable
fun VoteCountRoll(count: String, active: Boolean) {
AnimatedContent(targetState = count, label = "vote",
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(120)) }) { c ->
Text(c, style = QuoraText.VoteCount,
color = if (active) QuoraColors.Upvote else QuoraColors.TextSecondary)
}
}
Haptics: prefer LocalHapticFeedback. For a crisper tick use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) (API 30+) to approximate iOS’s .light impact on upvote.
7. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. Export Quora’s exact vote chevrons as vector drawables for pixel parity if needed.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Upvote | arrowtriangle.up / .fill |
Icons.Filled.ArrowDropUp |
| Downvote | arrowtriangle.down / .fill |
Icons.Filled.ArrowDropDown |
| Comment | bubble.left |
Icons.Outlined.ChatBubbleOutline |
| Share | square.and.arrow.up |
Icons.Filled.Share |
| Bookmark | bookmark / bookmark.fill |
Icons.Outlined.BookmarkBorder / Icons.Filled.Bookmark |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Answer / Compose | square.and.pencil |
Icons.Filled.Edit |
| Search | magnifyingglass |
Icons.Filled.Search |
| Home (tab) | house / house.fill |
Icons.Filled.Home |
| Following (tab) | person.2 |
Icons.Filled.People |
| Spaces (tab) | rectangle.3.group |
Icons.Filled.ViewAgenda |
| Notifications (tab) | bell / bell.fill |
Icons.Filled.Notifications |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the white canvas wants dark system-bar icons (isAppearanceLightStatusBars = true). ApplyScaffoldinsets so the bottom nav clears gesture nav. - Serif must survive: Georgia is not a system font on Android — you must bundle it in
res/font/. Do not let the question fall back to the same family as the answer; the serif/sans split is the entire identity. - Font scaling:
sphonors the user’s font scale — keep it on questions and answer bodies. Pin layout-sensitive text (vote counts, tab labels, segment labels) with a fixed-density wrapper. - TalkBack: announce the credential byline after the author name and before the timestamp — it is the trust signal. Label the vote pill segments separately (
"Upvote, 1,200","Downvote") and never expose a downvote count (Quora hides it). Merge each card’s question + answer withModifier.semantics(mergeDescendants = true)but keep the vote pill an independent focus group. - Touch targets: Material guidance is 48.dp minimum. Give each vote-pill segment ≥48.dp effective hit via padding even though the pill is 32.dp tall; the 18.dp footer icons need a 48.dp hit area.
- Contrast:
#636466on#FFFFFFpasses WCAG AA at 13sp+.#9A9A9Cis placeholder-only — never essential text. Validate the 10sp tab labels and bump toward#4A4B4Dif targeting strict compliance. - Dynamic color: do not enable Material You
dynamicLightColorScheme()— Quora’s identity requires the fixed white canvas and single red accent regardless of wallpaper.