coderef

Asana

Asana's iOS app is a calm, structured productivity surface whose entire personality comes from one warm color and a disciplined list.
DownloadRaw
Asana

Build with the Asana look.

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

Palette

At a glance

Theme
light
Typefaces
Inter
Sections
7
Length
4,423 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Asana

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

Asana (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Asana’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the task list + completion circle + status update card, 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 (Asana’s calm canvas, the single coral accent, the delightful completion circle, ~16% tinted object pills) while making everything idiomatic Android — NavigationBar instead of a UITabBar, a FloatingActionButton instead of a custom overlay, dp/sp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for avatars. No color extraction — Asana’s palette is fixed, so Palette is not needed. Asana is light-mode-first; a full charcoal dark scheme is provided.

1. Color Tokens

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

object AsanaColors {
    // Canvas & Surfaces (Light)
    val Canvas         = Color(0xFFFFFFFF)
    val SurfaceGray    = Color(0xFFF9F8F8)
    val SurfacePressed = Color(0xFFF0EFEF)
    val Divider        = Color(0xFFEDEBE9)
    val CardBorder     = Color(0xFFE8E6E4)

    // Canvas & Surfaces (Dark)
    val DarkCanvas     = Color(0xFF1E1F21) // Asana charcoal — NOT pure black
    val DarkSurface1   = Color(0xFF252628)
    val DarkSurface2   = Color(0xFF2E2F31)
    val DarkDivider    = Color(0xFF35363A)
    val DarkCardBorder = Color(0xFF3A3B3F)

    // Text
    val TextPrimary       = Color(0xFF1E1F21)
    val TextSecondary     = Color(0xFF6D6E6F)
    val TextTertiary      = Color(0xFF9CA3A6)
    val DarkTextPrimary   = Color(0xFFF5F4F2)
    val DarkTextSecondary = Color(0xFFA9A9AA)
    val DarkTextTertiary  = Color(0xFF6F7073)

    // Brand (interactive) — coral is the ONLY accent
    val Coral        = Color(0xFFF06A6A)
    val CoralPressed = Color(0xFFD85B5B)
    val ActionBlue   = Color(0xFF4573D2)

    // Object palette (project / tag / field hues)
    val Plum    = Color(0xFF4573D2)
    val Aqua    = Color(0xFF4ECBC4)
    val Green   = Color(0xFF62D26F)
    val Yellow  = Color(0xFFF8DF72)
    val Orange  = Color(0xFFF1BD6C)
    val Magenta = Color(0xFFF26FD3)
    val Indigo  = Color(0xFF5A3FFF)
    val CoolGray = Color(0xFF8DA3B0)

    // Semantic
    val Success = Color(0xFF62D26F)
    val Error   = Color(0xFFE8384F)
    val Warning = Color(0xFFF8DF72)
}

// Object-hue → tint fill pair (~16% light, ~25% dark)
data class TagStyle(val solid: Color, val tint: Color)
val PlumTag   = TagStyle(AsanaColors.Plum,    AsanaColors.Plum.copy(alpha = 0.16f))
val AquaTag   = TagStyle(AsanaColors.Aqua,    AsanaColors.Aqua.copy(alpha = 0.16f))
val GreenTag  = TagStyle(AsanaColors.Green,   AsanaColors.Green.copy(alpha = 0.16f))
val YellowTag = TagStyle(AsanaColors.Yellow,  AsanaColors.Yellow.copy(alpha = 0.22f))
val OrangeTag = TagStyle(AsanaColors.Orange,  AsanaColors.Orange.copy(alpha = 0.18f))

Wire it into both schemes. Asana is light-first; the dark scheme uses the signature #1E1F21 charcoal, 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

private val AsanaLight = lightColorScheme(
    primary        = AsanaColors.Coral,
    onPrimary      = Color.White,
    background     = AsanaColors.Canvas,
    onBackground   = AsanaColors.TextPrimary,
    surface        = AsanaColors.SurfaceGray,
    onSurface      = AsanaColors.TextPrimary,
    surfaceVariant = AsanaColors.SurfacePressed,
    outline        = AsanaColors.Divider,
    error          = AsanaColors.Error,
)

private val AsanaDark = darkColorScheme(
    primary        = AsanaColors.Coral, // coral identical in dark
    onPrimary      = Color.White,
    background     = AsanaColors.DarkCanvas,
    onBackground   = AsanaColors.DarkTextPrimary,
    surface        = AsanaColors.DarkSurface1,
    onSurface      = AsanaColors.DarkTextPrimary,
    surfaceVariant = AsanaColors.DarkSurface2,
    outline        = AsanaColors.DarkDivider,
    error          = AsanaColors.Error,
)

@Composable
fun AsanaTheme(
    dark: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (dark) AsanaDark else AsanaLight,
    typography = AsanaTypography,
    content = content,
)

2. Typography

Asana uses Inter across product surfaces — drop the TTFs in res/font/. Body 400, task rows 500, headings 700-800; quiet and legible.

// ui/theme/AsanaType.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 Inter = FontFamily(
    Font(R.font.inter_regular,   FontWeight.Normal),
    Font(R.font.inter_medium,    FontWeight.Medium),
    Font(R.font.inter_semibold,  FontWeight.SemiBold),
    Font(R.font.inter_bold,      FontWeight.Bold),
    Font(R.font.inter_extrabold, FontWeight.ExtraBold),
)

object AsanaText {
    val ScreenTitle = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.5).sp)
    val NavTitle    = TextStyle(Inter, fontWeight = FontWeight.Bold,      fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
    val Section     = TextStyle(Inter, fontWeight = FontWeight.Bold,      fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
    val Subsection  = TextStyle(Inter, fontWeight = FontWeight.SemiBold,  fontSize = 18.sp, lineHeight = 23.sp)
    val Body        = TextStyle(Inter, fontWeight = FontWeight.Normal,    fontSize = 16.sp, lineHeight = 24.sp)
    val TaskRow     = TextStyle(Inter, fontWeight = FontWeight.Medium,    fontSize = 15.sp, lineHeight = 20.sp)
    val ListLabel   = TextStyle(Inter, fontWeight = FontWeight.Bold,      fontSize = 13.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
    val Meta        = TextStyle(Inter, fontWeight = FontWeight.Normal,    fontSize = 14.sp, lineHeight = 19.sp)
    val Pill        = TextStyle(Inter, fontWeight = FontWeight.SemiBold,  fontSize = 12.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
    val Button      = TextStyle(Inter, fontWeight = FontWeight.SemiBold,  fontSize = 15.sp, lineHeight = 15.sp)
    val TextAction  = TextStyle(Inter, fontWeight = FontWeight.SemiBold,  fontSize = 14.sp, lineHeight = 14.sp)
    val Avatar      = TextStyle(Inter, fontWeight = FontWeight.Bold,      fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.2.sp)
    val Tab         = TextStyle(Inter, fontWeight = FontWeight.SemiBold,  fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val AsanaTypography = Typography(
    headlineLarge = AsanaText.ScreenTitle,
    headlineMedium = AsanaText.NavTitle,
    titleMedium   = AsanaText.Subsection,
    bodyMedium    = AsanaText.Body,
    labelSmall    = AsanaText.Tab,
)

3. Signature Components

Task Row (with Completion Circle)

import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
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.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp

@Composable
fun TaskRow(
    name: String,
    projectTag: Pair<String, TagStyle>?,
    due: Triple<String, Boolean, Unit>?, // text, overdue
    assigneeInitials: String,
    assigneeColor: Color,
    modifier: Modifier = Modifier,
) {
    var done by remember { mutableStateOf(false) }
    val haptics = LocalHapticFeedback.current
    val scale = remember { Animatable(1f) }

    LaunchedEffect(done) {
        if (done) {
            scale.animateTo(0.8f, tween(80)); scale.animateTo(1.1f, tween(80)); scale.animateTo(1f, tween(60))
        }
    }

    Row(
        modifier
            .fillMaxWidth()
            .padding(horizontal = 18.dp, vertical = 11.dp),
        verticalAlignment = Alignment.Top,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Box(
            Modifier
                .size(20.dp)
                .scale(scale.value)
                .clip(CircleShape)
                .background(if (done) AsanaColors.Success else Color.Transparent)
                .border(1.5.dp, if (done) AsanaColors.Success else AsanaColors.TextTertiary, CircleShape)
                .clickable {
                    done = !done
                    if (done) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                },
            contentAlignment = Alignment.Center,
        ) {
            if (done) Icon(Icons.Filled.Check, null, tint = AsanaColors.DarkCanvas, modifier = Modifier.size(12.dp))
        }

        Column(Modifier.weight(1f)) {
            Text(
                name,
                style = AsanaText.TaskRow.copy(
                    color = if (done) AsanaColors.TextTertiary else AsanaColors.TextPrimary,
                    textDecoration = if (done) TextDecoration.LineThrough else null,
                ),
            )
            Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                projectTag?.let { Pill(it.first, it.second) }
                due?.let {
                    val style = if (it.second) TagStyle(AsanaColors.Error, AsanaColors.Error.copy(alpha = 0.18f))
                                else TagStyle(AsanaColors.Orange, AsanaColors.Orange.copy(alpha = 0.18f))
                    Pill(it.first, style)
                }
            }
        }

        Box(
            Modifier.size(22.dp).clip(CircleShape).background(assigneeColor),
            contentAlignment = Alignment.Center,
        ) { Text(assigneeInitials, style = AsanaText.Avatar.copy(color = Color.White)) }
    }
    HorizontalDivider(color = AsanaColors.Divider, thickness = 1.dp)
}

@Composable
fun Pill(text: String, style: TagStyle) {
    Box(
        Modifier
            .clip(RoundedCornerShape(999.dp))
            .background(style.tint)
            .padding(horizontal = 9.dp, vertical = 3.dp),
    ) { Text(text, style = AsanaText.Pill.copy(color = style.solid)) }
}

Section Header (collapsible)

import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.ui.draw.rotate

@Composable
fun SectionHeader(title: String, count: Int, expanded: Boolean, onToggle: () -> Unit) {
    val rot by animateFloatAsState(if (expanded) 90f else 0f, tween(150), label = "caret")
    Row(
        Modifier
            .fillMaxWidth()
            .clickable { onToggle() }
            .padding(horizontal = 18.dp).padding(top = 14.dp, bottom = 8.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        Icon(Icons.Filled.ChevronRight, null, tint = AsanaColors.TextSecondary, modifier = Modifier.size(14.dp).rotate(rot))
        Text(title, style = AsanaText.ListLabel.copy(color = AsanaColors.TextPrimary))
        Text("$count", style = AsanaText.Pill.copy(color = AsanaColors.TextTertiary))
    }
}

Status Update Card

enum class ProjectStatus(val label: String, val solid: Color) {
    OnTrack("On track", AsanaColors.Aqua),
    AtRisk("At risk",  AsanaColors.Yellow),
    OffTrack("Off track", AsanaColors.Error),
    Complete("Complete", AsanaColors.Success),
}

@Composable
fun StatusUpdateCard(status: ProjectStatus, postedDate: String, title: String, body: String) {
    Column(
        Modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(12.dp))
            .background(AsanaColors.Canvas)
            .border(1.dp, AsanaColors.Divider, RoundedCornerShape(12.dp))
            .padding(18.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
            Box(
                Modifier.clip(RoundedCornerShape(999.dp)).background(status.solid.copy(alpha = 0.18f))
                    .padding(horizontal = 14.dp, vertical = 6.dp),
            ) { Text(status.label, style = AsanaText.Pill.copy(color = status.solid, fontWeight = FontWeight.Bold)) }
            Text(postedDate, style = AsanaText.Meta.copy(color = AsanaColors.TextSecondary))
        }
        Text(title, style = AsanaText.Section.copy(color = AsanaColors.TextPrimary))
        Text(body, style = AsanaText.Body.copy(color = AsanaColors.TextPrimary))
    }
}

Floating Action Button

import androidx.compose.material.icons.filled.Add

@Composable
fun CreateTaskFab(onClick: () -> Unit) {
    FloatingActionButton(
        onClick = onClick,
        containerColor = AsanaColors.Coral,
        contentColor = Color.White,
        shape = CircleShape,
        modifier = Modifier.size(56.dp),
    ) {
        Icon(Icons.Filled.Add, contentDescription = "Create task", modifier = Modifier.size(24.dp))
    }
}

4. Navigation

Asana has minimal chrome: a 4-tab bottom strip with the coral accent (no Material tint pill) and per-screen view tabs.

import androidx.compose.material.icons.filled.*

@Composable
fun AsanaBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(containerColor = AsanaColors.Canvas, tonalElevation = 0.dp) {
        val items = listOf(
            "Home"     to Icons.Filled.Home,
            "My Tasks" to Icons.Filled.CheckCircle,
            "Inbox"    to Icons.Filled.Notifications,
            "Search"   to Icons.Filled.Search,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(22.dp)) },
                label = { Text(label, style = AsanaText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor = AsanaColors.Coral,
                    selectedTextColor = AsanaColors.Coral,
                    unselectedIconColor = AsanaColors.TextTertiary,
                    unselectedTextColor = AsanaColors.TextTertiary,
                    indicatorColor = Color.Transparent, // no Material pill — Asana has none
                ),
            )
        }
    }
}

The per-screen view switcher (List / Board / Calendar / Timeline / Files) is a Row of clickable labels; the active one gets TextPrimary color plus a 2.dp coral underline drawn with Modifier.drawBehind. The board view is a horizontally scrolling LazyRow of 280.dp columns (SurfaceGray, 12.dp radius) containing draggable cards.

5. Motion

Asana motion is purposeful and warm — the completion circle is the signature moment.

Moment Compose recipe
Task complete circle Animatable scale 0.8 → 1.1 → 1.0 (tween(80/80/60)); checkmark fades in; HapticFeedbackType.LongPress (success analog); name → tertiary + line-through over 150ms
Celebration creature section fully done → Animatable translateX -80 → screenWidth+80 over 1200ms, then alpha → 0
Section collapse/expand caret animateFloatAsState 0 → 90° tween(150); children AnimatedVisibility(expandVertically(tween(200)) + fadeIn())
FAB → new task navigate to a bottom-sheet/full-screen task editor; FAB container scale on press
Board card drag LazyColumn/LazyRow + Modifier.draggable; neighbors animateItemPlacement(tween(200)); soft haptic on drop
View tab switch 2.dp coral underline animateDpAsState translateX 200ms; content Crossfade(tween(150))
Inbox read unread coral dot animateFloatAsState alpha → 0 over 120ms on tap
Page navigation Nav3/NavHost slide push tween(300)
// The canonical Asana moment — completion circle bounce
val scale = remember { Animatable(1f) }
LaunchedEffect(done) {
    if (done) { scale.animateTo(0.8f, tween(80)); scale.animateTo(1.1f, tween(80)); scale.animateTo(1f, tween(60)) }
}

Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) on task complete (success analog), block/card drag start, and section toggle. For a softer tick use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK). Auto-save is silent; only show a “Saved” snackbar on manual save or error.

6. Icons

Asana’s iconography maps cleanly to androidx.compose.material:material-icons-extended. The completion circle is custom-drawn (a CircleShape border + Check), not a stock icon.

Purpose SF Symbol (iOS) Material Icon (Compose)
Home (tab) house / house.fill Icons.Filled.Home
My Tasks (tab) checkmark.circle Icons.Filled.CheckCircle
Inbox (tab) bell / bell.fill Icons.Filled.Notifications
Search (tab) magnifyingglass Icons.Filled.Search
Task complete checkmark.circle.fill Icons.Filled.Check (in circle)
Add / FAB plus Icons.Filled.Add
Section caret chevron.right Icons.Filled.ChevronRight (rotate 90°)
Due date calendar Icons.Filled.CalendarToday
Assignee (empty) person.crop.circle Icons.Filled.AccountCircle
Subtask arrow.turn.down.right Icons.Filled.SubdirectoryArrowRight
Attachment paperclip Icons.Filled.AttachFile
Comment bubble.left Icons.Filled.ChatBubbleOutline
More ellipsis Icons.Filled.MoreHoriz
Back chevron.left Icons.Filled.ArrowBack
Filter line.3.horizontal.decrease Icons.Filled.FilterList
Board view rectangle.split.3x1 Icons.Filled.ViewKanban
Calendar view calendar Icons.Filled.CalendarMonth
Timeline view chart.bar.xaxis Icons.Filled.Timeline

7. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; modern motion comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); light canvas wants dark-content system bars (light-content in dark mode). The FAB sits above the NavigationBar and respects the gesture inset; the task editor uses Modifier.imePadding().
  • Font scaling: sp honors the user’s font scale — keep it on screen title, headings, body, task rows. Pin layout-sensitive text (pills, 10sp tab labels, list section labels, avatar initials) by deriving from dp or CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). Add fontFeatureSettings = "tnum" (via TextStyle) on due-dates/counts.
  • TalkBack: the completion circle and the row body are distinct nodes — circle contentDescription = "Mark complete, {task}" (and “Completed” when done); row Modifier.semantics { role = Role.Button } opening detail. Expose swipe actions via Modifier.semantics { customActions = listOf(...) }. Label status badges with their full status word.
  • Touch targets: Material guidance is 48.dp — give the 20.dp completion circle and 14.dp caret a 48.dp hit area via padding; task rows are full-row tappable; the FAB is 56.dp; primary buttons ≥ 36.dp.
  • Contrast: #1E1F21 on #FFFFFF and #F5F4F2 on #1E1F21 pass WCAG AA. Tag pills use saturated text on a ~16% tint — validate yellow (#F8DF72 on a light yellow tint may fail; use a deeper tint or darker text for the yellow/at-risk badge).
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the completion-circle bounce and the celebration creature — substitute a plain Crossfade fill; keep the success haptic and the selection state.
  • Dark mode: invert via the Dark* palette — #1E1F21, NOT true black; #1E1F21 text becomes #F5F4F2; bump object-tag tint alpha to ~25%. Shadows are nearly invisible on dark, so the 1dp DarkCardBorder on board cards is the elevation cue; the FAB keeps its coral surface. Do not enable Material You dynamicColorScheme() — Asana’s single-coral identity must hold regardless of wallpaper.
All mobile designs →

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