Canva (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Canva’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the tab bar with a centered create FAB, 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 (Canva’s bright workspace, the cyan→purple brand gradient, rounded everything, the lifted gradient create FAB, gold Pro) while making everything idiomatic Android — a
NavigationBarwith a centerFloatingActionButton,ModalBottomSheetfor tool/design pickers,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for template thumbnails. No color extraction — Canva’s palette and gradients are fixed. A full dark scheme is provided (warm-neutral, never true black).
1. Color Tokens
// ui/theme/CanvaColors.kt
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Brush
object CanvaColors {
// Canvas & Surfaces (Light)
val White = Color(0xFFFFFFFF)
val Workspace = Color(0xFFF7F8FA)
val PressedLight = Color(0xFFEEF0F4)
val DividerLight = Color(0xFFE6E8EC)
// Canvas & Surfaces (Dark)
val CanvasDark = Color(0xFF18191B) // warm-neutral — NOT pure black
val Surface1 = Color(0xFF1F2023)
val Surface2 = Color(0xFF27282C)
val Divider = Color(0xFF303135)
// Text
val TextPrimary = Color(0xFFECECEE) // dark
val TextPrimaryLt = Color(0xFF0D0E10) // light
val TextSecondary = Color(0xFFA4A6AD)
val TextTertiary = Color(0xFF6F7178)
// Brand
val Cyan = Color(0xFF00C4CC)
val Purple = Color(0xFF7D2AE8)
val Blue = Color(0xFF3B5CFF)
val PurplePressed = Color(0xFF6A1FD0)
// Semantic
val ProGold = Color(0xFFFFC24B)
val Success = Color(0xFF1FC77C)
val Error = Color(0xFFFF5163)
// The Canva brand gradient — logo, create FAB, primary CTA, hero rails
val BrandGradient = Brush.linearGradient(listOf(Cyan, Purple))
fun tile(a: Color, b: Color) = Brush.linearGradient(listOf(a, b))
val TileInstagram = tile(Cyan, Blue) // #00C4CC → #3B5CFF
val TilePresentation = tile(Purple, Color(0xFFC13AE0)) // #7D2AE8 → #C13AE0
val TileDoc = tile(Color(0xFFFF7A59), ProGold) // #FF7A59 → #FFC24B
val TileWhiteboard = tile(Success, Cyan) // #1FC77C → #00C4CC
}
Wire it into both schemes; the dark scheme uses warm-neutral #18191B, never true black.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
private val CanvaLight = lightColorScheme(
primary = CanvaColors.Purple,
onPrimary = CanvaColors.White,
secondary = CanvaColors.Cyan,
background = CanvaColors.Workspace,
onBackground = CanvaColors.TextPrimaryLt,
surface = CanvaColors.White,
onSurface = CanvaColors.TextPrimaryLt,
surfaceVariant = CanvaColors.PressedLight,
outline = CanvaColors.DividerLight,
error = CanvaColors.Error,
)
private val CanvaDark = darkColorScheme(
primary = CanvaColors.Purple,
onPrimary = CanvaColors.White,
secondary = CanvaColors.Cyan,
background = CanvaColors.CanvasDark,
onBackground = CanvaColors.TextPrimary,
surface = CanvaColors.Surface1,
onSurface = CanvaColors.TextPrimary,
surfaceVariant = CanvaColors.Surface2,
outline = CanvaColors.Divider,
error = CanvaColors.Error,
)
@Composable
fun CanvaTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) CanvaDark else CanvaLight,
typography = CanvaTypography,
content = content,
)
2. Typography
Canva Sans stand-in: Plus Jakarta Sans (SIL OFL) — drop the TTFs in res/font/. App chrome always uses this family; the hundreds of in-editor content fonts are design data, not UI. Heavy display weights (800) with tight negative tracking; friendly, confident.
// ui/theme/CanvaType.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
val PlusJakarta = FontFamily(
Font(R.font.pjs_regular, FontWeight.Normal),
Font(R.font.pjs_medium, FontWeight.Medium),
Font(R.font.pjs_semibold, FontWeight.SemiBold),
Font(R.font.pjs_bold, FontWeight.Bold),
Font(R.font.pjs_extrabold, FontWeight.ExtraBold),
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object CanvaText {
val Display = TextStyle(PlusJakarta, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.6).sp)
val Greeting = TextStyle(PlusJakarta, fontWeight = FontWeight.ExtraBold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
val Section = TextStyle(PlusJakarta, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
val Subsection = TextStyle(PlusJakarta, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(PlusJakarta, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val Label = TextStyle(PlusJakarta, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Button = TextStyle(PlusJakarta, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val Meta = TextStyle(PlusJakarta, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val Chip = TextStyle(PlusJakarta, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.1.sp)
val Tab = TextStyle(PlusJakarta, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
val ProBadge = TextStyle(PlusJakarta, fontWeight = FontWeight.ExtraBold, fontSize = 9.sp, lineHeight = 11.sp, letterSpacing = 0.3.sp)
val Tool = TextStyle(PlusJakarta, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 14.sp, letterSpacing = 0.1.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val CanvaTypography = Typography(
headlineLarge = CanvaText.Display,
headlineMedium = CanvaText.Greeting,
titleLarge = CanvaText.Section,
titleMedium = CanvaText.Subsection,
bodyMedium = CanvaText.Body,
labelSmall = CanvaText.Tab,
)
3. Signature Components
Home Header (Greeting + Avatar)
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
@Composable
fun HomeHeader(name: String, initial: String) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Hi, $name 👋", style = CanvaText.Greeting, color = CanvaColors.TextPrimary)
Spacer(Modifier.weight(1f))
Box(
Modifier.size(34.dp).clip(CircleShape).background(CanvaColors.BrandGradient),
contentAlignment = Alignment.Center,
) { Text(initial, color = CanvaColors.White, style = CanvaText.Label.copy(fontWeight = FontWeight.Bold)) }
}
}
Search Field
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
@Composable
fun CanvaSearchField() {
var q by remember { mutableStateOf("") }
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp)
.height(46.dp)
.clip(RoundedCornerShape(14.dp))
.background(CanvaColors.Surface2)
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(Icons.Filled.Search, null, tint = CanvaColors.TextSecondary, modifier = Modifier.size(17.dp))
BasicTextField(
value = q, onValueChange = { q = it },
textStyle = CanvaText.Label.copy(color = CanvaColors.TextPrimary, fontWeight = FontWeight.Normal),
modifier = Modifier.weight(1f),
decorationBox = { inner ->
if (q.isEmpty()) Text("Search templates, photos…", style = CanvaText.Label, color = CanvaColors.TextSecondary, fontWeight = FontWeight.Normal)
inner()
},
)
}
}
Design-Type Tile
import androidx.compose.foundation.clickable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun DesignTypeTile(icon: ImageVector, label: String, gradient: Brush, onClick: () -> Unit) {
Column(
Modifier.width(76.dp).clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(7.dp),
) {
Box(
Modifier.size(56.dp).clip(RoundedCornerShape(16.dp)).background(gradient),
contentAlignment = Alignment.Center,
) { Icon(icon, null, tint = CanvaColors.White, modifier = Modifier.size(24.dp)) }
Text(label, style = CanvaText.Chip, color = CanvaColors.TextSecondary, maxLines = 1)
}
}
Template Thumbnail (with PRO badge)
@Composable
fun TemplateThumb(gradient: Brush, isPro: Boolean, onClick: () -> Unit) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(3f / 4f)
.clip(RoundedCornerShape(12.dp))
.background(gradient)
.clickable(onClick = onClick),
) {
if (isPro) {
Box(
Modifier
.align(Alignment.TopEnd)
.padding(7.dp)
.clip(RoundedCornerShape(6.dp))
.background(CanvaColors.ProGold)
.padding(horizontal = 6.dp, vertical = 2.dp),
) { Text("PRO", style = CanvaText.ProBadge, color = Color(0xFF1A1205)) }
}
}
}
Buttons + Create FAB
import androidx.compose.foundation.BorderStroke
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material.icons.filled.Add
@Composable
fun PrimaryButton(title: String, onClick: () -> Unit) {
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(CanvaColors.BrandGradient)
.clickable(onClick = onClick)
.padding(horizontal = 28.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) { Text(title, style = CanvaText.Button, color = CanvaColors.White) }
}
@Composable
fun SolidButton(title: String, onClick: () -> Unit) {
Button(
onClick = onClick,
shape = RoundedCornerShape(999.dp),
colors = ButtonDefaults.buttonColors(containerColor = CanvaColors.Purple, contentColor = CanvaColors.White),
contentPadding = PaddingValues(horizontal = 22.dp, vertical = 12.dp),
) { Text(title, style = CanvaText.Button.copy(fontSize = 15.sp)) }
}
@Composable
fun ProButton(title: String, onClick: () -> Unit) {
Button(
onClick = onClick,
shape = RoundedCornerShape(999.dp),
colors = ButtonDefaults.buttonColors(containerColor = CanvaColors.ProGold, contentColor = Color(0xFF1A1205)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 10.dp),
) { Text(title, style = CanvaText.ProBadge.copy(fontSize = 14.sp)) }
}
@Composable
fun CreateFab(onClick: () -> Unit) {
Box(
Modifier
.offset(y = (-4).dp)
.size(46.dp)
.clip(RoundedCornerShape(16.dp))
.background(CanvaColors.BrandGradient)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Add, "Create a design", tint = CanvaColors.White, modifier = Modifier.size(26.dp)) }
}
Create-Design Bottom Sheet
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
data class DType(val icon: ImageVector, val label: String, val grad: Brush)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateDesignSheet(types: List<DType>, onDismiss: () -> Unit, onPick: (DType) -> Unit) {
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = CanvaColors.Surface1,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false),
) {
Text("Create a design", style = CanvaText.Subsection, color = CanvaColors.TextPrimary,
modifier = Modifier.padding(start = 20.dp, bottom = 16.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
items(types) { t -> DesignTypeTile(t.icon, t.label, t.grad) { onPick(t) } }
}
}
}
4. Navigation
Canva’s tab bar has a centered, lifted gradient create FAB — the one elevated element. Model it as a Scaffold with a NavigationBar and a center-docked FloatingActionButton. There is no Material tint pill — active is the primary text color.
import androidx.compose.material3.*
import androidx.compose.material.icons.filled.*
@Composable
fun CanvaScaffold(selected: Int, onSelect: (Int) -> Unit, onCreate: () -> Unit, content: @Composable (PaddingValues) -> Unit) {
Scaffold(
containerColor = CanvaColors.CanvasDark,
floatingActionButton = { CreateFab(onCreate) },
floatingActionButtonPosition = FabPosition.Center,
bottomBar = {
NavigationBar(containerColor = CanvaColors.CanvasDark, tonalElevation = 0.dp) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Templates" to Icons.Filled.GridView,
null to null, // FAB notch
"Projects" to Icons.Filled.ViewList,
"You" to Icons.Filled.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
if (icon == null) { Spacer(Modifier.weight(1f)); return@forEachIndexed }
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, label, modifier = Modifier.size(22.dp)) },
label = { Text(label!!, style = CanvaText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = CanvaColors.TextPrimary,
selectedTextColor = CanvaColors.TextPrimary,
unselectedIconColor = CanvaColors.TextSecondary,
unselectedTextColor = CanvaColors.TextSecondary,
indicatorColor = Color.Transparent, // no Material pill — Canva has none
),
)
}
}
},
content = content,
)
}
Tool pickers, the design-type chooser and share flows are all ModalBottomSheets with 24.dp top corners. The editor’s contextual element toolbar is a horizontally scrollable Row of round controls anchored above the IME (Modifier.imePadding()), on a Surface1 surface with 16.dp top corners.
5. Motion
Canva motion is friendly — 220–320ms ease-out, soft and confident.
| Moment | Compose recipe |
|---|---|
| Create FAB tap → sheet | ModalBottomSheet slide-up (≈320ms); FAB scale animateFloatAsState → 0.92 on press |
| Design-type tile tap | scale animateFloatAsState → 0.96 tween(120), then nav push tween(300) |
| Bottom sheet drag-dismiss | ModalBottomSheet sheetState — finger-tracked, spring release damping ≈ 0.85 |
| Template open | shared-element-style AnimatedContent zoom into the customize sheet tween(320) |
| Editor element select | contextual toolbar slideInVertically(tween(220)) + fadeIn |
| Tab active pop | selected icon animateFloatAsState scale → 1.05 tween(120) |
| Gradient button press | graphicsLayer alpha 0.93 + scale 0.98 tween(100) |
| Skeleton shimmer | rememberInfiniteTransition sweep over 1200ms LinearEasing |
// Design-type tile press scale — the canonical Canva micro-interaction
val scale by animateFloatAsState(if (pressed) 0.96f else 1f, tween(120), label = "tileScale")
Modifier.graphicsLayer { scaleX = scale; scaleY = scale }
Haptics: prefer LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) for the light tick on FAB tap, tile tap and element select. Use HapticFeedbackConstants.CONFIRM on export/publish complete. Auto-save is silent — only a snackbar on manual save or error.
6. Icons
Canva ships custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. The brand gradient marks brand + primary action; Pro is gold.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Home (tab) | house |
Icons.Filled.Home |
| Templates (tab) | square.grid.2x2 |
Icons.Filled.GridView |
| Create FAB | plus |
Icons.Filled.Add |
| Projects (tab) | list.bullet.rectangle |
Icons.Filled.ViewList |
| You (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Search | magnifyingglass |
Icons.Filled.Search |
| Pro / premium | crown.fill |
Icons.Filled.WorkspacePremium |
| Instagram type | camera.fill |
Icons.Filled.PhotoCamera |
| Presentation type | play.rectangle |
Icons.Filled.Slideshow |
| Doc type | doc.text |
Icons.Filled.Description |
| Whiteboard type | square.dashed |
Icons.Filled.Dashboard |
| Video type | play.circle.fill |
Icons.Filled.PlayCircle |
| Editor — back | chevron.left |
Icons.Filled.ArrowBack |
| Editor — undo / redo | arrow.uturn.backward / .forward |
Icons.Filled.Undo / Icons.Filled.Redo |
| Editor — share | square.and.arrow.up |
Icons.Filled.IosShare |
| Tool — Elements | square.on.circle |
Icons.Filled.Category |
| Tool — Text | textformat |
Icons.Filled.TextFields |
| Tool — Uploads | arrow.up.circle |
Icons.Filled.CloudUpload |
| Tool — Photos | photo.on.rectangle |
Icons.Filled.Image |
| Tool — Draw | pencil.tip |
Icons.Filled.Brush |
| Element — color | paintpalette.fill |
Icons.Filled.Palette |
| Element — animate | sparkles |
Icons.Filled.AutoAwesome |
| Element — delete | trash |
Icons.Filled.Delete |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
ModalBottomSheet+ modern motion comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the dark canvas wants light-content system bars (dark-content on the light theme). The editor’s contextual element toolbar must sit above the IME — useModifier.imePadding(). - Font scaling:
sphonors the user’s font scale — keep it on greeting, section titles, body, meta. Pin layout-sensitive text (10sp tab labels, 12sp chip labels, 9sp Pro badge, 11sp tool labels) viadp-derived sizes or a fixed-fontScaleLocalDensity. - App vs content fonts: app chrome is always Plus Jakarta Sans; the hundreds of in-editor content fonts are design data — load on demand for the canvas only, never for UI.
- TalkBack: label the create FAB “Create a design”; design-type tiles “{Label} template, button”; template thumbs “{name}, Pro” when locked; the Pro badge needs a
contentDescription. Mark section headers asheading(). - Touch targets: Material guidance is 48.dp. The create FAB is 46.dp (give it a 48.dp hit via padding); design-type tiles are 76.dp wide and fully tappable; tool-rail items get a 48.dp hit area.
- Contrast:
#0D0E10on#FFFFFFand#ECECEEon#18191Bpass WCAG AA. Verify white text/icons over the lighter cyan end of gradients (#00C4CC) — add a subtle dark scrim behind label text on light thumbnails. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the FAB/tile scale pops and the skeleton shimmer; keep sheets as a plain fade. - Dark mode: invert via the
Dark*palette — warm-neutral#18191B, NOT true black. Never desaturate content thumbnails — only chrome dims. Sheets add a faint 1dpDividertop border since shadows nearly vanish on dark. Do not enable Material YoudynamicColorScheme()— the cyan→purple brand gradient and gold Pro must hold regardless of wallpaper.