Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Atomic Design, Enforced: Scaling Mobile Design ...

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on. →

Atomic Design, Enforced: Scaling Mobile Design Systems (next.app devCon / droidCon Berlin 2026)

Design systems are easy to start but hard to scale. As your codebase and team grow, an unstructured design system can become a bottleneck when building new features. Components get duplicated, styling decisions leak into the UI layer, and inconsistencies spread across the app, degrading the user experience.

This raises a few questions every mobile team has to answer: How do you organize your design system into layers of reusable components? How do you decide when a component belongs in the UI layer versus the design system? And how do you prevent AI agents from re-creating existing UI components?

In this session, we will show how we adapted Atomic Design in our production Android apps using Jetpack Compose. You will learn how to organize your design system into primitives, tokens, atoms, molecules, organisms, and templates, enforce clear boundaries with lint rules, and create workflows for AI agents to implement consistent UI that follows your design system rules.

Avatar for Stelios Frantzeskakis

Stelios Frantzeskakis PRO

October 06, 2026

More Decks by Stelios Frantzeskakis

Other Decks in Programming

Transcript

  1. Atomic Design, Enforced: Scaling Mobile Design Systems Stelios Frantzeskakis •

    Liudas Survila Perry Street Software next.app devCon / droidCon Berlin 2026
  2. Stelios Frantzeskakis / Liudas Survila Staff / Senior Software Engineer

    at Perry Street Software Publisher of SCRUFF, Jack’d, & Growlr serving more than 100M members Passionate about architecture, testing, and design systems @SteliosFran / @Liudas-Survila
  3. Screens UI layer, one module per feature Grid Profile Favorites

    Account depends on Design System one module, six layers Primitives Tokens Atoms Molecules Organisms Templates
  4. Primitives Tokens Atoms Molecules Organisms Templates raw values primitives with

    meaning smallest component atoms combined sections of a screen page layout, slots only
  5. @Composable fun ProfileViewScreen(profile: DogProfile) { // ... photo, top bar,

    favorite and overflow actions Box( modifier = Modifier .fillMaxSize() .background(Brush.verticalGradient(listOf(Color.Transparent, Color(0xD9000000)))), ) Column(modifier = Modifier.padding(horizontal = 20.dp)) { Text( text = profile.dog.name, fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color.White, ) Spacer(modifier = Modifier.height(4.dp)) Text( text = "${profile.ageInYears} years · ${profile.breed} · ${profile.neighborhood}", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color(0x99FFFFFF), ) Spacer(modifier = Modifier.height(12.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { profile.personality.forEach { tag -> Text( text = tag, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color.White, modifier = Modifier .background(Color(0x40FFFFFF), RoundedCornerShape(20.dp)) .padding(horizontal = 12.dp, vertical = 4.dp), ) } } } // ... about, personality, and details sections // ... message bar, woof button, dialogs, toasts }
  6. @Composable fun ProfileTag(text: String) { Text( text = text, fontSize

    = 12.sp, color = Color.White, modifier = Modifier .background(Color(0x40FFFFFF), RoundedCornerShape(20.dp)) .padding(horizontal = 12.dp, vertical = 4.dp), ) } @Composable fun PersonalityChip(label: String) { Text( text = label, fontSize = 13.sp, color = Color(0xFF121016), modifier = Modifier .background(Color(0xFFEADFD0), RoundedCornerShape(16.dp)) .padding(horizontal = 10.dp, vertical = 6.dp), ) } @Composable fun DogBadge(title: String) { Text( text = title, fontSize = 11.sp, color = Color.White, modifier = Modifier .background(Color(0x33FFFFFF), CircleShape) .padding(horizontal = 8.dp, vertical = 2.dp), ) }
  7. @Composable fun InterestChip( label: String, modifier: Modifier = Modifier, backgroundColor:

    Color = Color(0xFFF5F5F5), textColor: Color = Color(0xFF333333), ) { Surface( modifier = modifier, shape = RoundedCornerShape(16.dp), color = backgroundColor, ) { Text( text = label, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), style = MaterialTheme.typography.labelMedium, color = textColor, ) } }
  8. object ColorPrimitives { object White { val _15: Color =

    Color(0x26FFFFFF) val _25: Color = Color(0x40FFFFFF) val _60: Color = Color(0x99FFFFFF) val _85: Color = Color(0xD9FFFFFF) val _100: Color = Color(0xFFFFFFFF) } // Black, Sand, Ink, Orange, Teal, Honey, Red, Green }
  9. object TypographyPrimitives { object Bold { val Size32 = style(size

    = 32, weight = FontWeight.Bold) // Size24 ... Size12 } object SemiBold { // Size16 val Size14 = style(size = 14, weight = FontWeight.SemiBold) val Size12 = style(size = 12, weight = FontWeight.SemiBold) } // Regular }
  10. object ColorRoles { val Light = Colors( // ... onScrim

    = ColorPrimitives.White._100, onScrimVariant = ColorPrimitives.White._60, scrimContainer = ColorPrimitives.White._25, // ... ) // val Dark = Colors(...) }
  11. object TypographyRoles { val Default = Typography( display = Typography.Display(

    h1 = TypographyPrimitives.Bold.Size32, // h2 ... h6 ), subhead = Typography.Subhead( // p1 p2 = TypographyPrimitives.SemiBold.Size14, p3 = TypographyPrimitives.SemiBold.Size12, ), // body ) }
  12. @Composable fun AtomText( text: String, textFontRole: TextFontRole, modifier: Modifier =

    Modifier, colorRole: TextColorRole = TextColorRole.OnSurface, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Ellipsis, textAlign: TextAlign? = null, ) { Text( text = text, modifier = modifier, style = textFontRole.textStyle(), color = colorRole.color(), maxLines = maxLines, overflow = overflow, textAlign = textAlign, ) }
  13. @Composable fun AtomTag( text: String, styleRole: TagStyleRole = TagStyleRole.Neutral, )

    { Text( text = text, style = Theme.typography.subhead.p3, color = styleRole.textColor(), maxLines = 1, modifier = Modifier .background( color = styleRole.backgroundColor(), shape = RoundedCornerShape(Theme.radius.xl), ) .padding( horizontal = PaddingRoles.Element.Relaxed.dp, vertical = PaddingRoles.Element.Compact.dp, ), ) }
  14. @Composable fun MolTitleSubtitle( title: String, subtitle: String, ) { Column(

    verticalArrangement = Arrangement.spacedBy(SpacingRoles.Component.ExtraCompact.dp) ) { AtomText( text = title, textFontRole = TextFontRole.DisplayH1, colorRole = TextColorRole.OnScrim, maxLines = 1, ) AtomText( text = subtitle, textFontRole = TextFontRole.SubheadP2, colorRole = TextColorRole.OnScrimVariant, maxLines = 2, ) } }
  15. @Composable fun MolTagGroup( tags: List<String>, styleRole: TagStyleRole = TagStyleRole.OnScrim, )

    { FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(SpacingRoles.Component.Compact.dp), verticalArrangement = Arrangement.spacedBy(SpacingRoles.Component.Compact.dp), ) { tags.forEach { tag -> AtomTag(text = tag, styleRole = styleRole) } } }
  16. @Composable fun OrgHeroSummary( title: String, subtitle: String?, tags: List<String>, )

    { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(SpacingRoles.Component.Cozy.dp), ) { when (subtitle) { null -> { /* loading placeholders */ } else -> { MolTitleSubtitle(title = title, subtitle = subtitle) MolTagGroup(tags = tags) } } } }
  17. @Composable fun TemplateHeroDetails( hero: @Composable (dimProgress: () -> Float) ->

    Unit, topBar: @Composable () -> Unit, summary: @Composable () -> Unit, bottomBar: @Composable () -> Unit, overlay: @Composable () -> Unit, details: LazyListScope.() -> Unit, ) { // ... }
  18. @Composable fun ProfileViewScreen( page: ProfilePageUIModel, headerState: ProfileHeaderViewModel.State, detailsState: ProfileDetailsUIModel, //

    ... more state and callbacks ) { TemplateHeroDetails( hero = { dimProgress -> OrgHeroPhoto( imageState = rememberAsyncImageState(url = page.photoUrl), // ... dimProgress = dimProgress, ) }, topBar = { OrgNavigationHeaderOverlay(/* ... */) }, summary = { OrgHeroSummary( title = detailsState.name, subtitle = detailsState.summary?.text(), tags = detailsState.heroTags, ) }, bottomBar = { OrgTypeBarWithAction(/* ... */) }, overlay = { TemplateOverlayTop { /* toasts */ } ProfileModerationDialog(/* ... */) }, ) { // … } }
  19. class DesignSystemAtomicDependencies : BehaviorSpec() { init { Given("A file in

    the atomic design system") { val files = KonsistUtils.atomicDesignModule.files Then("Tokens depend only on primitives") { files.inLayer("_tokens").assertFalse(message = Message) { file -> file.importsLayer("atoms", "molecules", "organisms", "templates") } } Then("Atoms depend only on tokens") { files.inLayer("atoms").assertFalse(message = Message) { file -> file.importsLayer("_primitives", "molecules", "organisms", "templates") } } Then("Molecules depend only on atoms and tokens") { files.inLayer("molecules").assertFalse(message = Message) { file -> file.importsLayer("_primitives", "organisms", "templates") } } // Organisms and Templates dependencies } } }
  20. class AtomicComponentsUseLayerPrefix : BehaviorSpec() { init { Given("A public composable

    in the atomic design system") { val composables = KonsistUtils.atomicDesignModule.functions() .withAnnotationNamed("Composable") .withPublicOrDefaultModifier() .filter { it.isTopLevel } Then("Its name carries the prefix of its layer") { composables.assertTrue(message = Message) { composable -> LayerPrefixes.any { (layer, prefix) -> composable.path.contains("/atomic/$layer/") && composable.hasPrefix(prefix) } } } } } private companion object { private val LayerPrefixes = listOf( "atoms" to "Atom", "molecules" to "Mol", "organisms" to "Org", "templates" to "Template", ) } }
  21. class ScreensUseDesignSystemComponents : BehaviorSpec() { init { Given("A screen Composable

    in a feature module") { val files = KonsistUtils.featureModules.files .filter { it.hasPackage("com.perrystreet.woof.presentation..ui..") } Then("It does not import raw Compose components, Modifier, or design tokens") { files.assertFalse(message = Message) { file -> file.hasImport { import -> ForbiddenImportPrefixes.any { import.name.startsWith(it) } } } } } } private companion object { private val ForbiddenImportPrefixes = listOf( "androidx.compose.material3", "androidx.compose.foundation.Image", "androidx.compose.ui.Modifier", "com.perrystreet.woof.designsystem.atomic._tokens", “com.perrystreet.woof.designsystem.atomic._primitives", // ... ) } }
  22. class TemplatesDoNotExposeModifiers : BehaviorSpec() { init { Given("A public Template

    composable") { val templates = KonsistUtils.atomicDesignModule.functions() .withAnnotationNamed("Composable") .withPublicOrDefaultModifier() .filter { it.path.contains("/atomic/templates/") } Then("It does not take a Modifier parameter") { templates.assertFalse(message = Message) { template -> template.parameters.any { it.type.name == "Modifier" } } } Then("It exposes at least one slot") { templates.assertFalse(message = Message) { template -> template.parameters.none { it.type.text.contains("@Composable") || it.type.text.contains("Scope.()") } } } } } }
  23. class MoleculesMustCombineAtLeastTwoAtoms : BehaviorSpec() { init { Given("A molecule composable")

    { val atomNames = KonsistUtils.atomicDesignModule.functions() .withAnnotationNamed("Composable") .filter { it.path.contains("/atoms/") } .withNameStartingWith("Atom") .map { it.name } .toSet() Then("It combines at least two atoms") { molecules.assertTrue(message = Message) { molecule -> val source = molecule.text val atomUsages = atomNames.sumOf { atom -> source.split("$atom(").size - 1 } val iterations = IterationRegexes.sumOf { it.findAll(source).count() } atomUsages + iterations >= 2 } } } } }
  24. class AtomsDoNotComposeOtherAtoms : BehaviorSpec() { init { Given("An atom composable")

    { val atoms = KonsistUtils.atomicDesignModule.functions() .withAnnotationNamed("Composable") .filter { it.path.contains("/atomic/atoms/") } Then("It does not call another atom") { atoms.assertFalse(message = Message) { atom -> AtomCallRegex.containsMatchIn(atom.body) } } } } private companion object { private val AtomCallRegex = Regex("""\bAtom[A-Z]\w*\(""") } }
  25. class UIComponentsDoNotUseHardcodedValues : BehaviorSpec() { init { Given("A composable outside

    the primitives") { val composables = KonsistUtils.productionCode.functions() .withAnnotationNamed("Composable") .filter { !it.path.contains("/_primitives/") } Then("It does not hardcode dp or sp values") { composables.assertFalse(message = DpMessage) { HardcodedDpSpRegex.containsMatchIn(it.text) } } Then("It does not hardcode colors") { composables.assertFalse(message = ColorMessage) { HardcodedColorRegex.containsMatchIn(it.text) } } } } private companion object { private val HardcodedDpSpRegex = Regex("""[1-9]\d*\.(dp|sp)\b""") private val HardcodedColorRegex = Regex("""Color\(0x""") } }
  26. AI

  27. --name: atomic-design description: How the WoofAndroid design system is built

    … --## 1. The layers | _primitives/ | Raw values: colors, type, spacing, sizing | | _tokens/ | Primitives with a meaning, read through Theme | | atoms/ | Atom*: one element on Compose primitives | | molecules/ | Mol*: a group of two or more atoms | | organisms/ | Org*: a section of molecules, atoms, organisms | | templates/ | Template*: a page layout with slots for content | ## 2. Tokens, roles and states … ## 3. Find what exists … ## 4. Add a component 1. Put it in the lowest layer that fits, with that layer's prefix. 2. Build it from the layers below it and from tokens. 3. Name it by its shape and its parameters by the data they hold, so every feature can use it. …
  28. --name: implement-screen description: Build a WoofAndroid feature from a Figma

    frame … --## 1. Read the design With the Figma MCP, fetch the frame's design context, its variable bindings and a screenshot. … ## 2. Map every Figma element to a component, before writing code Figma variables and text styles carry the same names as the tokens in `_tokens/` and the roles next to each component, so an element's tokens identify the component that draws it. … Add each new one the way the atomic-design skill describes. ## 3. Build every layer … ## 4. Check until it passes and matches the design The script builds the app and runs the unit tests and Konsist rules; once those pass, it installs the app on the emulator, taps through the labels and saves a screenshot. Run it again after every fix.
  29. --name: run-lint-rules description: Run WoofAndroid's Konsist lint rules and fix

    what they report … --… Before writing code in an area, read its rules: they say how that part is built. ## 1. Run them ./gradlew runKonsistTests --continue -q ## 2. Fix what fails Every failing rule prints RULE / WHY / HOW TO FIX / BAD / GOOD. Change the code the way HOW TO FIX says. Each rule encodes an architecture decision, so the code moves and the rules stay as they are. ## 3. Repeat Run them again after every fix. The task is done when every rule passes.
  30. --name: android-cli description: Provides instructions for installing and using the

    `android` CLI … metadata: author: Google LLC --… ## Managing emulators Manage Android Virtual Devices (AVDs) using the `android emulator` command ## Running APKs Use the `run` command to run Android apps. ## Interacting with Android devices Use the `android layout` command to inspect the UI layout of an Android application in JSON format. Use the `android screen` command to visually inspect the UI and obtain bounding coordinates for visual regions. ## Capturing screenshots Capture an image of the current screen of a connected Android device and output it to a file using the `android screen` command.
  31. @Composable fun OrgAvatarRowWithAction( title: String, subtitle: String, imageState: AsyncImageState, //

    ... action icon, color and tap ) { Row( modifier = Modifier.fillMaxWidth(), // ... ) { OrgAvatarRow( title = title, subtitle = subtitle, imageState = imageState, contentDescription = contentDescription, modifier = Modifier.weight(1f), ), AtomIconButton( iconRes = actionIconRes, contentDescription = actionContentDescription, colorRole = actionColorRole, backgroundRole = ButtonBackgroundRole.None, onTap = onActionTap, ) } }
  32. @Composable fun WoofsScreen( state: WoofsViewModel.State.Loaded, onWoofBackTap: (ReceivedWoofUIModel) -> Unit, )

    { TemplateScrollableContent( topBar = { OrgNavigationHeaderTitle(title = /* ... */) } ) { items(items = state.woofs, key = { woof -> woof.id }) { woof -> OrgAvatarRowWithAction( title = woof.name, subtitle = woof.age.text(), imageState = rememberAsyncImageState(url = woof.photoUrl), contentDescription = /* ... */, actionIconRes = woof.actionIconRes(), actionContentDescription = /* ... */, actionColorRole = woof.actionColorRole(), onActionTap = { onWoofBackTap(woof) }, ) } } }
  33. Resources Slide deck • https://speakerdeck.com/steliosf/atomic-design-enforced Woof demo • https://github.com/perrystreetsoftware/woof-demo Atomic

    Design by Brad Frost • https://atomicdesign.bradfrost.com/chapter-2 Start Enforcing with Lint Rules • https://youtu.be/SC9EBxjwoCE Turning Architecture into Unit Tests • https://youtu.be/Xe3ZFIapIpo Perry Street Engineering • https://medium.com/perry-street-software-engineering @SteliosFran • https://www.linkedin.com/in/SteliosFran @Liudas-Survila • https://www.linkedin.com/in/Liudas-Survila
  34. Did we break bad after all? We cooked The linter

    says it’s pure And now the agents cook too