Trello (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Trello’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 (Trello’s board-as-backdrop, pale lists of white cards, the navy-tinted card shadow, the drag lift, the functional label palette) while making everything idiomatic Android —
LazyRow/LazyColumninstead of UIScrollView,sp/dpinstead ofpt, and no bottom NavigationBar (Trello has none).
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for board photo backgrounds.
1. Color Tokens
// ui/theme/TrelloColors.kt
import androidx.compose.ui.graphics.Color
object TrelloColors {
// Action & board
val Action = Color(0xFF0C66E4)
val ActionPressed = Color(0xFF0055CC)
val ActionTint = Color(0xFFE9F2FF)
val BoardBlue = Color(0xFF0079BF) // default backdrop
// Surfaces & text
val Card = Color(0xFFFFFFFF)
val ListSurface = Color(0xFFF1F2F4)
val Sunken = Color(0xFFEBECF0)
val Divider = Color(0xFFDFE1E6)
val Border = Color(0xFFC1C7D0)
val TextPrimary = Color(0xFF172B4D)
val TextSecondary = Color(0xFF5E6C84)
val TextTertiary = Color(0xFF8993A4)
// Card label palette (functional)
val LabelGreen = Color(0xFF4BCE97)
val LabelYellow = Color(0xFFF5CD47)
val LabelOrange = Color(0xFFFEA362)
val LabelRed = Color(0xFFF87168)
val LabelPurple = Color(0xFF9F8FEF)
val LabelBlue = Color(0xFF579DFF)
// Semantic
val Success = Color(0xFF1F845A)
val DueSoon = Color(0xFFB65C02)
val Overdue = Color(0xFFC9372C)
// Signature navy shadow base
val ShadowNavy = Color(0xFF091E42)
}
Wire it into a Material 3 lightColorScheme. Trello supports a real dark mode for UI chrome; the board backdrop still drives the board area regardless.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val TrelloScheme = lightColorScheme(
primary = TrelloColors.Action,
onPrimary = Color.White,
primaryContainer = TrelloColors.ActionTint,
background = TrelloColors.ListSurface,
onBackground = TrelloColors.TextPrimary,
surface = TrelloColors.Card,
onSurface = TrelloColors.TextPrimary,
surfaceVariant = TrelloColors.ListSurface,
outline = TrelloColors.Border,
outlineVariant = TrelloColors.Divider,
error = TrelloColors.Overdue,
)
@Composable
fun TrelloTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = TrelloScheme, typography = TrelloTypography, content = content)
2. Typography
Trello uses the system font deliberately (legibility on any backdrop). Compose’s default FontFamily.Default is Roboto on Android; Inter is the closest substitute if bundled.
// ui/theme/TrelloType.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
private val Sys = FontFamily.Default
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object TrelloText {
val SheetTitle = TextStyle(Sys, fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 29.sp, letterSpacing = (-0.3).sp)
val BoardName = TextStyle(Sys, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
val Section = TextStyle(Sys, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.1).sp)
val ListTitle = TextStyle(Sys, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val CardTitle = TextStyle(Sys, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Body = TextStyle(Sys, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Button = TextStyle(Sys, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 20.sp)
val Badge = TextStyle(Sys, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 14.sp)
val Subtitle = TextStyle(Sys, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val LabelUpper = TextStyle(Sys, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.4.sp)
val Composer = TextStyle(Sys, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 21.sp)
val Caption = TextStyle(Sys, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 14.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val TrelloTypography = Typography(
headlineLarge = TrelloText.SheetTitle,
headlineSmall = TrelloText.Section,
titleMedium = TrelloText.CardTitle,
bodyMedium = TrelloText.Body,
labelSmall = TrelloText.Badge,
)
3. Signature Components
Card (with drag lift)
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
@Composable
fun TrelloCard(
title: String,
labels: List<Color>,
due: String? = null,
checklist: String? = null,
isDragging: Boolean = false,
modifier: Modifier = Modifier,
) {
val scale by animateFloatAsState(if (isDragging) 1.03f else 1f, spring(), label = "cardScale")
val elevation by animateDpAsState(if (isDragging) 24.dp else 1.dp, label = "cardElev")
Column(
modifier
.scale(scale)
.shadow(
elevation = elevation,
shape = RoundedCornerShape(8.dp),
ambientColor = TrelloColors.ShadowNavy,
spotColor = TrelloColors.ShadowNavy,
)
.clip(RoundedCornerShape(8.dp))
.background(TrelloColors.Card)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (labels.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
labels.forEach { c ->
Box(Modifier.size(width = 36.dp, height = 8.dp).clip(RoundedCornerShape(4.dp)).background(c))
}
}
}
Text(title, style = TrelloText.CardTitle, color = TrelloColors.TextPrimary, maxLines = 4, overflow = TextOverflow.Ellipsis)
if (due != null || checklist != null) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
if (due != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Filled.Schedule, null, tint = TrelloColors.DueSoon, modifier = Modifier.size(12.dp))
Text(due, style = TrelloText.Badge, color = TrelloColors.DueSoon)
}
}
if (checklist != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Filled.CheckBox, null, tint = TrelloColors.TextSecondary, modifier = Modifier.size(12.dp))
Text(checklist, style = TrelloText.Badge, color = TrelloColors.TextSecondary)
}
}
}
}
}
}
List / Column
@Composable
fun TrelloListColumn(
title: String,
cards: List<CardModel>,
modifier: Modifier = Modifier,
) {
var composing by remember { mutableStateOf(false) }
var draft by remember { mutableStateOf("") }
Column(
modifier
.width(272.dp)
.shadow(2.dp, RoundedCornerShape(12.dp), ambientColor = TrelloColors.ShadowNavy, spotColor = TrelloColors.ShadowNavy)
.clip(RoundedCornerShape(12.dp))
.background(TrelloColors.ListSurface),
) {
Row(
Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(title, style = TrelloText.ListTitle, color = TrelloColors.TextPrimary)
Text("${cards.size}", style = TrelloText.Badge, color = TrelloColors.TextSecondary)
Spacer(Modifier.weight(1f))
Icon(Icons.Filled.MoreHoriz, "More", tint = TrelloColors.TextSecondary, modifier = Modifier.size(16.dp))
}
LazyColumn(
Modifier.heightIn(max = 520.dp).padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(cards) { c -> TrelloCard(c.title, c.labels, c.due, c.checklist) }
}
if (composing) {
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
BasicTextField(
value = draft, onValueChange = { draft = it },
textStyle = TrelloText.Composer.copy(color = TrelloColors.TextPrimary),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(TrelloColors.Card)
.border(2.dp, TrelloColors.Action, RoundedCornerShape(8.dp))
.padding(8.dp),
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
Modifier.clip(RoundedCornerShape(8.dp)).background(TrelloColors.Action)
.height(36.dp).padding(horizontal = 16.dp).clickable { },
contentAlignment = Alignment.Center,
) { Text("Add card", style = TrelloText.Button, color = Color.White) }
Icon(Icons.Filled.Close, "Cancel", tint = TrelloColors.TextSecondary,
modifier = Modifier.size(20.dp).clickable { composing = false })
}
}
} else {
Row(
Modifier.fillMaxWidth().clickable { composing = true }.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Filled.Add, null, tint = TrelloColors.TextSecondary, modifier = Modifier.size(16.dp))
Text("Add a card", style = TrelloText.Body, color = TrelloColors.TextSecondary)
}
}
}
}
Board (horizontal scroll, backdrop)
@Composable
fun TrelloBoard(
boardName: String,
lists: List<ListModel>,
backdrop: Color = TrelloColors.BoardBlue, // or AsyncImage for a photo board + scrim
) {
Box(Modifier.fillMaxSize().background(backdrop)) {
// For a photo backdrop: AsyncImage(...) then Box(Modifier.matchParentSize().background(Color.Black.copy(alpha = 0.16f)))
Column {
Row(
Modifier.fillMaxWidth().height(44.dp).padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.ArrowBack, "Back", tint = Color.White)
Text(boardName, style = TrelloText.BoardName, color = Color.White)
Spacer(Modifier.weight(1f))
Row(
Modifier.clip(RoundedCornerShape(8.dp)).background(Color.White.copy(alpha = 0.16f))
.height(32.dp).padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.FilterList, null, tint = Color.White, modifier = Modifier.size(14.dp))
Text("Filter", style = TrelloText.Badge, color = Color.White)
}
Icon(Icons.Filled.MoreHoriz, "Menu", tint = Color.White)
}
LazyRow(
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
items(lists) { l -> TrelloListColumn(l.title, l.cards) }
item {
Box(
Modifier.width(272.dp).height(44.dp)
.clip(RoundedCornerShape(12.dp)).background(Color.White.copy(alpha = 0.16f))
.padding(horizontal = 12.dp),
contentAlignment = Alignment.CenterStart,
) { Text("+ Add list", style = TrelloText.Body, color = Color.White) }
}
}
}
}
}
Card Detail Sheet
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
@Composable
fun CardDetailSheet(title: String, progress: Float, onDismiss: () -> Unit) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = TrelloColors.Card,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
Text(title, style = TrelloText.SheetTitle, color = TrelloColors.TextPrimary)
Text("CHECKLIST", style = TrelloText.LabelUpper, color = TrelloColors.TextSecondary)
LinearProgressIndicator(
progress = { progress },
color = TrelloColors.Success,
trackColor = TrelloColors.Sunken,
modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)),
)
Text("ACTIVITY", style = TrelloText.LabelUpper, color = TrelloColors.TextSecondary)
// comment rows…
}
}
}
4. Drag-and-Drop Lift
// Use Modifier.pointerInput(detectDragGesturesAfterLongPress) to set isDragging.
// Visual contract: scale 1.03 + navy-tinted Level-3 shadow + a placeholder gap.
@Composable
fun DropPlaceholder() {
Box(
Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(8.dp))
.background(TrelloColors.ShadowNavy.copy(alpha = 0.14f)),
)
}
5. Navigation (no bottom bar)
// Trello has NO bottom NavigationBar. The board is the primary surface.
// Use a Navigation graph: a boards list -> a board route that owns the screen
// and scrolls horizontally. Do NOT add Material 3 NavigationBar.
@Composable
fun TrelloNavHost(nav: NavHostController) {
NavHost(nav, startDestination = "boards") {
composable("boards") { BoardsListScreen(onOpen = { nav.navigate("board/$it") }) }
composable("board/{id}") { TrelloBoard(boardName = "Sprint 12", lists = demoLists()) }
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Card lift on drag | animateFloatAsState scale 1→1.03 spring() + animateDpAsState elevation 1→24.dp (navy ambient/spot color) |
| Card drop | spring settle back to scale 1 / 1.dp elevation |
| Placeholder gap | AnimatedVisibility expand of the DropPlaceholder over ~160ms |
| Card detail present | ModalBottomSheet default slide (~300ms) over a scrim |
| Composer expand | animateContentSize() on the list footer, ~180ms |
| Checklist progress | animateFloatAsState feeding LinearProgressIndicator, ~200ms |
Haptics: LocalHapticFeedback.current.performHapticFeedback(HapticFeedbackType.LongPress) when a card is picked up; HapticFeedbackType.TextHandleMove (or a short Vibrator one-shot) when it snaps into a new slot.
7. Icons
Closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Trello glyphs as vector drawables and load via ImageVector.vectorResource(...).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Board overflow | ellipsis |
Icons.Filled.MoreHoriz |
| Filter (on board) | line.3.horizontal.decrease |
Icons.Filled.FilterList |
| Search | magnifyingglass |
Icons.Filled.Search |
| Add card / list | plus |
Icons.Filled.Add |
| Close composer | xmark |
Icons.Filled.Close |
| Due date badge | clock |
Icons.Filled.Schedule |
| Checklist badge | checklist |
Icons.Filled.CheckBox |
| Comment badge | text.bubble |
Icons.Filled.ChatBubbleOutline |
| Member | person.crop.circle.fill |
Icons.Filled.AccountCircle |
| Attachment | paperclip |
Icons.Filled.AttachFile |
| Move card | arrow.right.square |
Icons.Filled.DriveFileMove |
| Checklist item | checkmark.square / square |
Icons.Filled.CheckBox / Icons.Outlined.CheckBoxOutlineBlank |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
ModalBottomSheet+ drag gestures are comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); on a colored board use light-content system bars (WindowCompat), on white screens use dark-content. No bottom bar to inset — the board scrolls beneath the on-board header. - Font scaling:
sphonors the user’s scale — cards grow taller to fit titles. Pin badges and label chips with a fixedDensitywrapper where layout is rigid. - TalkBack: a card’s
contentDescriptionshould read “Card, <title>, 2 labels, due tomorrow, checklist 3 of 8”; expose “Move card” viaModifier.semantics { customActions = ... }since drag is hard for screen readers; mark the board name withModifier.semantics { heading() }. - Touch targets: Material minimum is 48.dp. The whole card is the drag/tap target; give 32.dp on-board chips and 16–24.dp icons a 48.dp hit area via padding; checklist boxes get 48.dp.
- Contrast: navy
#172B4Don white cards passes WCAG AAA; on-board white text requires the scrim — never white on a light photo withoutColor.Black.copy(alpha = 0.16f+)behind it.#5E6C84on white passes AA at 12sp+ (validate badges). - Color independence: label colors must not be the only signal — show label text in expanded mode so color-blind users can distinguish them.
- Reduced motion: honor
Settings.Global.ANIMATOR_DURATION_SCALE == 0by replacing the drag scale with an alpha change and instant placeholder. - Dynamic color: do not enable Material You
dynamicLightColorScheme()— Trello’s action blue#0C66E4and the functional label palette must stay fixed; the board background is user-chosen, not wallpaper-derived.